AI Analysis and Content Strategy fixes. Enhanced Strategy Routes refactoring.
This commit is contained in:
9
backend/api/research/handlers/__init__.py
Normal file
9
backend/api/research/handlers/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""
|
||||
Research API Handlers
|
||||
|
||||
Handler modules for research endpoints.
|
||||
"""
|
||||
|
||||
from . import providers, research, intent, projects
|
||||
|
||||
__all__ = ["providers", "research", "intent", "projects"]
|
||||
394
backend/api/research/handlers/intent.py
Normal file
394
backend/api/research/handlers/intent.py
Normal file
@@ -0,0 +1,394 @@
|
||||
"""
|
||||
Intent-Driven Research Handler
|
||||
|
||||
Handles intent analysis and intent-driven research endpoints.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from typing import Dict, Any
|
||||
from loguru import logger
|
||||
import asyncio
|
||||
|
||||
from services.database import get_db
|
||||
from services.research.core import (
|
||||
ResearchEngine,
|
||||
ResearchContext,
|
||||
ResearchPersonalizationContext,
|
||||
ResearchGoal,
|
||||
ResearchDepth,
|
||||
ProviderPreference,
|
||||
)
|
||||
from middleware.auth_middleware import get_current_user
|
||||
from models.research_intent_models import (
|
||||
ResearchIntent,
|
||||
ResearchQuery,
|
||||
ExpectedDeliverable,
|
||||
)
|
||||
from services.research.intent import (
|
||||
ResearchIntentInference,
|
||||
IntentQueryGenerator,
|
||||
IntentAwareAnalyzer,
|
||||
)
|
||||
from ..models import (
|
||||
AnalyzeIntentRequest,
|
||||
AnalyzeIntentResponse,
|
||||
IntentDrivenResearchRequest,
|
||||
IntentDrivenResearchResponse,
|
||||
)
|
||||
from ..utils import (
|
||||
map_purpose_to_goal,
|
||||
map_depth_to_engine_depth,
|
||||
map_provider_to_preference,
|
||||
merge_trends_data,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/intent/analyze", response_model=AnalyzeIntentResponse)
|
||||
async def analyze_research_intent(
|
||||
request: AnalyzeIntentRequest,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Analyze user input to understand research intent.
|
||||
|
||||
This endpoint uses AI to infer what the user really wants from their research:
|
||||
- What questions need answering
|
||||
- What deliverables they expect (statistics, quotes, case studies, etc.)
|
||||
- What depth and focus is appropriate
|
||||
|
||||
The response includes quick options that can be shown in the UI for user confirmation.
|
||||
"""
|
||||
try:
|
||||
if not current_user:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
user_id = str(current_user.get('id', ''))
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="Invalid user ID")
|
||||
|
||||
logger.info(f"[Intent API] Analyzing intent for: {request.user_input[:50]}...")
|
||||
|
||||
# Get research persona if requested
|
||||
research_persona = None
|
||||
competitor_data = None
|
||||
|
||||
if request.use_persona or request.use_competitor_data:
|
||||
from services.research.research_persona_service import ResearchPersonaService
|
||||
from services.onboarding.database_service import OnboardingDatabaseService
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
# Get database session
|
||||
db = next(get_db())
|
||||
try:
|
||||
persona_service = ResearchPersonaService(db)
|
||||
onboarding_service = OnboardingDatabaseService(db=db)
|
||||
|
||||
if request.use_persona:
|
||||
research_persona = persona_service.get_or_generate(user_id)
|
||||
|
||||
if request.use_competitor_data:
|
||||
competitor_data = onboarding_service.get_competitor_analysis(user_id, db)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# Use Unified Research Analyzer (single AI call for intent + queries + params)
|
||||
from services.research.intent.unified_research_analyzer import UnifiedResearchAnalyzer
|
||||
|
||||
analyzer = UnifiedResearchAnalyzer()
|
||||
unified_result = await analyzer.analyze(
|
||||
user_input=request.user_input,
|
||||
keywords=request.keywords,
|
||||
research_persona=research_persona,
|
||||
competitor_data=competitor_data,
|
||||
industry=research_persona.default_industry if research_persona else None,
|
||||
target_audience=research_persona.default_target_audience if research_persona else None,
|
||||
user_id=user_id,
|
||||
user_provided_purpose=request.user_provided_purpose,
|
||||
user_provided_content_output=request.user_provided_content_output,
|
||||
user_provided_depth=request.user_provided_depth,
|
||||
)
|
||||
|
||||
if not unified_result.get("success", False):
|
||||
logger.warning("Unified analysis failed, using fallback")
|
||||
|
||||
# Extract results
|
||||
intent = unified_result.get("intent")
|
||||
queries = unified_result.get("queries", [])
|
||||
exa_config = unified_result.get("exa_config", {})
|
||||
tavily_config = unified_result.get("tavily_config", {})
|
||||
trends_config = unified_result.get("trends_config", {}) # NEW: Google Trends config
|
||||
|
||||
# Build optimized config with AI-driven justifications
|
||||
optimized_config = {
|
||||
"provider": unified_result.get("recommended_provider", "exa"),
|
||||
"provider_justification": unified_result.get("provider_justification", ""),
|
||||
# Exa settings with justifications
|
||||
"exa_type": exa_config.get("type", "auto"),
|
||||
"exa_type_justification": exa_config.get("type_justification", ""),
|
||||
"exa_category": exa_config.get("category"),
|
||||
"exa_category_justification": exa_config.get("category_justification", ""),
|
||||
"exa_include_domains": exa_config.get("includeDomains", []),
|
||||
"exa_include_domains_justification": exa_config.get("includeDomains_justification", ""),
|
||||
"exa_num_results": exa_config.get("numResults", 10),
|
||||
"exa_num_results_justification": exa_config.get("numResults_justification", ""),
|
||||
"exa_date_filter": exa_config.get("startPublishedDate"),
|
||||
"exa_date_justification": exa_config.get("date_justification", ""),
|
||||
"exa_highlights": exa_config.get("highlights", True),
|
||||
"exa_highlights_justification": exa_config.get("highlights_justification", ""),
|
||||
"exa_context": exa_config.get("context", True),
|
||||
"exa_context_justification": exa_config.get("context_justification", ""),
|
||||
# Tavily settings with justifications
|
||||
"tavily_topic": tavily_config.get("topic", "general"),
|
||||
"tavily_topic_justification": tavily_config.get("topic_justification", ""),
|
||||
"tavily_search_depth": tavily_config.get("search_depth", "advanced"),
|
||||
"tavily_search_depth_justification": tavily_config.get("search_depth_justification", ""),
|
||||
"tavily_include_answer": tavily_config.get("include_answer", True),
|
||||
"tavily_include_answer_justification": tavily_config.get("include_answer_justification", ""),
|
||||
"tavily_time_range": tavily_config.get("time_range"),
|
||||
"tavily_time_range_justification": tavily_config.get("time_range_justification", ""),
|
||||
"tavily_max_results": tavily_config.get("max_results", 10),
|
||||
"tavily_max_results_justification": tavily_config.get("max_results_justification", ""),
|
||||
"tavily_raw_content": tavily_config.get("include_raw_content", "markdown"),
|
||||
"tavily_raw_content_justification": tavily_config.get("include_raw_content_justification", ""),
|
||||
}
|
||||
|
||||
# Build trends config response (if enabled)
|
||||
trends_config_response = None
|
||||
if trends_config.get("enabled", False):
|
||||
trends_config_response = {
|
||||
"enabled": True,
|
||||
"keywords": trends_config.get("keywords", []),
|
||||
"keywords_justification": trends_config.get("keywords_justification", ""),
|
||||
"timeframe": trends_config.get("timeframe", "today 12-m"),
|
||||
"timeframe_justification": trends_config.get("timeframe_justification", ""),
|
||||
"geo": trends_config.get("geo", "US"),
|
||||
"geo_justification": trends_config.get("geo_justification", ""),
|
||||
"expected_insights": trends_config.get("expected_insights", []),
|
||||
}
|
||||
|
||||
return AnalyzeIntentResponse(
|
||||
success=True,
|
||||
intent=intent.dict() if hasattr(intent, 'dict') else intent,
|
||||
analysis_summary=unified_result.get("analysis_summary", ""),
|
||||
suggested_queries=[q.dict() if hasattr(q, 'dict') else q for q in queries],
|
||||
suggested_keywords=unified_result.get("enhanced_keywords", []),
|
||||
suggested_angles=unified_result.get("research_angles", []),
|
||||
quick_options=[], # Deprecated in unified approach
|
||||
confidence_reason=intent.confidence_reason if hasattr(intent, 'confidence_reason') else "",
|
||||
great_example=intent.great_example if hasattr(intent, 'great_example') else "",
|
||||
optimized_config=optimized_config,
|
||||
recommended_provider=unified_result.get("recommended_provider", "exa"),
|
||||
trends_config=trends_config_response, # NEW: Google Trends configuration
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Intent API] Analyze failed: {e}")
|
||||
return AnalyzeIntentResponse(
|
||||
success=False,
|
||||
intent={},
|
||||
analysis_summary="",
|
||||
suggested_queries=[],
|
||||
suggested_keywords=[],
|
||||
suggested_angles=[],
|
||||
quick_options=[],
|
||||
confidence_reason=None,
|
||||
great_example=None,
|
||||
error_message=str(e),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/intent/research", response_model=IntentDrivenResearchResponse)
|
||||
async def execute_intent_driven_research(
|
||||
request: IntentDrivenResearchRequest,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Execute research based on user intent.
|
||||
|
||||
This is the main endpoint for intent-driven research. It:
|
||||
1. Uses the confirmed intent (or infers from user_input if not provided)
|
||||
2. Generates targeted queries for each expected deliverable
|
||||
3. Executes research using Exa/Tavily/Google
|
||||
4. Analyzes results through the lens of user intent
|
||||
5. Returns exactly what the user needs
|
||||
|
||||
The response is organized by deliverable type (statistics, quotes, case studies, etc.)
|
||||
instead of generic search results.
|
||||
"""
|
||||
try:
|
||||
if not current_user:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
user_id = str(current_user.get('id', ''))
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="Invalid user ID")
|
||||
|
||||
logger.info(f"[Intent API] Executing intent-driven research for: {request.user_input[:50]}...")
|
||||
|
||||
# Get database session
|
||||
db = next(get_db())
|
||||
|
||||
try:
|
||||
# Get research persona
|
||||
from services.research.research_persona_service import ResearchPersonaService
|
||||
persona_service = ResearchPersonaService(db)
|
||||
research_persona = persona_service.get_or_generate(user_id)
|
||||
|
||||
# Determine intent
|
||||
if request.confirmed_intent:
|
||||
# Use confirmed intent from UI
|
||||
intent = ResearchIntent(**request.confirmed_intent)
|
||||
elif not request.skip_inference:
|
||||
# Infer intent from user input
|
||||
intent_service = ResearchIntentInference()
|
||||
intent_response = await intent_service.infer_intent(
|
||||
user_input=request.user_input,
|
||||
research_persona=research_persona,
|
||||
user_id=user_id,
|
||||
)
|
||||
intent = intent_response.intent
|
||||
else:
|
||||
# Create basic intent from input
|
||||
intent = ResearchIntent(
|
||||
primary_question=f"What are the key insights about: {request.user_input}?",
|
||||
purpose="learn",
|
||||
content_output="general",
|
||||
expected_deliverables=["key_statistics", "best_practices", "examples"],
|
||||
depth="detailed",
|
||||
original_input=request.user_input,
|
||||
confidence=0.6,
|
||||
)
|
||||
|
||||
# Generate or use provided queries
|
||||
if request.selected_queries:
|
||||
queries = [ResearchQuery(**q) for q in request.selected_queries]
|
||||
else:
|
||||
query_generator = IntentQueryGenerator()
|
||||
query_result = await query_generator.generate_queries(
|
||||
intent=intent,
|
||||
research_persona=research_persona,
|
||||
user_id=user_id,
|
||||
)
|
||||
queries = query_result.get("queries", [])
|
||||
|
||||
# Execute research using the Research Engine
|
||||
engine = ResearchEngine(db_session=db)
|
||||
|
||||
# Build context from intent
|
||||
personalization = ResearchPersonalizationContext(
|
||||
creator_id=user_id,
|
||||
industry=research_persona.default_industry if research_persona else None,
|
||||
target_audience=research_persona.default_target_audience if research_persona else None,
|
||||
)
|
||||
|
||||
# Use the highest priority query for the main search
|
||||
# (In a more advanced version, we could run multiple queries and merge)
|
||||
primary_query = queries[0] if queries else ResearchQuery(
|
||||
query=request.user_input,
|
||||
purpose=ExpectedDeliverable.KEY_STATISTICS,
|
||||
provider="exa",
|
||||
priority=5,
|
||||
expected_results="General research results",
|
||||
)
|
||||
|
||||
context = ResearchContext(
|
||||
query=primary_query.query,
|
||||
keywords=request.user_input.split()[:10],
|
||||
goal=map_purpose_to_goal(intent.purpose),
|
||||
depth=map_depth_to_engine_depth(intent.depth),
|
||||
provider_preference=map_provider_to_preference(primary_query.provider),
|
||||
personalization=personalization,
|
||||
max_sources=request.max_sources,
|
||||
include_domains=request.include_domains,
|
||||
exclude_domains=request.exclude_domains,
|
||||
)
|
||||
|
||||
# Execute research and trends in parallel
|
||||
research_task = asyncio.create_task(engine.research(context))
|
||||
|
||||
# Execute Google Trends analysis in parallel (if enabled)
|
||||
trends_task = None
|
||||
trends_data = None
|
||||
if request.trends_config and request.trends_config.get("enabled"):
|
||||
from services.research.trends.google_trends_service import GoogleTrendsService
|
||||
trends_service = GoogleTrendsService()
|
||||
trends_task = asyncio.create_task(
|
||||
trends_service.analyze_trends(
|
||||
keywords=request.trends_config.get("keywords", []),
|
||||
timeframe=request.trends_config.get("timeframe", "today 12-m"),
|
||||
geo=request.trends_config.get("geo", "US"),
|
||||
user_id=user_id
|
||||
)
|
||||
)
|
||||
|
||||
# Wait for research to complete
|
||||
raw_result = await research_task
|
||||
|
||||
# Wait for trends if it was started
|
||||
if trends_task:
|
||||
try:
|
||||
trends_data = await trends_task
|
||||
logger.info(f"Google Trends data fetched: {len(trends_data.get('interest_over_time', []))} time points")
|
||||
except Exception as e:
|
||||
logger.error(f"Google Trends analysis failed: {e}")
|
||||
trends_data = None
|
||||
|
||||
# Analyze results using intent-aware analyzer
|
||||
analyzer = IntentAwareAnalyzer()
|
||||
analyzed_result = await analyzer.analyze(
|
||||
raw_results={
|
||||
"content": raw_result.raw_content or "",
|
||||
"sources": raw_result.sources,
|
||||
"grounding_metadata": raw_result.grounding_metadata,
|
||||
},
|
||||
intent=intent,
|
||||
research_persona=research_persona,
|
||||
user_id=user_id, # Required for subscription checking
|
||||
)
|
||||
|
||||
# Merge Google Trends data into trends analysis
|
||||
if trends_data and analyzed_result.trends:
|
||||
analyzed_result = merge_trends_data(analyzed_result, trends_data)
|
||||
|
||||
# Build response
|
||||
return IntentDrivenResearchResponse(
|
||||
success=True,
|
||||
primary_answer=analyzed_result.primary_answer,
|
||||
secondary_answers=analyzed_result.secondary_answers,
|
||||
focus_areas_coverage=analyzed_result.focus_areas_coverage,
|
||||
also_answering_coverage=analyzed_result.also_answering_coverage,
|
||||
statistics=[s.dict() for s in analyzed_result.statistics],
|
||||
expert_quotes=[q.dict() for q in analyzed_result.expert_quotes],
|
||||
case_studies=[cs.dict() for cs in analyzed_result.case_studies],
|
||||
trends=[t.dict() for t in analyzed_result.trends],
|
||||
comparisons=[c.dict() for c in analyzed_result.comparisons],
|
||||
best_practices=analyzed_result.best_practices,
|
||||
step_by_step=analyzed_result.step_by_step,
|
||||
pros_cons=analyzed_result.pros_cons.dict() if analyzed_result.pros_cons else None,
|
||||
definitions=analyzed_result.definitions,
|
||||
examples=analyzed_result.examples,
|
||||
predictions=analyzed_result.predictions,
|
||||
executive_summary=analyzed_result.executive_summary,
|
||||
key_takeaways=analyzed_result.key_takeaways,
|
||||
suggested_outline=analyzed_result.suggested_outline,
|
||||
sources=[s.dict() for s in analyzed_result.sources],
|
||||
confidence=analyzed_result.confidence,
|
||||
gaps_identified=analyzed_result.gaps_identified,
|
||||
follow_up_queries=analyzed_result.follow_up_queries,
|
||||
intent=intent.dict(),
|
||||
google_trends_data=trends_data, # Include Google Trends data in response
|
||||
)
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Intent API] Research failed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return IntentDrivenResearchResponse(
|
||||
success=False,
|
||||
error_message=str(e),
|
||||
)
|
||||
269
backend/api/research/handlers/projects.py
Normal file
269
backend/api/research/handlers/projects.py
Normal file
@@ -0,0 +1,269 @@
|
||||
"""
|
||||
Research Project Handler
|
||||
|
||||
CRUD operations for research projects.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional, Dict, Any
|
||||
from loguru import logger
|
||||
import uuid
|
||||
from sqlalchemy import func
|
||||
|
||||
from services.database import get_db
|
||||
from middleware.auth_middleware import get_current_user
|
||||
from services.research_service import ResearchService
|
||||
from models.research_models import ResearchProject
|
||||
from ..models import (
|
||||
SaveResearchProjectRequest,
|
||||
SaveResearchProjectResponse,
|
||||
ResearchProjectResponse,
|
||||
ResearchProjectListResponse,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/projects/save", response_model=SaveResearchProjectResponse)
|
||||
async def save_research_project(
|
||||
request: SaveResearchProjectRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Save a research project to database.
|
||||
|
||||
This endpoint saves the complete research project state to the database,
|
||||
allowing users to resume research later. Similar to podcast projects.
|
||||
Uses database storage instead of file-based storage for production reliability.
|
||||
"""
|
||||
try:
|
||||
if not current_user:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
user_id = str(current_user.get('id', ''))
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="Invalid user ID")
|
||||
|
||||
logger.info(f"[Research Projects] Saving project: {request.title[:50] if request.title else 'Untitled'}...")
|
||||
|
||||
service = ResearchService(db)
|
||||
|
||||
# Check if this is an update (project_id provided) or new project
|
||||
project_id = request.project_id if request.project_id else str(uuid.uuid4())
|
||||
existing_project = service.get_project(user_id, project_id)
|
||||
|
||||
# Determine status based on completion
|
||||
status = "completed" if (request.intent_result or request.legacy_result) else "in_progress" if request.intent_analysis else "draft"
|
||||
|
||||
# Generate title if not provided
|
||||
project_title = request.title or f"Research: {', '.join(request.keywords[:3])}"
|
||||
|
||||
if existing_project:
|
||||
# Update existing project
|
||||
updated = service.update_project(
|
||||
user_id=user_id,
|
||||
project_id=project_id,
|
||||
title=project_title,
|
||||
keywords=request.keywords,
|
||||
industry=request.industry,
|
||||
target_audience=request.target_audience,
|
||||
research_mode=request.research_mode,
|
||||
config=request.config,
|
||||
intent_analysis=request.intent_analysis,
|
||||
confirmed_intent=request.confirmed_intent,
|
||||
intent_result=request.intent_result,
|
||||
legacy_result=request.legacy_result,
|
||||
current_step=request.current_step,
|
||||
status=status,
|
||||
)
|
||||
|
||||
if updated:
|
||||
logger.info(f"✅ Research project updated in database: project_id={project_id}, db_id={updated.id}")
|
||||
return SaveResearchProjectResponse(
|
||||
success=True,
|
||||
asset_id=updated.id,
|
||||
project_id=project_id,
|
||||
message=f"Research project updated successfully"
|
||||
)
|
||||
else:
|
||||
return SaveResearchProjectResponse(
|
||||
success=False,
|
||||
message="Failed to update research project"
|
||||
)
|
||||
else:
|
||||
# Create new project
|
||||
project = service.create_project(
|
||||
user_id=user_id,
|
||||
project_id=project_id,
|
||||
keywords=request.keywords,
|
||||
industry=request.industry,
|
||||
target_audience=request.target_audience,
|
||||
research_mode=request.research_mode,
|
||||
title=project_title,
|
||||
config=request.config,
|
||||
intent_analysis=request.intent_analysis,
|
||||
confirmed_intent=request.confirmed_intent,
|
||||
intent_result=request.intent_result,
|
||||
legacy_result=request.legacy_result,
|
||||
current_step=request.current_step,
|
||||
status=status,
|
||||
)
|
||||
|
||||
logger.info(f"✅ Research project saved to database: project_id={project_id}, db_id={project.id}")
|
||||
return SaveResearchProjectResponse(
|
||||
success=True,
|
||||
asset_id=project.id,
|
||||
project_id=project_id,
|
||||
message=f"Research project saved successfully"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Research Projects] Save failed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return SaveResearchProjectResponse(
|
||||
success=False,
|
||||
message=f"Error saving research project: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}", response_model=ResearchProjectResponse)
|
||||
async def get_research_project(
|
||||
project_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
"""Get a research project by ID."""
|
||||
try:
|
||||
if not current_user:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
user_id = str(current_user.get('id', ''))
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="Invalid user ID")
|
||||
|
||||
service = ResearchService(db)
|
||||
project = service.get_project(user_id, project_id)
|
||||
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
return ResearchProjectResponse.model_validate(project)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[Research Projects] Get failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Error fetching project: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/projects", response_model=ResearchProjectListResponse)
|
||||
async def list_research_projects(
|
||||
status: Optional[str] = Query(None, description="Filter by status"),
|
||||
is_favorite: Optional[bool] = Query(None, description="Filter by favorite"),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
"""List user's research projects."""
|
||||
try:
|
||||
if not current_user:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
user_id = str(current_user.get('id', ''))
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="Invalid user ID")
|
||||
|
||||
service = ResearchService(db)
|
||||
projects = service.list_projects(
|
||||
user_id=user_id,
|
||||
status=status,
|
||||
is_favorite=is_favorite,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
# Get total count
|
||||
total_query = db.query(func.count(ResearchProject.id)).filter(ResearchProject.user_id == user_id)
|
||||
if status:
|
||||
total_query = total_query.filter(ResearchProject.status == status)
|
||||
if is_favorite is not None:
|
||||
total_query = total_query.filter(ResearchProject.is_favorite == is_favorite)
|
||||
total = total_query.scalar()
|
||||
|
||||
return ResearchProjectListResponse(
|
||||
projects=[ResearchProjectResponse.model_validate(p) for p in projects],
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[Research Projects] List failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Error listing projects: {str(e)}")
|
||||
|
||||
|
||||
@router.put("/projects/{project_id}", response_model=ResearchProjectResponse)
|
||||
async def update_research_project(
|
||||
project_id: str,
|
||||
updates: Dict[str, Any],
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
"""Update a research project (e.g., toggle favorite, update title)."""
|
||||
try:
|
||||
if not current_user:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
user_id = str(current_user.get('id', ''))
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="Invalid user ID")
|
||||
|
||||
service = ResearchService(db)
|
||||
updated = service.update_project(
|
||||
user_id=user_id,
|
||||
project_id=project_id,
|
||||
**updates
|
||||
)
|
||||
|
||||
if not updated:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
return ResearchProjectResponse.model_validate(updated)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[Research Projects] Update failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Error updating project: {str(e)}")
|
||||
|
||||
|
||||
@router.delete("/projects/{project_id}", status_code=204)
|
||||
async def delete_research_project(
|
||||
project_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
"""Delete a research project."""
|
||||
try:
|
||||
if not current_user:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
user_id = str(current_user.get('id', ''))
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="Invalid user ID")
|
||||
|
||||
service = ResearchService(db)
|
||||
deleted = service.delete_project(user_id, project_id)
|
||||
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
return None
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[Research Projects] Delete failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Error deleting project: {str(e)}")
|
||||
33
backend/api/research/handlers/providers.py
Normal file
33
backend/api/research/handlers/providers.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""
|
||||
Provider Status Handler
|
||||
|
||||
Handles provider availability and status endpoints.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
from loguru import logger
|
||||
|
||||
from services.research.core import ResearchEngine
|
||||
from ..models import ProviderStatusResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/providers/status", response_model=ProviderStatusResponse)
|
||||
async def get_provider_status():
|
||||
"""
|
||||
Get status of available research providers.
|
||||
|
||||
Returns availability and priority of Exa, Tavily, and Google providers.
|
||||
"""
|
||||
try:
|
||||
engine = ResearchEngine()
|
||||
return engine.get_provider_status()
|
||||
except Exception as e:
|
||||
logger.error(f"[Provider Status] Failed: {e}")
|
||||
# Return default status on error
|
||||
return ProviderStatusResponse(
|
||||
exa={"available": False, "error": str(e)},
|
||||
tavily={"available": False, "error": str(e)},
|
||||
google={"available": False, "error": str(e)},
|
||||
)
|
||||
186
backend/api/research/handlers/research.py
Normal file
186
backend/api/research/handlers/research.py
Normal file
@@ -0,0 +1,186 @@
|
||||
"""
|
||||
Research Execution Handler
|
||||
|
||||
Handles research execution endpoints (execute, start, status, cancel).
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
|
||||
from typing import Dict, Any
|
||||
from loguru import logger
|
||||
import uuid
|
||||
|
||||
from services.database import get_db
|
||||
from services.research.core import ResearchEngine, ResearchContext
|
||||
from middleware.auth_middleware import get_current_user
|
||||
from ..models import ResearchRequest, ResearchResponse
|
||||
from ..utils import convert_to_research_context
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# In-memory task storage for async research
|
||||
# TODO: In production, use Redis or database for persistence
|
||||
_research_tasks: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
|
||||
@router.post("/execute", response_model=ResearchResponse)
|
||||
async def execute_research(
|
||||
request: ResearchRequest,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Execute research synchronously.
|
||||
|
||||
For quick research needs. For longer research, use /start endpoint.
|
||||
"""
|
||||
try:
|
||||
if not current_user:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
user_id = str(current_user.get('id', ''))
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="Invalid user ID in authentication token")
|
||||
|
||||
logger.info(f"[Research API] Execute request: {request.query[:50]}...")
|
||||
|
||||
engine = ResearchEngine()
|
||||
context = convert_to_research_context(request, user_id)
|
||||
|
||||
result = await engine.research(context)
|
||||
|
||||
return ResearchResponse(
|
||||
success=result.success,
|
||||
sources=result.sources,
|
||||
keyword_analysis=result.keyword_analysis,
|
||||
competitor_analysis=result.competitor_analysis,
|
||||
suggested_angles=result.suggested_angles,
|
||||
provider_used=result.provider_used,
|
||||
search_queries=result.search_queries,
|
||||
error_message=result.error_message,
|
||||
error_code=result.error_code,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Research API] Execute failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/start", response_model=ResearchResponse)
|
||||
async def start_research(
|
||||
request: ResearchRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Start research asynchronously.
|
||||
|
||||
Returns a task_id that can be used to poll for status.
|
||||
Use this for comprehensive research that may take longer.
|
||||
"""
|
||||
try:
|
||||
if not current_user:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
user_id = str(current_user.get('id', ''))
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="Invalid user ID in authentication token")
|
||||
|
||||
logger.info(f"[Research API] Start async request: {request.query[:50]}...")
|
||||
|
||||
task_id = str(uuid.uuid4())
|
||||
|
||||
# Initialize task
|
||||
_research_tasks[task_id] = {
|
||||
"status": "pending",
|
||||
"progress_messages": [],
|
||||
"result": None,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
# Start background task
|
||||
context = convert_to_research_context(request, user_id)
|
||||
background_tasks.add_task(_run_research_task, task_id, context)
|
||||
|
||||
return ResearchResponse(
|
||||
success=True,
|
||||
task_id=task_id,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Research API] Start failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
async def _run_research_task(task_id: str, context: ResearchContext):
|
||||
"""Background task to run research."""
|
||||
try:
|
||||
_research_tasks[task_id]["status"] = "running"
|
||||
|
||||
def progress_callback(message: str):
|
||||
_research_tasks[task_id]["progress_messages"].append(message)
|
||||
|
||||
engine = ResearchEngine()
|
||||
result = await engine.research(context, progress_callback=progress_callback)
|
||||
|
||||
_research_tasks[task_id]["status"] = "completed"
|
||||
_research_tasks[task_id]["result"] = result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Research API] Task {task_id} failed: {e}")
|
||||
_research_tasks[task_id]["status"] = "failed"
|
||||
_research_tasks[task_id]["error"] = str(e)
|
||||
|
||||
|
||||
@router.get("/status/{task_id}")
|
||||
async def get_research_status(task_id: str):
|
||||
"""
|
||||
Get status of an async research task.
|
||||
|
||||
Poll this endpoint to get progress updates and final results.
|
||||
"""
|
||||
if task_id not in _research_tasks:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
task = _research_tasks[task_id]
|
||||
|
||||
response = {
|
||||
"task_id": task_id,
|
||||
"status": task["status"],
|
||||
"progress_messages": task["progress_messages"],
|
||||
}
|
||||
|
||||
if task["status"] == "completed" and task["result"]:
|
||||
result = task["result"]
|
||||
response["result"] = {
|
||||
"success": result.success,
|
||||
"sources": result.sources,
|
||||
"keyword_analysis": result.keyword_analysis,
|
||||
"competitor_analysis": result.competitor_analysis,
|
||||
"suggested_angles": result.suggested_angles,
|
||||
"provider_used": result.provider_used,
|
||||
"search_queries": result.search_queries,
|
||||
}
|
||||
|
||||
# Clean up completed task after returning
|
||||
# In production, use Redis or database for persistence
|
||||
|
||||
elif task["status"] == "failed":
|
||||
response["error"] = task["error"]
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.delete("/status/{task_id}")
|
||||
async def cancel_research(task_id: str):
|
||||
"""
|
||||
Cancel a running research task.
|
||||
"""
|
||||
if task_id not in _research_tasks:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
task = _research_tasks[task_id]
|
||||
|
||||
if task["status"] in ["pending", "running"]:
|
||||
task["status"] = "cancelled"
|
||||
return {"message": "Task cancelled", "task_id": task_id}
|
||||
|
||||
return {"message": f"Task already {task['status']}", "task_id": task_id}
|
||||
237
backend/api/research/models.py
Normal file
237
backend/api/research/models.py
Normal file
@@ -0,0 +1,237 @@
|
||||
"""
|
||||
Research API Models
|
||||
|
||||
All Pydantic request/response models for research endpoints.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Research Execution Models
|
||||
# ============================================================================
|
||||
|
||||
class ResearchRequest(BaseModel):
|
||||
"""API request for research."""
|
||||
query: str = Field(..., description="Main research query or topic")
|
||||
keywords: List[str] = Field(default_factory=list, description="Additional keywords")
|
||||
|
||||
# Research configuration
|
||||
goal: Optional[str] = Field(default="factual", description="Research goal: factual, trending, competitive, etc.")
|
||||
depth: Optional[str] = Field(default="standard", description="Research depth: quick, standard, comprehensive, expert")
|
||||
provider: Optional[str] = Field(default="auto", description="Provider preference: auto, exa, tavily, google")
|
||||
|
||||
# Personalization
|
||||
content_type: Optional[str] = Field(default="general", description="Content type: blog, podcast, video, etc.")
|
||||
industry: Optional[str] = None
|
||||
target_audience: Optional[str] = None
|
||||
tone: Optional[str] = None
|
||||
|
||||
# Constraints
|
||||
max_sources: int = Field(default=10, ge=1, le=25)
|
||||
recency: Optional[str] = None # day, week, month, year
|
||||
|
||||
# Domain filtering
|
||||
include_domains: List[str] = Field(default_factory=list)
|
||||
exclude_domains: List[str] = Field(default_factory=list)
|
||||
|
||||
# Advanced mode
|
||||
advanced_mode: bool = False
|
||||
|
||||
# Raw provider parameters (only if advanced_mode=True)
|
||||
exa_category: Optional[str] = None
|
||||
exa_search_type: Optional[str] = None
|
||||
tavily_topic: Optional[str] = None
|
||||
tavily_search_depth: Optional[str] = None
|
||||
tavily_include_answer: bool = False
|
||||
tavily_time_range: Optional[str] = None
|
||||
|
||||
|
||||
class ResearchResponse(BaseModel):
|
||||
"""API response for research."""
|
||||
success: bool
|
||||
task_id: Optional[str] = None # For async requests
|
||||
|
||||
# Results (if synchronous)
|
||||
sources: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
keyword_analysis: Dict[str, Any] = Field(default_factory=dict)
|
||||
competitor_analysis: Dict[str, Any] = Field(default_factory=dict)
|
||||
suggested_angles: List[str] = Field(default_factory=list)
|
||||
|
||||
# Metadata
|
||||
provider_used: Optional[str] = None
|
||||
search_queries: List[str] = Field(default_factory=list)
|
||||
|
||||
# Error handling
|
||||
error_message: Optional[str] = None
|
||||
error_code: Optional[str] = None
|
||||
|
||||
|
||||
class ProviderStatusResponse(BaseModel):
|
||||
"""Response for provider status check."""
|
||||
exa: Dict[str, Any]
|
||||
tavily: Dict[str, Any]
|
||||
google: Dict[str, Any]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Intent-Driven Research Models
|
||||
# ============================================================================
|
||||
|
||||
class AnalyzeIntentRequest(BaseModel):
|
||||
"""Request to analyze user research intent."""
|
||||
user_input: str = Field(..., description="User's keywords, question, or goal")
|
||||
keywords: List[str] = Field(default_factory=list, description="Extracted keywords")
|
||||
use_persona: bool = Field(True, description="Use research persona for context")
|
||||
use_competitor_data: bool = Field(True, description="Use competitor data for context")
|
||||
# User-provided intent settings (optional - if provided, use these instead of inferring)
|
||||
user_provided_purpose: Optional[str] = Field(None, description="User-selected purpose (learn, create_content, etc.)")
|
||||
user_provided_content_output: Optional[str] = Field(None, description="User-selected content output (blog, podcast, etc.)")
|
||||
user_provided_depth: Optional[str] = Field(None, description="User-selected depth (overview, detailed, expert)")
|
||||
|
||||
|
||||
class AnalyzeIntentResponse(BaseModel):
|
||||
"""Response from intent analysis with optimized provider parameters."""
|
||||
success: bool
|
||||
intent: Dict[str, Any]
|
||||
analysis_summary: str
|
||||
suggested_queries: List[Dict[str, Any]]
|
||||
suggested_keywords: List[str]
|
||||
suggested_angles: List[str]
|
||||
quick_options: List[Dict[str, Any]]
|
||||
confidence_reason: Optional[str] = None
|
||||
great_example: Optional[str] = None
|
||||
error_message: Optional[str] = None
|
||||
|
||||
# Unified: Optimized provider parameters based on intent
|
||||
optimized_config: Optional[Dict[str, Any]] = None # Provider settings auto-configured from intent
|
||||
recommended_provider: Optional[str] = None # Best provider for this intent (exa, tavily, google)
|
||||
|
||||
# Google Trends configuration (if trends in deliverables)
|
||||
trends_config: Optional[Dict[str, Any]] = None # Trends keywords and settings with justifications
|
||||
|
||||
|
||||
class IntentDrivenResearchRequest(BaseModel):
|
||||
"""Request for intent-driven research."""
|
||||
# Intent from previous analyze step, or minimal input for auto-inference
|
||||
user_input: str = Field(..., description="User's original input")
|
||||
|
||||
# Optional: Confirmed intent from UI (if user modified the inferred intent)
|
||||
confirmed_intent: Optional[Dict[str, Any]] = None
|
||||
|
||||
# Optional: Specific queries to run (if user selected from suggested)
|
||||
selected_queries: Optional[List[Dict[str, Any]]] = None
|
||||
|
||||
# Research configuration
|
||||
max_sources: int = Field(default=10, ge=1, le=25)
|
||||
include_domains: List[str] = Field(default_factory=list)
|
||||
exclude_domains: List[str] = Field(default_factory=list)
|
||||
|
||||
# Google Trends configuration (from intent analysis)
|
||||
trends_config: Optional[Dict[str, Any]] = None # Trends keywords and settings
|
||||
|
||||
# Skip intent inference (for re-runs with same intent)
|
||||
skip_inference: bool = False
|
||||
|
||||
|
||||
class IntentDrivenResearchResponse(BaseModel):
|
||||
"""Response from intent-driven research."""
|
||||
success: bool
|
||||
|
||||
# Direct answers
|
||||
primary_answer: str = ""
|
||||
secondary_answers: Dict[str, Optional[str]] = Field(default_factory=dict)
|
||||
focus_areas_coverage: Dict[str, Optional[str]] = Field(default_factory=dict)
|
||||
also_answering_coverage: Dict[str, Optional[str]] = Field(default_factory=dict)
|
||||
|
||||
# Deliverables
|
||||
statistics: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
expert_quotes: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
case_studies: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
trends: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
comparisons: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
best_practices: List[str] = Field(default_factory=list)
|
||||
step_by_step: List[str] = Field(default_factory=list)
|
||||
pros_cons: Optional[Dict[str, Any]] = None
|
||||
definitions: Dict[str, str] = Field(default_factory=dict)
|
||||
examples: List[str] = Field(default_factory=list)
|
||||
predictions: List[str] = Field(default_factory=list)
|
||||
|
||||
# Content-ready outputs
|
||||
executive_summary: str = ""
|
||||
key_takeaways: List[str] = Field(default_factory=list)
|
||||
suggested_outline: List[str] = Field(default_factory=list)
|
||||
|
||||
# Sources and metadata
|
||||
sources: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
confidence: float = 0.8
|
||||
gaps_identified: List[str] = Field(default_factory=list)
|
||||
follow_up_queries: List[str] = Field(default_factory=list)
|
||||
intent: Optional[Dict[str, Any]] = None
|
||||
google_trends_data: Optional[Dict[str, Any]] = None
|
||||
error_message: Optional[str] = None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Research Project Models
|
||||
# ============================================================================
|
||||
|
||||
class SaveResearchProjectRequest(BaseModel):
|
||||
"""Request to save a research project to database."""
|
||||
project_id: Optional[str] = Field(None, description="Project ID for updates (optional, auto-generated if not provided)")
|
||||
title: Optional[str] = Field(None, description="Project title")
|
||||
keywords: List[str] = Field(..., description="Research keywords")
|
||||
industry: str = Field(..., description="Industry")
|
||||
target_audience: str = Field(..., description="Target audience")
|
||||
research_mode: str = Field(..., description="Research mode (comprehensive, targeted, basic)")
|
||||
config: Dict[str, Any] = Field(..., description="Research configuration")
|
||||
intent_analysis: Optional[Dict[str, Any]] = Field(None, description="Intent analysis result")
|
||||
confirmed_intent: Optional[Dict[str, Any]] = Field(None, description="Confirmed research intent")
|
||||
intent_result: Optional[Dict[str, Any]] = Field(None, description="Intent-driven research result")
|
||||
legacy_result: Optional[Dict[str, Any]] = Field(None, description="Legacy research result")
|
||||
current_step: int = Field(1, description="Current wizard step")
|
||||
description: Optional[str] = Field(None, description="Project description")
|
||||
|
||||
|
||||
class SaveResearchProjectResponse(BaseModel):
|
||||
"""Response after saving research project."""
|
||||
success: bool
|
||||
asset_id: Optional[int] = None # Database ID (for backward compatibility)
|
||||
project_id: Optional[str] = None # Project UUID (for lookups)
|
||||
message: str
|
||||
|
||||
|
||||
class ResearchProjectResponse(BaseModel):
|
||||
"""Response model for research project."""
|
||||
id: int
|
||||
project_id: str
|
||||
user_id: str
|
||||
title: Optional[str] = None
|
||||
keywords: List[str]
|
||||
industry: Optional[str] = None
|
||||
target_audience: Optional[str] = None
|
||||
research_mode: Optional[str] = None
|
||||
config: Optional[Dict[str, Any]] = None
|
||||
intent_analysis: Optional[Dict[str, Any]] = None
|
||||
confirmed_intent: Optional[Dict[str, Any]] = None
|
||||
intent_result: Optional[Dict[str, Any]] = None
|
||||
legacy_result: Optional[Dict[str, Any]] = None
|
||||
trends_config: Optional[Dict[str, Any]] = None
|
||||
current_step: int = 1
|
||||
status: str = "draft"
|
||||
is_favorite: bool = False
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ResearchProjectListResponse(BaseModel):
|
||||
"""Response model for listing research projects."""
|
||||
projects: List[ResearchProjectResponse]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
@@ -1,910 +1,23 @@
|
||||
"""
|
||||
Research API Router
|
||||
|
||||
Standalone API endpoints for the Research Engine.
|
||||
These endpoints can be used by:
|
||||
- Frontend Research UI
|
||||
- Blog Writer (via adapter)
|
||||
- Podcast Maker
|
||||
- YouTube Creator
|
||||
- Any other content tool
|
||||
Main router that imports and registers all handler modules.
|
||||
Refactored for maintainability and extensibility.
|
||||
|
||||
Author: ALwrity Team
|
||||
Version: 2.0
|
||||
Version: 3.0
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List, Dict, Any
|
||||
from loguru import logger
|
||||
import uuid
|
||||
import asyncio
|
||||
from models.research_intent_models import TrendAnalysis
|
||||
from fastapi import APIRouter
|
||||
|
||||
from services.database import get_db
|
||||
from services.research.core import (
|
||||
ResearchEngine,
|
||||
ResearchContext,
|
||||
ResearchPersonalizationContext,
|
||||
ContentType,
|
||||
ResearchGoal,
|
||||
ResearchDepth,
|
||||
ProviderPreference,
|
||||
)
|
||||
from services.research.core.research_context import ResearchResult
|
||||
from middleware.auth_middleware import get_current_user
|
||||
|
||||
# Intent-driven research imports
|
||||
from models.research_intent_models import (
|
||||
ResearchIntent,
|
||||
IntentInferenceRequest,
|
||||
IntentInferenceResponse,
|
||||
IntentDrivenResearchResult,
|
||||
ResearchQuery,
|
||||
ExpectedDeliverable,
|
||||
ResearchPurpose,
|
||||
ContentOutput,
|
||||
ResearchDepthLevel,
|
||||
)
|
||||
from services.research.intent import (
|
||||
ResearchIntentInference,
|
||||
IntentQueryGenerator,
|
||||
IntentAwareAnalyzer,
|
||||
)
|
||||
# Import all handler routers
|
||||
from .handlers import providers, research, intent, projects
|
||||
|
||||
# Create main router
|
||||
router = APIRouter(prefix="/api/research", tags=["Research Engine"])
|
||||
|
||||
|
||||
# Request/Response models
|
||||
class ResearchRequest(BaseModel):
|
||||
"""API request for research."""
|
||||
query: str = Field(..., description="Main research query or topic")
|
||||
keywords: List[str] = Field(default_factory=list, description="Additional keywords")
|
||||
|
||||
# Research configuration
|
||||
goal: Optional[str] = Field(default="factual", description="Research goal: factual, trending, competitive, etc.")
|
||||
depth: Optional[str] = Field(default="standard", description="Research depth: quick, standard, comprehensive, expert")
|
||||
provider: Optional[str] = Field(default="auto", description="Provider preference: auto, exa, tavily, google")
|
||||
|
||||
# Personalization
|
||||
content_type: Optional[str] = Field(default="general", description="Content type: blog, podcast, video, etc.")
|
||||
industry: Optional[str] = None
|
||||
target_audience: Optional[str] = None
|
||||
tone: Optional[str] = None
|
||||
|
||||
# Constraints
|
||||
max_sources: int = Field(default=10, ge=1, le=25)
|
||||
recency: Optional[str] = None # day, week, month, year
|
||||
|
||||
# Domain filtering
|
||||
include_domains: List[str] = Field(default_factory=list)
|
||||
exclude_domains: List[str] = Field(default_factory=list)
|
||||
|
||||
# Advanced mode
|
||||
advanced_mode: bool = False
|
||||
|
||||
# Raw provider parameters (only if advanced_mode=True)
|
||||
exa_category: Optional[str] = None
|
||||
exa_search_type: Optional[str] = None
|
||||
tavily_topic: Optional[str] = None
|
||||
tavily_search_depth: Optional[str] = None
|
||||
tavily_include_answer: bool = False
|
||||
tavily_time_range: Optional[str] = None
|
||||
|
||||
|
||||
class ResearchResponse(BaseModel):
|
||||
"""API response for research."""
|
||||
success: bool
|
||||
task_id: Optional[str] = None # For async requests
|
||||
|
||||
# Results (if synchronous)
|
||||
sources: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
keyword_analysis: Dict[str, Any] = Field(default_factory=dict)
|
||||
competitor_analysis: Dict[str, Any] = Field(default_factory=dict)
|
||||
suggested_angles: List[str] = Field(default_factory=list)
|
||||
|
||||
# Metadata
|
||||
provider_used: Optional[str] = None
|
||||
search_queries: List[str] = Field(default_factory=list)
|
||||
|
||||
# Error handling
|
||||
error_message: Optional[str] = None
|
||||
error_code: Optional[str] = None
|
||||
|
||||
|
||||
class ProviderStatusResponse(BaseModel):
|
||||
"""API response for provider status."""
|
||||
exa: Dict[str, Any]
|
||||
tavily: Dict[str, Any]
|
||||
google: Dict[str, Any]
|
||||
|
||||
|
||||
# In-memory task storage for async research
|
||||
_research_tasks: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
|
||||
def _convert_to_research_context(request: ResearchRequest, user_id: str) -> ResearchContext:
|
||||
"""Convert API request to ResearchContext."""
|
||||
|
||||
# Map string enums
|
||||
goal_map = {
|
||||
"factual": ResearchGoal.FACTUAL,
|
||||
"trending": ResearchGoal.TRENDING,
|
||||
"competitive": ResearchGoal.COMPETITIVE,
|
||||
"educational": ResearchGoal.EDUCATIONAL,
|
||||
"technical": ResearchGoal.TECHNICAL,
|
||||
"inspirational": ResearchGoal.INSPIRATIONAL,
|
||||
}
|
||||
|
||||
depth_map = {
|
||||
"quick": ResearchDepth.QUICK,
|
||||
"standard": ResearchDepth.STANDARD,
|
||||
"comprehensive": ResearchDepth.COMPREHENSIVE,
|
||||
"expert": ResearchDepth.EXPERT,
|
||||
}
|
||||
|
||||
provider_map = {
|
||||
"auto": ProviderPreference.AUTO,
|
||||
"exa": ProviderPreference.EXA,
|
||||
"tavily": ProviderPreference.TAVILY,
|
||||
"google": ProviderPreference.GOOGLE,
|
||||
"hybrid": ProviderPreference.HYBRID,
|
||||
}
|
||||
|
||||
content_type_map = {
|
||||
"blog": ContentType.BLOG,
|
||||
"podcast": ContentType.PODCAST,
|
||||
"video": ContentType.VIDEO,
|
||||
"social": ContentType.SOCIAL,
|
||||
"email": ContentType.EMAIL,
|
||||
"newsletter": ContentType.NEWSLETTER,
|
||||
"whitepaper": ContentType.WHITEPAPER,
|
||||
"general": ContentType.GENERAL,
|
||||
}
|
||||
|
||||
# Build personalization context
|
||||
personalization = ResearchPersonalizationContext(
|
||||
creator_id=user_id,
|
||||
content_type=content_type_map.get(request.content_type or "general", ContentType.GENERAL),
|
||||
industry=request.industry,
|
||||
target_audience=request.target_audience,
|
||||
tone=request.tone,
|
||||
)
|
||||
|
||||
return ResearchContext(
|
||||
query=request.query,
|
||||
keywords=request.keywords,
|
||||
goal=goal_map.get(request.goal or "factual", ResearchGoal.FACTUAL),
|
||||
depth=depth_map.get(request.depth or "standard", ResearchDepth.STANDARD),
|
||||
provider_preference=provider_map.get(request.provider or "auto", ProviderPreference.AUTO),
|
||||
personalization=personalization,
|
||||
max_sources=request.max_sources,
|
||||
recency=request.recency,
|
||||
include_domains=request.include_domains,
|
||||
exclude_domains=request.exclude_domains,
|
||||
advanced_mode=request.advanced_mode,
|
||||
exa_category=request.exa_category,
|
||||
exa_search_type=request.exa_search_type,
|
||||
tavily_topic=request.tavily_topic,
|
||||
tavily_search_depth=request.tavily_search_depth,
|
||||
tavily_include_answer=request.tavily_include_answer,
|
||||
tavily_time_range=request.tavily_time_range,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/providers/status", response_model=ProviderStatusResponse)
|
||||
async def get_provider_status():
|
||||
"""
|
||||
Get status of available research providers.
|
||||
|
||||
Returns availability and priority of Exa, Tavily, and Google providers.
|
||||
"""
|
||||
engine = ResearchEngine()
|
||||
return engine.get_provider_status()
|
||||
|
||||
|
||||
@router.post("/execute", response_model=ResearchResponse)
|
||||
async def execute_research(
|
||||
request: ResearchRequest,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Execute research synchronously.
|
||||
|
||||
For quick research needs. For longer research, use /start endpoint.
|
||||
"""
|
||||
try:
|
||||
if not current_user:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
user_id = str(current_user.get('id', ''))
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="Invalid user ID in authentication token")
|
||||
|
||||
logger.info(f"[Research API] Execute request: {request.query[:50]}...")
|
||||
|
||||
engine = ResearchEngine()
|
||||
context = _convert_to_research_context(request, user_id)
|
||||
|
||||
result = await engine.research(context)
|
||||
|
||||
return ResearchResponse(
|
||||
success=result.success,
|
||||
sources=result.sources,
|
||||
keyword_analysis=result.keyword_analysis,
|
||||
competitor_analysis=result.competitor_analysis,
|
||||
suggested_angles=result.suggested_angles,
|
||||
provider_used=result.provider_used,
|
||||
search_queries=result.search_queries,
|
||||
error_message=result.error_message,
|
||||
error_code=result.error_code,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Research API] Execute failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/start", response_model=ResearchResponse)
|
||||
async def start_research(
|
||||
request: ResearchRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Start research asynchronously.
|
||||
|
||||
Returns a task_id that can be used to poll for status.
|
||||
Use this for comprehensive research that may take longer.
|
||||
"""
|
||||
try:
|
||||
if not current_user:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
user_id = str(current_user.get('id', ''))
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="Invalid user ID in authentication token")
|
||||
|
||||
logger.info(f"[Research API] Start async request: {request.query[:50]}...")
|
||||
|
||||
task_id = str(uuid.uuid4())
|
||||
|
||||
# Initialize task
|
||||
_research_tasks[task_id] = {
|
||||
"status": "pending",
|
||||
"progress_messages": [],
|
||||
"result": None,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
# Start background task
|
||||
context = _convert_to_research_context(request, user_id)
|
||||
background_tasks.add_task(_run_research_task, task_id, context)
|
||||
|
||||
return ResearchResponse(
|
||||
success=True,
|
||||
task_id=task_id,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Research API] Start failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
async def _run_research_task(task_id: str, context: ResearchContext):
|
||||
"""Background task to run research."""
|
||||
try:
|
||||
_research_tasks[task_id]["status"] = "running"
|
||||
|
||||
def progress_callback(message: str):
|
||||
_research_tasks[task_id]["progress_messages"].append(message)
|
||||
|
||||
engine = ResearchEngine()
|
||||
result = await engine.research(context, progress_callback=progress_callback)
|
||||
|
||||
_research_tasks[task_id]["status"] = "completed"
|
||||
_research_tasks[task_id]["result"] = result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Research API] Task {task_id} failed: {e}")
|
||||
_research_tasks[task_id]["status"] = "failed"
|
||||
_research_tasks[task_id]["error"] = str(e)
|
||||
|
||||
|
||||
@router.get("/status/{task_id}")
|
||||
async def get_research_status(task_id: str):
|
||||
"""
|
||||
Get status of an async research task.
|
||||
|
||||
Poll this endpoint to get progress updates and final results.
|
||||
"""
|
||||
if task_id not in _research_tasks:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
task = _research_tasks[task_id]
|
||||
|
||||
response = {
|
||||
"task_id": task_id,
|
||||
"status": task["status"],
|
||||
"progress_messages": task["progress_messages"],
|
||||
}
|
||||
|
||||
if task["status"] == "completed" and task["result"]:
|
||||
result = task["result"]
|
||||
response["result"] = {
|
||||
"success": result.success,
|
||||
"sources": result.sources,
|
||||
"keyword_analysis": result.keyword_analysis,
|
||||
"competitor_analysis": result.competitor_analysis,
|
||||
"suggested_angles": result.suggested_angles,
|
||||
"provider_used": result.provider_used,
|
||||
"search_queries": result.search_queries,
|
||||
}
|
||||
|
||||
# Clean up completed task after returning
|
||||
# In production, use Redis or database for persistence
|
||||
|
||||
elif task["status"] == "failed":
|
||||
response["error"] = task["error"]
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.delete("/status/{task_id}")
|
||||
async def cancel_research(task_id: str):
|
||||
"""
|
||||
Cancel a running research task.
|
||||
"""
|
||||
if task_id not in _research_tasks:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
task = _research_tasks[task_id]
|
||||
|
||||
if task["status"] in ["pending", "running"]:
|
||||
task["status"] = "cancelled"
|
||||
return {"message": "Task cancelled", "task_id": task_id}
|
||||
|
||||
return {"message": f"Task already {task['status']}", "task_id": task_id}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Intent-Driven Research Endpoints
|
||||
# ============================================================================
|
||||
|
||||
class AnalyzeIntentRequest(BaseModel):
|
||||
"""Request to analyze user research intent."""
|
||||
user_input: str = Field(..., description="User's keywords, question, or goal")
|
||||
keywords: List[str] = Field(default_factory=list, description="Extracted keywords")
|
||||
use_persona: bool = Field(True, description="Use research persona for context")
|
||||
use_competitor_data: bool = Field(True, description="Use competitor data for context")
|
||||
|
||||
|
||||
class AnalyzeIntentResponse(BaseModel):
|
||||
"""Response from intent analysis with optimized provider parameters."""
|
||||
success: bool
|
||||
intent: Dict[str, Any]
|
||||
analysis_summary: str
|
||||
suggested_queries: List[Dict[str, Any]]
|
||||
suggested_keywords: List[str]
|
||||
suggested_angles: List[str]
|
||||
quick_options: List[Dict[str, Any]]
|
||||
confidence_reason: Optional[str] = None
|
||||
great_example: Optional[str] = None
|
||||
error_message: Optional[str] = None
|
||||
|
||||
# Unified: Optimized provider parameters based on intent
|
||||
optimized_config: Optional[Dict[str, Any]] = None # Provider settings auto-configured from intent
|
||||
recommended_provider: Optional[str] = None # Best provider for this intent (exa, tavily, google)
|
||||
|
||||
# Google Trends configuration (if trends in deliverables)
|
||||
trends_config: Optional[Dict[str, Any]] = None # Trends keywords and settings with justifications
|
||||
|
||||
|
||||
class IntentDrivenResearchRequest(BaseModel):
|
||||
"""Request for intent-driven research."""
|
||||
# Intent from previous analyze step, or minimal input for auto-inference
|
||||
user_input: str = Field(..., description="User's original input")
|
||||
|
||||
# Optional: Confirmed intent from UI (if user modified the inferred intent)
|
||||
confirmed_intent: Optional[Dict[str, Any]] = None
|
||||
|
||||
# Optional: Specific queries to run (if user selected from suggested)
|
||||
selected_queries: Optional[List[Dict[str, Any]]] = None
|
||||
|
||||
# Research configuration
|
||||
max_sources: int = Field(default=10, ge=1, le=25)
|
||||
include_domains: List[str] = Field(default_factory=list)
|
||||
exclude_domains: List[str] = Field(default_factory=list)
|
||||
|
||||
# Google Trends configuration (from intent analysis)
|
||||
trends_config: Optional[Dict[str, Any]] = None # Trends keywords and settings
|
||||
|
||||
# Skip intent inference (for re-runs with same intent)
|
||||
skip_inference: bool = False
|
||||
|
||||
|
||||
class IntentDrivenResearchResponse(BaseModel):
|
||||
"""Response from intent-driven research."""
|
||||
success: bool
|
||||
|
||||
# Direct answers
|
||||
primary_answer: str = ""
|
||||
secondary_answers: Dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
# Deliverables
|
||||
statistics: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
expert_quotes: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
case_studies: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
trends: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
comparisons: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
best_practices: List[str] = Field(default_factory=list)
|
||||
step_by_step: List[str] = Field(default_factory=list)
|
||||
pros_cons: Optional[Dict[str, Any]] = None
|
||||
definitions: Dict[str, str] = Field(default_factory=dict)
|
||||
examples: List[str] = Field(default_factory=list)
|
||||
predictions: List[str] = Field(default_factory=list)
|
||||
|
||||
# Content-ready outputs
|
||||
executive_summary: str = ""
|
||||
key_takeaways: List[str] = Field(default_factory=list)
|
||||
suggested_outline: List[str] = Field(default_factory=list)
|
||||
|
||||
# Sources and metadata
|
||||
sources: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
confidence: float = 0.8
|
||||
gaps_identified: List[str] = Field(default_factory=list)
|
||||
follow_up_queries: List[str] = Field(default_factory=list)
|
||||
|
||||
# The inferred/confirmed intent
|
||||
intent: Optional[Dict[str, Any]] = None
|
||||
|
||||
# Google Trends data (if trends were analyzed)
|
||||
google_trends_data: Optional[Dict[str, Any]] = None
|
||||
|
||||
# Error handling
|
||||
error_message: Optional[str] = None
|
||||
|
||||
|
||||
@router.post("/intent/analyze", response_model=AnalyzeIntentResponse)
|
||||
async def analyze_research_intent(
|
||||
request: AnalyzeIntentRequest,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Analyze user input to understand research intent.
|
||||
|
||||
This endpoint uses AI to infer what the user really wants from their research:
|
||||
- What questions need answering
|
||||
- What deliverables they expect (statistics, quotes, case studies, etc.)
|
||||
- What depth and focus is appropriate
|
||||
|
||||
The response includes quick options that can be shown in the UI for user confirmation.
|
||||
"""
|
||||
try:
|
||||
if not current_user:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
user_id = str(current_user.get('id', ''))
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="Invalid user ID")
|
||||
|
||||
logger.info(f"[Intent API] Analyzing intent for: {request.user_input[:50]}...")
|
||||
|
||||
# Get research persona if requested
|
||||
research_persona = None
|
||||
competitor_data = None
|
||||
|
||||
if request.use_persona or request.use_competitor_data:
|
||||
from services.research.research_persona_service import ResearchPersonaService
|
||||
from services.onboarding.database_service import OnboardingDatabaseService
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
# Get database session
|
||||
db = next(get_db())
|
||||
try:
|
||||
persona_service = ResearchPersonaService(db)
|
||||
onboarding_service = OnboardingDatabaseService(db=db)
|
||||
|
||||
if request.use_persona:
|
||||
research_persona = persona_service.get_or_generate(user_id)
|
||||
|
||||
if request.use_competitor_data:
|
||||
competitor_data = onboarding_service.get_competitor_analysis(user_id, db)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# Use Unified Research Analyzer (single AI call for intent + queries + params)
|
||||
from services.research.intent.unified_research_analyzer import UnifiedResearchAnalyzer
|
||||
|
||||
analyzer = UnifiedResearchAnalyzer()
|
||||
unified_result = await analyzer.analyze(
|
||||
user_input=request.user_input,
|
||||
keywords=request.keywords,
|
||||
research_persona=research_persona,
|
||||
competitor_data=competitor_data,
|
||||
industry=research_persona.default_industry if research_persona else None,
|
||||
target_audience=research_persona.default_target_audience if research_persona else None,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
if not unified_result.get("success", False):
|
||||
logger.warning("Unified analysis failed, using fallback")
|
||||
|
||||
# Extract results
|
||||
intent = unified_result.get("intent")
|
||||
queries = unified_result.get("queries", [])
|
||||
exa_config = unified_result.get("exa_config", {})
|
||||
tavily_config = unified_result.get("tavily_config", {})
|
||||
trends_config = unified_result.get("trends_config", {}) # NEW: Google Trends config
|
||||
|
||||
# Build optimized config with AI-driven justifications
|
||||
optimized_config = {
|
||||
"provider": unified_result.get("recommended_provider", "exa"),
|
||||
"provider_justification": unified_result.get("provider_justification", ""),
|
||||
# Exa settings with justifications
|
||||
"exa_type": exa_config.get("type", "auto"),
|
||||
"exa_type_justification": exa_config.get("type_justification", ""),
|
||||
"exa_category": exa_config.get("category"),
|
||||
"exa_category_justification": exa_config.get("category_justification", ""),
|
||||
"exa_include_domains": exa_config.get("includeDomains", []),
|
||||
"exa_include_domains_justification": exa_config.get("includeDomains_justification", ""),
|
||||
"exa_num_results": exa_config.get("numResults", 10),
|
||||
"exa_num_results_justification": exa_config.get("numResults_justification", ""),
|
||||
"exa_date_filter": exa_config.get("startPublishedDate"),
|
||||
"exa_date_justification": exa_config.get("date_justification", ""),
|
||||
"exa_highlights": exa_config.get("highlights", True),
|
||||
"exa_highlights_justification": exa_config.get("highlights_justification", ""),
|
||||
"exa_context": exa_config.get("context", True),
|
||||
"exa_context_justification": exa_config.get("context_justification", ""),
|
||||
# Tavily settings with justifications
|
||||
"tavily_topic": tavily_config.get("topic", "general"),
|
||||
"tavily_topic_justification": tavily_config.get("topic_justification", ""),
|
||||
"tavily_search_depth": tavily_config.get("search_depth", "advanced"),
|
||||
"tavily_search_depth_justification": tavily_config.get("search_depth_justification", ""),
|
||||
"tavily_include_answer": tavily_config.get("include_answer", True),
|
||||
"tavily_include_answer_justification": tavily_config.get("include_answer_justification", ""),
|
||||
"tavily_time_range": tavily_config.get("time_range"),
|
||||
"tavily_time_range_justification": tavily_config.get("time_range_justification", ""),
|
||||
"tavily_max_results": tavily_config.get("max_results", 10),
|
||||
"tavily_max_results_justification": tavily_config.get("max_results_justification", ""),
|
||||
"tavily_raw_content": tavily_config.get("include_raw_content", "markdown"),
|
||||
"tavily_raw_content_justification": tavily_config.get("include_raw_content_justification", ""),
|
||||
}
|
||||
|
||||
# Build trends config response (if enabled)
|
||||
trends_config_response = None
|
||||
if trends_config.get("enabled", False):
|
||||
trends_config_response = {
|
||||
"enabled": True,
|
||||
"keywords": trends_config.get("keywords", []),
|
||||
"keywords_justification": trends_config.get("keywords_justification", ""),
|
||||
"timeframe": trends_config.get("timeframe", "today 12-m"),
|
||||
"timeframe_justification": trends_config.get("timeframe_justification", ""),
|
||||
"geo": trends_config.get("geo", "US"),
|
||||
"geo_justification": trends_config.get("geo_justification", ""),
|
||||
"expected_insights": trends_config.get("expected_insights", []),
|
||||
}
|
||||
|
||||
return AnalyzeIntentResponse(
|
||||
success=True,
|
||||
intent=intent.dict() if hasattr(intent, 'dict') else intent,
|
||||
analysis_summary=unified_result.get("analysis_summary", ""),
|
||||
suggested_queries=[q.dict() if hasattr(q, 'dict') else q for q in queries],
|
||||
suggested_keywords=unified_result.get("enhanced_keywords", []),
|
||||
suggested_angles=unified_result.get("research_angles", []),
|
||||
quick_options=[], # Deprecated in unified approach
|
||||
confidence_reason=intent.confidence_reason if hasattr(intent, 'confidence_reason') else "",
|
||||
great_example=intent.great_example if hasattr(intent, 'great_example') else "",
|
||||
optimized_config=optimized_config,
|
||||
recommended_provider=unified_result.get("recommended_provider", "exa"),
|
||||
trends_config=trends_config_response, # NEW: Google Trends configuration
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Intent API] Analyze failed: {e}")
|
||||
return AnalyzeIntentResponse(
|
||||
success=False,
|
||||
intent={},
|
||||
analysis_summary="",
|
||||
suggested_queries=[],
|
||||
suggested_keywords=[],
|
||||
suggested_angles=[],
|
||||
quick_options=[],
|
||||
confidence_reason=None,
|
||||
great_example=None,
|
||||
error_message=str(e),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/intent/research", response_model=IntentDrivenResearchResponse)
|
||||
async def execute_intent_driven_research(
|
||||
request: IntentDrivenResearchRequest,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Execute research based on user intent.
|
||||
|
||||
This is the main endpoint for intent-driven research. It:
|
||||
1. Uses the confirmed intent (or infers from user_input if not provided)
|
||||
2. Generates targeted queries for each expected deliverable
|
||||
3. Executes research using Exa/Tavily/Google
|
||||
4. Analyzes results through the lens of user intent
|
||||
5. Returns exactly what the user needs
|
||||
|
||||
The response is organized by deliverable type (statistics, quotes, case studies, etc.)
|
||||
instead of generic search results.
|
||||
"""
|
||||
try:
|
||||
if not current_user:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
user_id = str(current_user.get('id', ''))
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="Invalid user ID")
|
||||
|
||||
logger.info(f"[Intent API] Executing intent-driven research for: {request.user_input[:50]}...")
|
||||
|
||||
# Get database session
|
||||
db = next(get_db())
|
||||
|
||||
try:
|
||||
# Get research persona
|
||||
from services.research.research_persona_service import ResearchPersonaService
|
||||
persona_service = ResearchPersonaService(db)
|
||||
research_persona = persona_service.get_or_generate(user_id)
|
||||
|
||||
# Determine intent
|
||||
if request.confirmed_intent:
|
||||
# Use confirmed intent from UI
|
||||
intent = ResearchIntent(**request.confirmed_intent)
|
||||
elif not request.skip_inference:
|
||||
# Infer intent from user input
|
||||
intent_service = ResearchIntentInference()
|
||||
intent_response = await intent_service.infer_intent(
|
||||
user_input=request.user_input,
|
||||
research_persona=research_persona,
|
||||
user_id=user_id,
|
||||
)
|
||||
intent = intent_response.intent
|
||||
else:
|
||||
# Create basic intent from input
|
||||
intent = ResearchIntent(
|
||||
primary_question=f"What are the key insights about: {request.user_input}?",
|
||||
purpose="learn",
|
||||
content_output="general",
|
||||
expected_deliverables=["key_statistics", "best_practices", "examples"],
|
||||
depth="detailed",
|
||||
original_input=request.user_input,
|
||||
confidence=0.6,
|
||||
)
|
||||
|
||||
# Generate or use provided queries
|
||||
if request.selected_queries:
|
||||
queries = [ResearchQuery(**q) for q in request.selected_queries]
|
||||
else:
|
||||
query_generator = IntentQueryGenerator()
|
||||
query_result = await query_generator.generate_queries(
|
||||
intent=intent,
|
||||
research_persona=research_persona,
|
||||
user_id=user_id,
|
||||
)
|
||||
queries = query_result.get("queries", [])
|
||||
|
||||
# Execute research using the Research Engine
|
||||
engine = ResearchEngine(db_session=db)
|
||||
|
||||
# Build context from intent
|
||||
personalization = ResearchPersonalizationContext(
|
||||
creator_id=user_id,
|
||||
industry=research_persona.default_industry if research_persona else None,
|
||||
target_audience=research_persona.default_target_audience if research_persona else None,
|
||||
)
|
||||
|
||||
# Use the highest priority query for the main search
|
||||
# (In a more advanced version, we could run multiple queries and merge)
|
||||
primary_query = queries[0] if queries else ResearchQuery(
|
||||
query=request.user_input,
|
||||
purpose=ExpectedDeliverable.KEY_STATISTICS,
|
||||
provider="exa",
|
||||
priority=5,
|
||||
expected_results="General research results",
|
||||
)
|
||||
|
||||
context = ResearchContext(
|
||||
query=primary_query.query,
|
||||
keywords=request.user_input.split()[:10],
|
||||
goal=_map_purpose_to_goal(intent.purpose),
|
||||
depth=_map_depth_to_engine_depth(intent.depth),
|
||||
provider_preference=_map_provider_to_preference(primary_query.provider),
|
||||
personalization=personalization,
|
||||
max_sources=request.max_sources,
|
||||
include_domains=request.include_domains,
|
||||
exclude_domains=request.exclude_domains,
|
||||
)
|
||||
|
||||
# Execute research and trends in parallel
|
||||
research_task = asyncio.create_task(engine.research(context))
|
||||
|
||||
# Execute Google Trends analysis in parallel (if enabled)
|
||||
trends_task = None
|
||||
trends_data = None
|
||||
if request.trends_config and request.trends_config.get("enabled"):
|
||||
from services.research.trends.google_trends_service import GoogleTrendsService
|
||||
trends_service = GoogleTrendsService()
|
||||
trends_task = asyncio.create_task(
|
||||
trends_service.analyze_trends(
|
||||
keywords=request.trends_config.get("keywords", []),
|
||||
timeframe=request.trends_config.get("timeframe", "today 12-m"),
|
||||
geo=request.trends_config.get("geo", "US"),
|
||||
user_id=user_id
|
||||
)
|
||||
)
|
||||
|
||||
# Wait for research to complete
|
||||
raw_result = await research_task
|
||||
|
||||
# Wait for trends if it was started
|
||||
if trends_task:
|
||||
try:
|
||||
trends_data = await trends_task
|
||||
logger.info(f"Google Trends data fetched: {len(trends_data.get('interest_over_time', []))} time points")
|
||||
except Exception as e:
|
||||
logger.error(f"Google Trends analysis failed: {e}")
|
||||
trends_data = None
|
||||
|
||||
# Analyze results using intent-aware analyzer
|
||||
analyzer = IntentAwareAnalyzer()
|
||||
analyzed_result = await analyzer.analyze(
|
||||
raw_results={
|
||||
"content": raw_result.raw_content or "",
|
||||
"sources": raw_result.sources,
|
||||
"grounding_metadata": raw_result.grounding_metadata,
|
||||
},
|
||||
intent=intent,
|
||||
research_persona=research_persona,
|
||||
user_id=user_id, # Required for subscription checking
|
||||
)
|
||||
|
||||
# Merge Google Trends data into trends analysis
|
||||
if trends_data and analyzed_result.trends:
|
||||
analyzed_result = _merge_trends_data(analyzed_result, trends_data)
|
||||
|
||||
# Build response
|
||||
return IntentDrivenResearchResponse(
|
||||
success=True,
|
||||
primary_answer=analyzed_result.primary_answer,
|
||||
secondary_answers=analyzed_result.secondary_answers,
|
||||
statistics=[s.dict() for s in analyzed_result.statistics],
|
||||
expert_quotes=[q.dict() for q in analyzed_result.expert_quotes],
|
||||
case_studies=[cs.dict() for cs in analyzed_result.case_studies],
|
||||
trends=[t.dict() for t in analyzed_result.trends],
|
||||
comparisons=[c.dict() for c in analyzed_result.comparisons],
|
||||
best_practices=analyzed_result.best_practices,
|
||||
step_by_step=analyzed_result.step_by_step,
|
||||
pros_cons=analyzed_result.pros_cons.dict() if analyzed_result.pros_cons else None,
|
||||
definitions=analyzed_result.definitions,
|
||||
examples=analyzed_result.examples,
|
||||
predictions=analyzed_result.predictions,
|
||||
executive_summary=analyzed_result.executive_summary,
|
||||
key_takeaways=analyzed_result.key_takeaways,
|
||||
suggested_outline=analyzed_result.suggested_outline,
|
||||
sources=[s.dict() for s in analyzed_result.sources],
|
||||
confidence=analyzed_result.confidence,
|
||||
gaps_identified=analyzed_result.gaps_identified,
|
||||
follow_up_queries=analyzed_result.follow_up_queries,
|
||||
intent=intent.dict(),
|
||||
google_trends_data=trends_data, # Include Google Trends data in response
|
||||
)
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Intent API] Research failed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return IntentDrivenResearchResponse(
|
||||
success=False,
|
||||
error_message=str(e),
|
||||
)
|
||||
|
||||
|
||||
def _map_purpose_to_goal(purpose: str) -> ResearchGoal:
|
||||
"""Map intent purpose to research goal."""
|
||||
mapping = {
|
||||
"learn": ResearchGoal.EDUCATIONAL,
|
||||
"create_content": ResearchGoal.FACTUAL,
|
||||
"make_decision": ResearchGoal.FACTUAL,
|
||||
"compare": ResearchGoal.COMPETITIVE,
|
||||
"solve_problem": ResearchGoal.EDUCATIONAL,
|
||||
"find_data": ResearchGoal.FACTUAL,
|
||||
"explore_trends": ResearchGoal.TRENDING,
|
||||
"validate": ResearchGoal.FACTUAL,
|
||||
"generate_ideas": ResearchGoal.INSPIRATIONAL,
|
||||
}
|
||||
return mapping.get(purpose, ResearchGoal.FACTUAL)
|
||||
|
||||
|
||||
def _map_depth_to_engine_depth(depth: str) -> ResearchDepth:
|
||||
"""Map intent depth to research engine depth."""
|
||||
mapping = {
|
||||
"overview": ResearchDepth.QUICK,
|
||||
"detailed": ResearchDepth.STANDARD,
|
||||
"expert": ResearchDepth.COMPREHENSIVE,
|
||||
}
|
||||
return mapping.get(depth, ResearchDepth.STANDARD)
|
||||
|
||||
|
||||
def _map_provider_to_preference(provider: str) -> ProviderPreference:
|
||||
"""Map query provider to engine preference."""
|
||||
mapping = {
|
||||
"exa": ProviderPreference.EXA,
|
||||
"tavily": ProviderPreference.TAVILY,
|
||||
"google": ProviderPreference.GOOGLE,
|
||||
}
|
||||
return mapping.get(provider, ProviderPreference.AUTO)
|
||||
|
||||
|
||||
def _merge_trends_data(
|
||||
analyzed_result: Any,
|
||||
trends_data: Dict[str, Any]
|
||||
) -> Any:
|
||||
"""
|
||||
Merge Google Trends data into analyzed result trends.
|
||||
|
||||
Enhances AI-extracted trends with Google Trends data.
|
||||
"""
|
||||
from services.research.intent.intent_aware_analyzer import IntentDrivenResearchResult
|
||||
from models.research_intent_models import TrendAnalysis
|
||||
|
||||
if not analyzed_result.trends:
|
||||
return analyzed_result
|
||||
|
||||
# Enhance each trend with Google Trends data
|
||||
enhanced_trends = []
|
||||
for trend in analyzed_result.trends:
|
||||
# Create enhanced trend with Google Trends data
|
||||
trend_dict = trend.dict() if hasattr(trend, 'dict') else trend
|
||||
trend_dict["google_trends_data"] = trends_data
|
||||
|
||||
# Add interest score if available
|
||||
if trends_data.get("interest_over_time"):
|
||||
# Calculate average interest score
|
||||
interest_values = []
|
||||
for point in trends_data["interest_over_time"]:
|
||||
for key, value in point.items():
|
||||
if key not in ["date", "isPartial"] and isinstance(value, (int, float)):
|
||||
interest_values.append(value)
|
||||
if interest_values:
|
||||
trend_dict["interest_score"] = sum(interest_values) / len(interest_values)
|
||||
|
||||
# Add related topics/queries
|
||||
if trends_data.get("related_topics"):
|
||||
top_topics = [t.get("topic_title", "") for t in trends_data["related_topics"].get("top", [])[:5]]
|
||||
rising_topics = [t.get("topic_title", "") for t in trends_data["related_topics"].get("rising", [])[:5]]
|
||||
trend_dict["related_topics"] = {"top": top_topics, "rising": rising_topics}
|
||||
|
||||
if trends_data.get("related_queries"):
|
||||
top_queries = [q.get("query", "") for q in trends_data["related_queries"].get("top", [])[:5]]
|
||||
rising_queries = [q.get("query", "") for q in trends_data["related_queries"].get("rising", [])[:5]]
|
||||
trend_dict["related_queries"] = {"top": top_queries, "rising": rising_queries}
|
||||
|
||||
# Add regional interest
|
||||
if trends_data.get("interest_by_region"):
|
||||
regional_interest = {}
|
||||
for region in trends_data["interest_by_region"][:10]: # Top 10 regions
|
||||
region_name = region.get("geoName", "")
|
||||
if region_name:
|
||||
# Get interest value (first numeric column)
|
||||
for key, value in region.items():
|
||||
if key != "geoName" and isinstance(value, (int, float)):
|
||||
regional_interest[region_name] = value
|
||||
break
|
||||
trend_dict["regional_interest"] = regional_interest
|
||||
|
||||
enhanced_trends.append(TrendAnalysis(**trend_dict))
|
||||
|
||||
# Update analyzed result with enhanced trends
|
||||
analyzed_result.trends = enhanced_trends
|
||||
return analyzed_result
|
||||
|
||||
# Include all handler routers
|
||||
router.include_router(providers.router)
|
||||
router.include_router(research.router)
|
||||
router.include_router(intent.router)
|
||||
router.include_router(projects.router)
|
||||
|
||||
182
backend/api/research/utils.py
Normal file
182
backend/api/research/utils.py
Normal file
@@ -0,0 +1,182 @@
|
||||
"""
|
||||
Research API Utilities
|
||||
|
||||
Helper functions for research endpoints.
|
||||
"""
|
||||
|
||||
from typing import Dict, Any
|
||||
from services.research.core import (
|
||||
ResearchContext,
|
||||
ResearchPersonalizationContext,
|
||||
ContentType,
|
||||
ResearchGoal,
|
||||
ResearchDepth,
|
||||
ProviderPreference,
|
||||
)
|
||||
from models.research_intent_models import TrendAnalysis
|
||||
|
||||
|
||||
def convert_to_research_context(request, user_id: str) -> ResearchContext:
|
||||
"""Convert API request to ResearchContext."""
|
||||
from .models import ResearchRequest
|
||||
|
||||
# Map string enums
|
||||
goal_map = {
|
||||
"factual": ResearchGoal.FACTUAL,
|
||||
"trending": ResearchGoal.TRENDING,
|
||||
"competitive": ResearchGoal.COMPETITIVE,
|
||||
"educational": ResearchGoal.EDUCATIONAL,
|
||||
"technical": ResearchGoal.TECHNICAL,
|
||||
"inspirational": ResearchGoal.INSPIRATIONAL,
|
||||
}
|
||||
|
||||
depth_map = {
|
||||
"quick": ResearchDepth.QUICK,
|
||||
"standard": ResearchDepth.STANDARD,
|
||||
"comprehensive": ResearchDepth.COMPREHENSIVE,
|
||||
"expert": ResearchDepth.EXPERT,
|
||||
}
|
||||
|
||||
provider_map = {
|
||||
"auto": ProviderPreference.AUTO,
|
||||
"exa": ProviderPreference.EXA,
|
||||
"tavily": ProviderPreference.TAVILY,
|
||||
"google": ProviderPreference.GOOGLE,
|
||||
"hybrid": ProviderPreference.HYBRID,
|
||||
}
|
||||
|
||||
content_type_map = {
|
||||
"blog": ContentType.BLOG,
|
||||
"podcast": ContentType.PODCAST,
|
||||
"video": ContentType.VIDEO,
|
||||
"social": ContentType.SOCIAL,
|
||||
"email": ContentType.EMAIL,
|
||||
"newsletter": ContentType.NEWSLETTER,
|
||||
"whitepaper": ContentType.WHITEPAPER,
|
||||
"general": ContentType.GENERAL,
|
||||
}
|
||||
|
||||
# Build personalization context
|
||||
personalization = ResearchPersonalizationContext(
|
||||
creator_id=user_id,
|
||||
content_type=content_type_map.get(request.content_type or "general", ContentType.GENERAL),
|
||||
industry=request.industry,
|
||||
target_audience=request.target_audience,
|
||||
tone=request.tone,
|
||||
)
|
||||
|
||||
return ResearchContext(
|
||||
query=request.query,
|
||||
keywords=request.keywords,
|
||||
goal=goal_map.get(request.goal or "factual", ResearchGoal.FACTUAL),
|
||||
depth=depth_map.get(request.depth or "standard", ResearchDepth.STANDARD),
|
||||
provider_preference=provider_map.get(request.provider or "auto", ProviderPreference.AUTO),
|
||||
personalization=personalization,
|
||||
max_sources=request.max_sources,
|
||||
recency=request.recency,
|
||||
include_domains=request.include_domains,
|
||||
exclude_domains=request.exclude_domains,
|
||||
advanced_mode=request.advanced_mode,
|
||||
exa_category=request.exa_category,
|
||||
exa_search_type=request.exa_search_type,
|
||||
tavily_topic=request.tavily_topic,
|
||||
tavily_search_depth=request.tavily_search_depth,
|
||||
tavily_include_answer=request.tavily_include_answer,
|
||||
tavily_time_range=request.tavily_time_range,
|
||||
)
|
||||
|
||||
|
||||
def map_purpose_to_goal(purpose: str) -> ResearchGoal:
|
||||
"""Map intent purpose to research goal."""
|
||||
mapping = {
|
||||
"learn": ResearchGoal.EDUCATIONAL,
|
||||
"create_content": ResearchGoal.FACTUAL,
|
||||
"make_decision": ResearchGoal.FACTUAL,
|
||||
"compare": ResearchGoal.COMPETITIVE,
|
||||
"solve_problem": ResearchGoal.EDUCATIONAL,
|
||||
"find_data": ResearchGoal.FACTUAL,
|
||||
"explore_trends": ResearchGoal.TRENDING,
|
||||
"validate": ResearchGoal.FACTUAL,
|
||||
"generate_ideas": ResearchGoal.INSPIRATIONAL,
|
||||
}
|
||||
return mapping.get(purpose, ResearchGoal.FACTUAL)
|
||||
|
||||
|
||||
def map_depth_to_engine_depth(depth: str) -> ResearchDepth:
|
||||
"""Map intent depth to research engine depth."""
|
||||
mapping = {
|
||||
"overview": ResearchDepth.QUICK,
|
||||
"detailed": ResearchDepth.STANDARD,
|
||||
"expert": ResearchDepth.COMPREHENSIVE,
|
||||
}
|
||||
return mapping.get(depth, ResearchDepth.STANDARD)
|
||||
|
||||
|
||||
def map_provider_to_preference(provider: str) -> ProviderPreference:
|
||||
"""Map query provider to engine preference."""
|
||||
mapping = {
|
||||
"exa": ProviderPreference.EXA,
|
||||
"tavily": ProviderPreference.TAVILY,
|
||||
"google": ProviderPreference.GOOGLE,
|
||||
}
|
||||
return mapping.get(provider, ProviderPreference.AUTO)
|
||||
|
||||
|
||||
def merge_trends_data(analyzed_result: Any, trends_data: Dict[str, Any]) -> Any:
|
||||
"""
|
||||
Merge Google Trends data into analyzed result trends.
|
||||
|
||||
Enhances AI-extracted trends with Google Trends data.
|
||||
"""
|
||||
from services.research.intent.intent_aware_analyzer import IntentDrivenResearchResult
|
||||
|
||||
if not analyzed_result.trends:
|
||||
return analyzed_result
|
||||
|
||||
# Enhance each trend with Google Trends data
|
||||
enhanced_trends = []
|
||||
for trend in analyzed_result.trends:
|
||||
# Create enhanced trend with Google Trends data
|
||||
trend_dict = trend.dict() if hasattr(trend, 'dict') else trend
|
||||
trend_dict["google_trends_data"] = trends_data
|
||||
|
||||
# Add interest score if available
|
||||
if trends_data.get("interest_over_time"):
|
||||
# Calculate average interest score
|
||||
interest_values = []
|
||||
for point in trends_data["interest_over_time"]:
|
||||
for key, value in point.items():
|
||||
if key not in ["date", "isPartial"] and isinstance(value, (int, float)):
|
||||
interest_values.append(value)
|
||||
if interest_values:
|
||||
trend_dict["interest_score"] = sum(interest_values) / len(interest_values)
|
||||
|
||||
# Add related topics/queries
|
||||
if trends_data.get("related_topics"):
|
||||
top_topics = [t.get("topic_title", "") for t in trends_data["related_topics"].get("top", [])[:5]]
|
||||
rising_topics = [t.get("topic_title", "") for t in trends_data["related_topics"].get("rising", [])[:5]]
|
||||
trend_dict["related_topics"] = {"top": top_topics, "rising": rising_topics}
|
||||
|
||||
if trends_data.get("related_queries"):
|
||||
top_queries = [q.get("query", "") for q in trends_data["related_queries"].get("top", [])[:5]]
|
||||
rising_queries = [q.get("query", "") for q in trends_data["related_queries"].get("rising", [])[:5]]
|
||||
trend_dict["related_queries"] = {"top": top_queries, "rising": rising_queries}
|
||||
|
||||
# Add regional interest
|
||||
if trends_data.get("interest_by_region"):
|
||||
regional_interest = {}
|
||||
for region in trends_data["interest_by_region"][:10]: # Top 10 regions
|
||||
region_name = region.get("geoName", "")
|
||||
if region_name:
|
||||
# Get interest value (first numeric column)
|
||||
for key, value in region.items():
|
||||
if key != "geoName" and isinstance(value, (int, float)):
|
||||
regional_interest[region_name] = value
|
||||
break
|
||||
trend_dict["regional_interest"] = regional_interest
|
||||
|
||||
enhanced_trends.append(TrendAnalysis(**trend_dict))
|
||||
|
||||
# Update analyzed result with enhanced trends
|
||||
analyzed_result.trends = enhanced_trends
|
||||
return analyzed_result
|
||||
Reference in New Issue
Block a user