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}
|
||||
Reference in New Issue
Block a user