Merge branch 'recover-stash'
This commit is contained in:
@@ -9,10 +9,12 @@ from fastapi import APIRouter, HTTPException, Depends
|
||||
from typing import Any, Dict, List, Optional
|
||||
from pydantic import BaseModel, Field
|
||||
from loguru import logger
|
||||
from datetime import datetime
|
||||
from middleware.auth_middleware import get_current_user
|
||||
from sqlalchemy.orm import Session
|
||||
from services.database import get_db as get_db_dependency
|
||||
from utils.text_asset_tracker import save_and_track_text_content
|
||||
from models.content_asset_models import AssetType, AssetSource
|
||||
|
||||
from models.blog_models import (
|
||||
BlogResearchRequest,
|
||||
@@ -36,6 +38,7 @@ from models.blog_models import (
|
||||
from services.blog_writer.blog_service import BlogWriterService
|
||||
from services.blog_writer.seo.blog_seo_recommendation_applier import BlogSEORecommendationApplier
|
||||
from services.llm_providers.main_text_generation import llm_text_gen
|
||||
from services.content_asset_service import ContentAssetService
|
||||
from .task_manager import task_manager
|
||||
from .cache_manager import cache_manager
|
||||
from models.blog_models import MediumBlogGenerateRequest
|
||||
@@ -1260,3 +1263,233 @@ async def save_complete_blog_asset(
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save complete blog asset: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ---------------------------------------
|
||||
# Blog Asset API (phase-by-phase saving via ContentAsset)
|
||||
# ---------------------------------------
|
||||
|
||||
|
||||
class BlogAssetCreateRequest(BaseModel):
|
||||
research_keywords: str = Field(..., max_length=2000, description="Research keywords / topic")
|
||||
topic: Optional[str] = Field(default=None, max_length=500)
|
||||
word_count_target: Optional[int] = Field(default=None, ge=100, le=20000)
|
||||
|
||||
|
||||
class BlogAssetUpdateRequest(BaseModel):
|
||||
phase: Optional[str] = Field(default=None, pattern=r"^(research|outline|content|seo|publish)$")
|
||||
topic: Optional[str] = Field(default=None, max_length=500)
|
||||
selected_title: Optional[str] = Field(default=None, max_length=500)
|
||||
word_count_target: Optional[int] = Field(default=None, ge=100, le=20000)
|
||||
research_data: Optional[Dict[str, Any]] = None
|
||||
outline_data: Optional[Dict[str, Any]] = None
|
||||
content_data: Optional[Dict[str, Any]] = None
|
||||
seo_data: Optional[Dict[str, Any]] = None
|
||||
publish_data: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
def _normalize_keywords(kw: str) -> str:
|
||||
"""Normalize keywords for duplicate comparison."""
|
||||
return " ".join(sorted(kw.lower().split()))
|
||||
|
||||
|
||||
@router.post("/asset", response_model=Dict[str, Any])
|
||||
async def create_blog_asset(
|
||||
request: BlogAssetCreateRequest,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Create a blog ContentAsset on research start.
|
||||
Returns existing asset if duplicate keywords found (unique topics only).
|
||||
"""
|
||||
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")
|
||||
|
||||
svc = ContentAssetService(db)
|
||||
normalized_kw = _normalize_keywords(request.research_keywords)
|
||||
|
||||
# Duplicate check — search existing blog assets for matching keywords
|
||||
existing_assets, _ = svc.get_user_assets(
|
||||
user_id=user_id,
|
||||
source_module=AssetSource.BLOG_WRITER,
|
||||
asset_type=AssetType.TEXT,
|
||||
limit=100,
|
||||
)
|
||||
for asset in existing_assets:
|
||||
meta = asset.asset_metadata or {}
|
||||
if meta.get("normalized_keywords") == normalized_kw:
|
||||
logger.info(f"Duplicate blog asset found: {asset.id}, returning existing")
|
||||
return {
|
||||
"success": True,
|
||||
"asset": _asset_to_response(asset),
|
||||
"existing": True,
|
||||
}
|
||||
|
||||
# Create new ContentAsset for this blog
|
||||
title = request.topic or request.research_keywords[:200]
|
||||
asset_metadata = {
|
||||
"phase": "research",
|
||||
"research_keywords": request.research_keywords,
|
||||
"normalized_keywords": normalized_kw,
|
||||
"word_count_target": request.word_count_target,
|
||||
"topic": request.topic,
|
||||
"research_data": None,
|
||||
"outline_data": None,
|
||||
"content_data": None,
|
||||
"seo_data": None,
|
||||
"publish_data": None,
|
||||
}
|
||||
asset = svc.create_asset(
|
||||
user_id=user_id,
|
||||
asset_type=AssetType.TEXT,
|
||||
source_module=AssetSource.BLOG_WRITER,
|
||||
filename=f"blog_{int(datetime.utcnow().timestamp())}.md",
|
||||
file_url=f"/api/blog/content/pending",
|
||||
title=title,
|
||||
description=f"Blog: {title}",
|
||||
tags=["blog", "research"],
|
||||
asset_metadata=asset_metadata,
|
||||
)
|
||||
logger.info(f"✅ Created blog asset: {asset.id}")
|
||||
return {
|
||||
"success": True,
|
||||
"asset": _asset_to_response(asset),
|
||||
"existing": False,
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create blog asset: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/asset/{asset_id}", response_model=Dict[str, Any])
|
||||
async def update_blog_asset(
|
||||
asset_id: int,
|
||||
request: BlogAssetUpdateRequest,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Update a blog asset's phase, metadata, and tags."""
|
||||
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")
|
||||
|
||||
svc = ContentAssetService(db)
|
||||
asset = svc.get_asset_by_id(asset_id, user_id)
|
||||
if not asset:
|
||||
raise HTTPException(status_code=404, detail="Blog asset not found")
|
||||
|
||||
meta = dict(asset.asset_metadata or {})
|
||||
tags = list(asset.tags or [])
|
||||
|
||||
if request.phase is not None:
|
||||
meta["phase"] = request.phase
|
||||
# Update tags to reflect phase
|
||||
new_tags = [t for t in tags if t not in ("research", "outline", "content", "seo", "publish")]
|
||||
new_tags.append(request.phase)
|
||||
if "blog" not in new_tags:
|
||||
new_tags.append("blog")
|
||||
tags = new_tags
|
||||
|
||||
if request.topic is not None:
|
||||
meta["topic"] = request.topic
|
||||
if request.selected_title is not None:
|
||||
meta["selected_title"] = request.selected_title
|
||||
if request.word_count_target is not None:
|
||||
meta["word_count_target"] = request.word_count_target
|
||||
|
||||
for field in ("research_data", "outline_data", "content_data", "seo_data", "publish_data"):
|
||||
val = getattr(request, field, None)
|
||||
if val is not None:
|
||||
meta[field] = val
|
||||
|
||||
if meta.get("selected_title"):
|
||||
new_title = meta["selected_title"]
|
||||
elif meta.get("topic"):
|
||||
new_title = meta["topic"]
|
||||
else:
|
||||
new_title = asset.title or "Blog Post"
|
||||
|
||||
updated = svc.update_asset(
|
||||
asset_id=asset_id,
|
||||
user_id=user_id,
|
||||
title=new_title[:500],
|
||||
tags=tags,
|
||||
asset_metadata=meta,
|
||||
)
|
||||
if not updated:
|
||||
raise HTTPException(status_code=500, detail="Failed to update asset")
|
||||
|
||||
logger.info(f"✅ Updated blog asset {asset_id}: phase={meta.get('phase')}")
|
||||
return {"success": True, "asset": _asset_to_response(updated)}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update blog asset {asset_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/asset/{asset_id}", response_model=Dict[str, Any])
|
||||
async def get_blog_asset(
|
||||
asset_id: int,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get a blog asset with all phase data."""
|
||||
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")
|
||||
|
||||
svc = ContentAssetService(db)
|
||||
asset = svc.get_asset_by_id(asset_id, user_id)
|
||||
if not asset:
|
||||
raise HTTPException(status_code=404, detail="Blog asset not found")
|
||||
|
||||
return {"success": True, "asset": _asset_to_response(asset, full=True)}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get blog asset {asset_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
def _asset_to_response(asset: Any, full: bool = False) -> Dict[str, Any]:
|
||||
"""Convert a ContentAsset to a blog asset response dict."""
|
||||
meta = asset.asset_metadata or {}
|
||||
resp: Dict[str, Any] = {
|
||||
"id": asset.id,
|
||||
"title": asset.title,
|
||||
"description": asset.description,
|
||||
"tags": asset.tags or [],
|
||||
"phase": meta.get("phase", "research"),
|
||||
"research_keywords": meta.get("research_keywords"),
|
||||
"topic": meta.get("topic"),
|
||||
"selected_title": meta.get("selected_title"),
|
||||
"word_count_target": meta.get("word_count_target"),
|
||||
"has_research": meta.get("research_data") is not None,
|
||||
"has_outline": meta.get("outline_data") is not None,
|
||||
"has_content": meta.get("content_data") is not None,
|
||||
"has_seo": meta.get("seo_data") is not None,
|
||||
"has_publish": meta.get("publish_data") is not None,
|
||||
"created_at": asset.created_at.isoformat() if asset.created_at else None,
|
||||
"updated_at": asset.updated_at.isoformat() if asset.updated_at else None,
|
||||
}
|
||||
if full:
|
||||
resp["research_data"] = meta.get("research_data")
|
||||
resp["outline_data"] = meta.get("outline_data")
|
||||
resp["content_data"] = meta.get("content_data")
|
||||
resp["seo_data"] = meta.get("seo_data")
|
||||
resp["publish_data"] = meta.get("publish_data")
|
||||
return resp
|
||||
|
||||
@@ -256,7 +256,8 @@ class TaskManager:
|
||||
self.task_storage[task_id]["status"] = "running"
|
||||
self.task_storage[task_id]["progress_messages"] = []
|
||||
|
||||
await self.update_progress(task_id, "📦 Packaging outline and metadata...")
|
||||
await self.update_progress(task_id, "📝 Alwrity is preparing your blog content — this usually takes 20–40 seconds.")
|
||||
await self.update_progress(task_id, "📦 Packaging your outline sections and research data...")
|
||||
|
||||
# Basic guard: respect global target words
|
||||
total_target = int(request.globalTargetWords or 1000)
|
||||
@@ -281,16 +282,22 @@ class TaskManager:
|
||||
# Check if result came from cache
|
||||
cache_hit = getattr(result, 'cache_hit', False)
|
||||
if cache_hit:
|
||||
await self.update_progress(task_id, "⚡ Found cached content - loading instantly!")
|
||||
await self.update_progress(task_id, "⚡ Found existing content in cache — no need to regenerate!")
|
||||
else:
|
||||
await self.update_progress(task_id, "🤖 Generated fresh content with AI...")
|
||||
await self.update_progress(task_id, "✨ Post-processing and assembling sections...")
|
||||
await self.update_progress(task_id, "🧠 AI is writing each section with research-backed insights and natural flow...")
|
||||
await self.update_progress(task_id, "✨ Polishing content — improving structure, readability, and transitions...")
|
||||
|
||||
# Mark completed
|
||||
self.task_storage[task_id]["status"] = "completed"
|
||||
self.task_storage[task_id]["result"] = result.dict()
|
||||
await self.update_progress(task_id, f"✅ Generated {len(result.sections)} sections successfully.")
|
||||
|
||||
section_count = len(result.sections)
|
||||
total_words = sum(getattr(s, 'wordCount', 0) or 0 for s in result.sections)
|
||||
await self.update_progress(
|
||||
task_id,
|
||||
f"✅ Content generation complete! {section_count} sections written ({total_words} words). "
|
||||
"Next up: SEO Analysis to optimize your blog for search engines."
|
||||
)
|
||||
|
||||
# Note: Blog content tracking is handled in the status endpoint
|
||||
# to ensure we have proper database session and user context
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ async def detect_hallucinations(request: HallucinationDetectionRequest, current_
|
||||
text=source.get('text', ''),
|
||||
published_date=source.get('publishedDate'),
|
||||
author=source.get('author'),
|
||||
score=source.get('score', 0.5)
|
||||
score=source.get('score') if source.get('score') is not None else 0.5
|
||||
)
|
||||
for source in claim.supporting_sources
|
||||
]
|
||||
@@ -83,7 +83,7 @@ async def detect_hallucinations(request: HallucinationDetectionRequest, current_
|
||||
text=source.get('text', ''),
|
||||
published_date=source.get('publishedDate'),
|
||||
author=source.get('author'),
|
||||
score=source.get('score', 0.5)
|
||||
score=source.get('score') if source.get('score') is not None else 0.5
|
||||
)
|
||||
for source in claim.refuting_sources
|
||||
]
|
||||
@@ -214,7 +214,7 @@ async def verify_claim(request: ClaimVerificationRequest, current_user: Dict[str
|
||||
text=source.get('text', ''),
|
||||
published_date=source.get('publishedDate'),
|
||||
author=source.get('author'),
|
||||
score=source.get('score', 0.5)
|
||||
score=source.get('score') if source.get('score') is not None else 0.5
|
||||
)
|
||||
for source in claim_result.supporting_sources
|
||||
]
|
||||
@@ -226,7 +226,7 @@ async def verify_claim(request: ClaimVerificationRequest, current_user: Dict[str
|
||||
text=source.get('text', ''),
|
||||
published_date=source.get('publishedDate'),
|
||||
author=source.get('author'),
|
||||
score=source.get('score', 0.5)
|
||||
score=source.get('score') if source.get('score') is not None else 0.5
|
||||
)
|
||||
for source in claim_result.refuting_sources
|
||||
]
|
||||
|
||||
@@ -12,6 +12,7 @@ router = APIRouter(prefix="/api/writing-assistant", tags=["writing-assistant"])
|
||||
|
||||
class SuggestRequest(BaseModel):
|
||||
text: str
|
||||
cursor_position: int | None = None
|
||||
|
||||
|
||||
class SourceModel(BaseModel):
|
||||
@@ -32,6 +33,7 @@ class SuggestionModel(BaseModel):
|
||||
class SuggestResponse(BaseModel):
|
||||
success: bool
|
||||
suggestions: List[SuggestionModel]
|
||||
message: str = ""
|
||||
|
||||
|
||||
assistant_service = WritingAssistantService()
|
||||
@@ -41,9 +43,9 @@ assistant_service = WritingAssistantService()
|
||||
async def suggest_endpoint(req: SuggestRequest, current_user: Dict[str, Any] = Depends(get_current_user)) -> SuggestResponse:
|
||||
try:
|
||||
user_id = current_user.get("id")
|
||||
suggestions = await assistant_service.suggest(req.text, user_id=user_id)
|
||||
suggestions = await assistant_service.suggest(req.text, user_id=user_id, cursor_position=req.cursor_position)
|
||||
return SuggestResponse(
|
||||
success=True,
|
||||
success=len(suggestions) > 0,
|
||||
suggestions=[
|
||||
SuggestionModel(
|
||||
text=s.text,
|
||||
|
||||
Reference in New Issue
Block a user