diff --git a/.env.example b/.env.example index be8f3125c..0ec2861ea 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,12 @@ GITHUB_WEBHOOK_SECRET= # Solana - defaults to devnet for safe local development SOLANA_RPC_URL=https://api.devnet.solana.com +# LLM API Keys (for AI Bounty Description Enhancer, Issue #848) +# At least one provider is required for the enhancer to work +ANTHROPIC_API_KEY= +OPENAI_API_KEY= +GOOGLE_API_KEY= + # Observability (Prometheus /metrics + background probes) # OBSERVABILITY_ENABLE_BACKGROUND=true # OBSERVABILITY_REFRESH_SECONDS=15 diff --git a/backend/app/api/bounties.py b/backend/app/api/bounties.py index e0f126db4..a345b423d 100644 --- a/backend/app/api/bounties.py +++ b/backend/app/api/bounties.py @@ -5,7 +5,7 @@ search, autocomplete, hot bounties, recommended bounties. """ -from typing import Optional +from typing import List, Optional from pydantic import BaseModel, Field as PydanticField from fastapi import APIRouter, Depends, HTTPException, Query, status diff --git a/backend/app/api/bounty_enhance.py b/backend/app/api/bounty_enhance.py new file mode 100644 index 000000000..2ebd50a5c --- /dev/null +++ b/backend/app/api/bounty_enhance.py @@ -0,0 +1,300 @@ +"""AI Bounty Description Enhancer API router (Issue #848). + +Endpoints: +- POST /api/bounties/{bounty_id}/enhance — Trigger AI-powered description enhancement +- GET /api/bounties/{bounty_id}/enhancements — List all enhancement records +- GET /api/bounties/{bounty_id}/enhancements/{enhancement_id} — Get a specific enhancement +- POST /api/bounties/{bounty_id}/enhancements/{enhancement_id}/approve — Approve an enhancement +- POST /api/bounties/{bounty_id}/enhancements/{enhancement_id}/reject — Reject an enhancement +""" + +import logging +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, status + +from app.models.bounty_enhance import ( + EnhanceApproval, + EnhanceRequest, + EnhanceResponse, + EnhancementListResponse, + EnhancementRecord, + LLMProvider, +) +from app.models.errors import ErrorResponse +from app.services.bounty_service import get_bounty +from app.services import bounty_enhancer_service +from app.services.bounty_service import update_bounty +from app.models.bounty import BountyUpdate + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/bounties", tags=["bounty-enhance"]) + + +@router.post( + "/{bounty_id}/enhance", + response_model=EnhanceResponse, + status_code=status.HTTP_200_OK, + summary="Enhance a bounty description using AI", + description="Analyzes a bounty description using multiple LLM providers (Claude, Codex, Gemini) " + "and generates improved versions with clearer requirements, acceptance criteria, and examples. " + "Results are stored for maintainer review.", + responses={ + 404: {"model": ErrorResponse, "description": "Bounty not found"}, + 400: {"model": ErrorResponse, "description": "Bounty description is empty"}, + }, +) +async def enhance_bounty_description( + bounty_id: str, + request: EnhanceRequest, +) -> EnhanceResponse: + """Trigger AI-powered enhancement of a bounty description. + + Args: + bounty_id: The UUID of the bounty to enhance. + request: The enhancement request payload (providers + custom prompt). + + Returns: + EnhanceResponse with results from each configured LLM provider. + + Raises: + HTTPException 404: If the bounty is not found. + HTTPException 400: If the bounty has no description to enhance. + """ + # Fetch the bounty + bounty = await get_bounty(bounty_id) + if not bounty: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Bounty with ID '{bounty_id}' not found", + ) + + # Check that the bounty has a description + if not bounty.description or not bounty.description.strip(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Bounty has no description to enhance. Add a description first.", + ) + + # Enhance the description + result = await bounty_enhancer_service.enhance_bounty_description( + bounty_id=bounty_id, + title=bounty.title, + description=bounty.description, + request=request, + created_by="api", + ) + + return result + + +@router.get( + "/{bounty_id}/enhancements", + response_model=EnhancementListResponse, + summary="List all enhancement records for a bounty", + description="Returns all AI-generated description enhancement records for a specific bounty, " + "ordered newest first.", + responses={ + 404: {"model": ErrorResponse, "description": "Bounty not found"}, + }, +) +async def list_enhancements( + bounty_id: str, +) -> EnhancementListResponse: + """List all AI description enhancement records for a bounty. + + Args: + bounty_id: The UUID of the bounty. + + Returns: + EnhancementListResponse with all enhancement records (newest first). + + Raises: + HTTPException 404: If the bounty is not found. + """ + # Verify bounty exists + bounty = await get_bounty(bounty_id) + if not bounty: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Bounty with ID '{bounty_id}' not found", + ) + + return await bounty_enhancer_service.get_enhancements_for_bounty(bounty_id) + + +@router.get( + "/{bounty_id}/enhancements/{enhancement_id}", + response_model=EnhancementRecord, + summary="Get a specific enhancement record", + description="Returns a single AI description enhancement record by its ID.", + responses={ + 404: {"model": ErrorResponse, "description": "Enhancement record not found"}, + }, +) +async def get_enhancement( + bounty_id: str, + enhancement_id: str, +) -> EnhancementRecord: + """Get a specific enhancement record. + + Args: + bounty_id: The UUID of the bounty. + enhancement_id: The ID of the enhancement record. + + Returns: + The EnhancementRecord with full details. + + Raises: + HTTPException 404: If the enhancement record is not found. + """ + record = await bounty_enhancer_service.get_enhancement(bounty_id, enhancement_id) + if not record: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Enhancement record '{enhancement_id}' not found for bounty '{bounty_id}'", + ) + return record + + +@router.post( + "/{bounty_id}/enhancements/{enhancement_id}/approve", + response_model=EnhancementRecord, + summary="Approve an AI-generated enhancement", + description="Approve a specific LLM provider's enhancement result and apply it to the bounty. " + "The bounty's title and description will be updated with the approved version.", + responses={ + 404: {"model": ErrorResponse, "description": "Enhancement record not found"}, + 400: {"model": ErrorResponse, "description": "Invalid approval request"}, + }, +) +async def approve_enhancement( + bounty_id: str, + enhancement_id: str, + approval: EnhanceApproval, +) -> EnhancementRecord: + """Approve an AI-generated enhancement and apply it to the bounty. + + The request body must specify which LLM provider's result to use + (e.g., 'claude', 'codex', or 'gemini'). + + Args: + bounty_id: The UUID of the bounty. + enhancement_id: The ID of the enhancement record. + approval: The approval payload with provider selection. + + Returns: + The updated EnhancementRecord with the approved result. + + Raises: + HTTPException 404: If the enhancement record is not found. + HTTPException 400: If the provider's result has an error or is invalid. + """ + if approval.action != "approve": + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Use the /reject endpoint for rejection. This endpoint requires action='approve'.", + ) + + # The enhancement service expects a provider in the body + # We'll use the first non-error provider as default + record = await bounty_enhancer_service.get_enhancement(bounty_id, enhancement_id) + if not record: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Enhancement record '{enhancement_id}' not found for bounty '{bounty_id}'", + ) + + # Find the first successful provider result + selected_provider = None + for result in record.results: + if result.error is None: + selected_provider = result.provider + break + + if not selected_provider: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="No successful enhancement results available to approve. " + "All providers returned errors.", + ) + + # Approve the enhancement + updated_record = await bounty_enhancer_service.approve_enhancement( + bounty_id=bounty_id, + enhancement_id=enhancement_id, + provider=selected_provider, + ) + + if not updated_record: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Enhancement record '{enhancement_id}' not found", + ) + + # Apply the enhancement to the bounty + if updated_record.selected_result: + await update_bounty( + bounty_id=bounty_id, + data=BountyUpdate( + title=updated_record.selected_result.enhanced_title, + description=updated_record.selected_result.enhanced_description, + ), + ) + logger.info( + "Bounty %s description enhanced (provider: %s, enhancement: %s)", + bounty_id, + selected_provider.value, + enhancement_id, + ) + + return updated_record + + +@router.post( + "/{bounty_id}/enhancements/{enhancement_id}/reject", + response_model=EnhancementRecord, + summary="Reject an AI-generated enhancement", + description="Reject an AI-generated description enhancement without applying it.", + responses={ + 404: {"model": ErrorResponse, "description": "Enhancement record not found"}, + }, +) +async def reject_enhancement( + bounty_id: str, + enhancement_id: str, +) -> EnhancementRecord: + """Reject an AI-generated description enhancement. + + The enhancement is marked as rejected and the bounty description is + not modified. + + Args: + bounty_id: The UUID of the bounty. + enhancement_id: The ID of the enhancement record. + + Returns: + The updated EnhancementRecord with rejected status. + + Raises: + HTTPException 404: If the enhancement record is not found. + """ + record = await bounty_enhancer_service.reject_enhancement( + bounty_id=bounty_id, + enhancement_id=enhancement_id, + ) + + if not record: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Enhancement record '{enhancement_id}' not found for bounty '{bounty_id}'", + ) + + logger.info( + "Bounty %s enhancement %s rejected", + bounty_id, + enhancement_id, + ) + + return record \ No newline at end of file diff --git a/backend/app/main.py b/backend/app/main.py index 99fa7b4e2..d42d9f9c8 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -56,6 +56,7 @@ from app.api.og import router as og_router from app.api.contributor_webhooks import router as contributor_webhooks_router from app.api.siws import router as siws_router +from app.api.bounty_enhance import router as bounty_enhance_router from app.middleware.security import SecurityHeadersMiddleware from app.middleware.sanitization import InputSanitizationMiddleware from app.services.config_validator import install_log_filter, validate_secrets @@ -411,6 +412,7 @@ async def value_error_handler(request: Request, exc: ValueError): app.include_router(og_router) app.include_router(contributor_webhooks_router, prefix="/api") app.include_router(siws_router, prefix="/api") +app.include_router(bounty_enhance_router, prefix="/api") # System Health: /health, Prometheus: /metrics app.include_router(health_router) diff --git a/backend/app/models/bounty_enhance.py b/backend/app/models/bounty_enhance.py new file mode 100644 index 000000000..7ca7804bb --- /dev/null +++ b/backend/app/models/bounty_enhance.py @@ -0,0 +1,177 @@ +"""Pydantic models for the AI Bounty Description Enhancer (Issue #848). + +Defines the request/response schemas for: +- Triggering AI-powered description enhancement +- Viewing enhancement history +- Approving/rejecting enhancements +""" + +from datetime import datetime, timezone +from enum import Enum +from typing import Optional + +from pydantic import BaseModel, Field + + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class EnhancementStatus(str, Enum): + """Lifecycle status of a bounty description enhancement.""" + + PENDING = "pending" + APPROVED = "approved" + REJECTED = "rejected" + + +class LLMProvider(str, Enum): + """Supported LLM providers for description enhancement.""" + + CLAUDE = "claude" + CODEX = "codex" + GEMINI = "gemini" + + +# --------------------------------------------------------------------------- +# Request models +# --------------------------------------------------------------------------- + + +class EnhanceRequest(BaseModel): + """Payload for requesting an AI-powered bounty description enhancement. + + The enhancer will analyze the current description and generate improved + versions with clearer requirements, acceptance criteria, and examples. + """ + + providers: list[LLMProvider] = Field( + default_factory=lambda: [LLMProvider.CLAUDE, LLMProvider.CODEX, LLMProvider.GEMINI], + description="List of LLM providers to use for enhancement", + max_length=5, + ) + custom_prompt: Optional[str] = Field( + None, + max_length=500, + description="Optional custom instructions for the enhancement prompt", + ) + + +class EnhanceApproval(BaseModel): + """Payload for approving or rejecting an enhancement result.""" + + action: str = Field( + ..., + pattern=r"^(approve|reject)$", + description="'approve' to accept the enhancement and update the bounty description, or 'reject' to decline", + ) + + +# --------------------------------------------------------------------------- +# Response models +# --------------------------------------------------------------------------- + + +class LLMEnhancementResult(BaseModel): + """Enhancement result from a single LLM provider.""" + + provider: LLMProvider + enhanced_title: str = Field( + ..., max_length=200, description="The provider's suggested improved title" + ) + enhanced_description: str = Field( + ..., + max_length=5000, + description="The provider's suggested improved description with structured sections", + ) + changes_summary: str = Field( + ..., + max_length=500, + description="Brief summary of what was improved (e.g., 'Added acceptance criteria, clarified requirements, added examples')", + ) + confidence_score: float = Field( + ..., + ge=0.0, + le=1.0, + description="Provider's confidence in the enhancement quality (0.0 to 1.0)", + ) + error: Optional[str] = Field( + None, + description="Error message if the provider failed to generate an enhancement", + ) + + +class EnhancementRecord(BaseModel): + """A single bounty description enhancement record.""" + + id: str = Field( + ..., description="Unique identifier for this enhancement record" + ) + bounty_id: str = Field( + ..., description="The bounty ID this enhancement belongs to" + ) + original_title: str = Field( + ..., max_length=200, description="The original bounty title before enhancement" + ) + original_description: str = Field( + ..., + max_length=5000, + description="The original bounty description before enhancement", + ) + results: list[LLMEnhancementResult] = Field( + default_factory=list, + description="Enhancement results from each LLM provider", + ) + selected_result: Optional[LLMEnhancementResult] = Field( + None, + description="The enhancement result that was approved (if any)", + ) + status: EnhancementStatus = Field( + default=EnhancementStatus.PENDING, + description="Current status of this enhancement request", + ) + created_by: str = Field( + ..., description="User or agent that triggered the enhancement" + ) + created_at: datetime = Field( + default_factory=lambda: datetime.now(timezone.utc), + description="When the enhancement was requested", + ) + updated_at: datetime = Field( + default_factory=lambda: datetime.now(timezone.utc), + description="When the enhancement was last updated", + ) + + +class EnhanceResponse(BaseModel): + """Response after triggering a bounty description enhancement.""" + + enhancement_id: str = Field( + ..., description="ID of the created enhancement record" + ) + bounty_id: str = Field( + ..., description="The bounty ID that was enhanced" + ) + results: list[LLMEnhancementResult] = Field( + ..., + description="Enhancement results from each requested LLM provider", + ) + status: EnhancementStatus = Field( + ..., description="Current status of this enhancement request" + ) + created_at: datetime = Field( + ..., description="When the enhancement was requested" + ) + message: str = Field( + ..., + description="Human-readable summary of what happened", + ) + + +class EnhancementListResponse(BaseModel): + """Paginated list of enhancement records for a bounty.""" + + items: list[EnhancementRecord] + total: int + bounty_id: str \ No newline at end of file diff --git a/backend/app/services/bounty_enhancer_service.py b/backend/app/services/bounty_enhancer_service.py new file mode 100644 index 000000000..ae788a902 --- /dev/null +++ b/backend/app/services/bounty_enhancer_service.py @@ -0,0 +1,549 @@ +"""AI Bounty Description Enhancer service (Issue #848). + +Analyzes vague bounty descriptions and generates improved versions with +clearer requirements, acceptance criteria, and examples using multiple LLM +providers (Claude, Codex, Gemini). + +Architecture: +- Each provider is called via its own HTTP client (httpx) +- Results are stored in-memory (enhancement records dict) +- The maintainer can approve or reject each enhancement +- On approval, the bounty description is updated in the service layer +""" + +import logging +import os +import uuid +from datetime import datetime, timezone +from typing import Optional + +import httpx + +from app.models.bounty_enhance import ( + EnhanceRequest, + EnhanceResponse, + EnhancementRecord, + EnhancementStatus, + EnhancementListResponse, + LLMEnhancementResult, + LLMProvider, +) + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# In-memory storage +# --------------------------------------------------------------------------- + +# Mapping: bounty_id -> list of EnhancementRecord +_enhancements: dict[str, list[EnhancementRecord]] = {} + +# --------------------------------------------------------------------------- +# Default enhancement prompt template +# --------------------------------------------------------------------------- + +DEFAULT_SYSTEM_PROMPT = """You are a SolFoundry bounty description expert. Your task is to improve a bounty description to make it clearer, more actionable, and more likely to attract quality contributors. + +Given a bounty title and description, produce an enhanced version that: +1. **Clear Title**: A concise, descriptive title that captures the core task +2. **Problem Statement**: What problem does this bounty solve? Why is it needed? +3. **Requirements**: Specific, actionable requirements written as bullet points +4. **Acceptance Criteria**: Clear, testable criteria that define "done" +5. **Technical Context**: Relevant tech stack, dependencies, and architectural notes +6. **Examples**: Concrete examples of expected input/output or behavior +7. **Out of Scope**: Explicitly list what is NOT part of this bounty + +Return the result as a JSON object with these fields: +- enhanced_title: string (max 200 chars) +- enhanced_description: string (markdown formatted, max 5000 chars) +- changes_summary: string (brief list of what was improved, max 500 chars) +- confidence_score: float (0.0 to 1.0, how confident you are in the quality of the enhancement) +""" + +# --------------------------------------------------------------------------- +# Provider-specific prompts (add context about the provider's strengths) +# --------------------------------------------------------------------------- + +PROVIDER_CONTEXT = { + LLMProvider.CLAUDE: ( + "You are Claude, an AI assistant. Focus on structured, thorough analysis " + "with clear sections and precise technical details." + ), + LLMProvider.CODEX: ( + "You are Codex, an AI coding assistant. Focus on practical implementation " + "details, code examples, and developer-friendly language." + ), + LLMProvider.GEMINI: ( + "You are Gemini, a versatile AI. Focus on comprehensive coverage, " + "bridging technical and non-technical requirements clearly." + ), +} + + +# --------------------------------------------------------------------------- +# Service functions +# --------------------------------------------------------------------------- + + +def _get_api_key(provider: LLMProvider) -> Optional[str]: + """Get the API key for a given LLM provider from environment variables. + + Args: + provider: The LLM provider to look up. + + Returns: + The API key string, or None if not configured. + """ + key_map = { + LLMProvider.CLAUDE: "ANTHROPIC_API_KEY", + LLMProvider.CODEX: "OPENAI_API_KEY", + LLMProvider.GEMINI: "GOOGLE_API_KEY", + } + env_var = key_map.get(provider) + if not env_var: + return None + return os.getenv(env_var) + + +def _call_llm_provider( + provider: LLMProvider, title: str, description: str, custom_prompt: Optional[str] = None +) -> LLMEnhancementResult: + """Call a single LLM provider to enhance a bounty description. + + Args: + provider: The LLM provider to use. + title: The current bounty title. + description: The current bounty description. + custom_prompt: Optional custom instructions. + + Returns: + An LLMEnhancementResult with the generated enhancement, or an error result. + """ + api_key = _get_api_key(provider) + if not api_key: + return LLMEnhancementResult( + provider=provider, + enhanced_title=title, + enhanced_description=description, + changes_summary="No enhancement generated", + confidence_score=0.0, + error=f"{provider.value.title()} API key not configured. Set the {provider.value.upper()}_API_KEY environment variable.", + ) + + system_prompt = PROVIDER_CONTEXT.get(provider, DEFAULT_SYSTEM_PROMPT) + if custom_prompt: + system_prompt += f"\n\nAdditional instructions from the maintainer:\n{custom_prompt}" + + user_prompt = f"""Please enhance this SolFoundry bounty description: + +Title: {title} + +Description: +{description} + +Generate an improved version following the instructions in the system prompt. Return ONLY valid JSON.""" + + try: + if provider == LLMProvider.CLAUDE: + return _call_claude(api_key, system_prompt, user_prompt, title, description) + elif provider == LLMProvider.CODEX: + return _call_codex(api_key, system_prompt, user_prompt, title, description) + elif provider == LLMProvider.GEMINI: + return _call_gemini(api_key, system_prompt, user_prompt, title, description) + else: + return LLMEnhancementResult( + provider=provider, + enhanced_title=title, + enhanced_description=description, + changes_summary="Unsupported provider", + confidence_score=0.0, + error=f"Unsupported provider: {provider}", + ) + except Exception as e: + logger.error("LLM call failed for %s: %s", provider.value, str(e)) + return LLMEnhancementResult( + provider=provider, + enhanced_title=title, + enhanced_description=description, + changes_summary="Enhancement failed", + confidence_score=0.0, + error=f"API call failed: {str(e)}", + ) + + +def _call_claude( + api_key: str, system_prompt: str, user_prompt: str, title: str, description: str +) -> LLMEnhancementResult: + """Call Claude (Anthropic API) to enhance a bounty description.""" + import json + + try: + with httpx.Client(timeout=60.0) as client: + resp = client.post( + "https://api.anthropic.com/v1/messages", + headers={ + "x-api-key": api_key, + "anthropic-version": "2023-06-01", + "content-type": "application/json", + }, + json={ + "model": "claude-sonnet-4-20250514", + "max_tokens": 4000, + "system": system_prompt, + "messages": [{"role": "user", "content": user_prompt}], + }, + ) + resp.raise_for_status() + data = resp.json() + content = data.get("content", [{}]) + text = "" + for block in content: + if block.get("type") == "text": + text = block.get("text", "") + break + return _parse_llm_response(text, LLMProvider.CLAUDE, title, description) + except httpx.HTTPStatusError as e: + logger.error("Claude API error: %s - %s", e.response.status_code, e.response.text) + return LLMEnhancementResult( + provider=LLMProvider.CLAUDE, + enhanced_title=title, + enhanced_description=description, + changes_summary="Claude API error", + confidence_score=0.0, + error=f"API error {e.response.status_code}: {e.response.text[:200]}", + ) + except Exception as e: + logger.error("Claude API call failed: %s", str(e)) + raise + + +def _call_codex( + api_key: str, system_prompt: str, user_prompt: str, title: str, description: str +) -> LLMEnhancementResult: + """Call Codex (OpenAI API) to enhance a bounty description.""" + import json + + try: + with httpx.Client(timeout=60.0) as client: + resp = client.post( + "https://api.openai.com/v1/chat/completions", + headers={ + "Authorization": f"Bearer {api_key}", + "content-type": "application/json", + }, + json={ + "model": "gpt-4o", + "max_tokens": 4000, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + }, + ) + resp.raise_for_status() + data = resp.json() + choice = data.get("choices", [{}])[0] + text = choice.get("message", {}).get("content", "") + return _parse_llm_response(text, LLMProvider.CODEX, title, description) + except httpx.HTTPStatusError as e: + logger.error("Codex API error: %s - %s", e.response.status_code, e.response.text) + return LLMEnhancementResult( + provider=LLMProvider.CODEX, + enhanced_title=title, + enhanced_description=description, + changes_summary="Codex API error", + confidence_score=0.0, + error=f"API error {e.response.status_code}: {e.response.text[:200]}", + ) + except Exception as e: + logger.error("Codex API call failed: %s", str(e)) + raise + + +def _call_gemini( + api_key: str, system_prompt: str, user_prompt: str, title: str, description: str +) -> LLMEnhancementResult: + """Call Gemini (Google AI API) to enhance a bounty description.""" + import json + + try: + with httpx.Client(timeout=60.0) as client: + resp = client.post( + f"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={api_key}", + headers={"content-type": "application/json"}, + json={ + "system_instruction": { + "parts": [{"text": system_prompt}] + }, + "contents": [ + { + "parts": [{"text": user_prompt}] + } + ], + "generationConfig": { + "maxOutputTokens": 4000, + }, + }, + ) + resp.raise_for_status() + data = resp.json() + candidates = data.get("candidates", []) + if candidates: + parts = candidates[0].get("content", {}).get("parts", []) + text = " ".join(p.get("text", "") for p in parts) + else: + text = "" + return _parse_llm_response(text, LLMProvider.GEMINI, title, description) + except httpx.HTTPStatusError as e: + logger.error("Gemini API error: %s - %s", e.response.status_code, e.response.text) + return LLMEnhancementResult( + provider=LLMProvider.GEMINI, + enhanced_title=title, + enhanced_description=description, + changes_summary="Gemini API error", + confidence_score=0.0, + error=f"API error {e.response.status_code}: {e.response.text[:200]}", + ) + except Exception as e: + logger.error("Gemini API call failed: %s", str(e)) + raise + + +def _parse_llm_response( + text: str, provider: LLMProvider, fallback_title: str, fallback_description: str +) -> LLMEnhancementResult: + """Parse the LLM response JSON into an LLMEnhancementResult. + + Args: + text: The raw text response from the LLM. + provider: The provider that generated the response. + fallback_title: Title to use if parsing fails. + fallback_description: Description to use if parsing fails. + + Returns: + A parsed LLMEnhancementResult. + """ + import json + import re + + # Try to extract JSON from the response (it may be wrapped in markdown code blocks) + json_match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL) + if json_match: + json_str = json_match.group(1) + else: + # Try to find JSON object directly + json_match = re.search(r"\{.*\"enhanced_title\".*\"enhanced_description\".*\}", text, re.DOTALL) + if json_match: + json_str = json_match.group(0) + else: + # Fallback: wrap the entire response as description + return LLMEnhancementResult( + provider=provider, + enhanced_title=fallback_title, + enhanced_description=text[:5000] if text else fallback_description, + changes_summary="Generated enhanced description", + confidence_score=0.5, + error=None, + ) + + try: + parsed = json.loads(json_str) + return LLMEnhancementResult( + provider=provider, + enhanced_title=parsed.get("enhanced_title", fallback_title)[:200], + enhanced_description=parsed.get("enhanced_description", fallback_description)[:5000], + changes_summary=parsed.get("changes_summary", "Generated enhanced description")[:500], + confidence_score=min(max(float(parsed.get("confidence_score", 0.5)), 0.0), 1.0), + error=None, + ) + except (json.JSONDecodeError, ValueError, TypeError) as e: + logger.warning("Failed to parse LLM response JSON: %s", str(e)) + return LLMEnhancementResult( + provider=provider, + enhanced_title=fallback_title, + enhanced_description=text[:5000] if text else fallback_description, + changes_summary="Generated enhanced description (parsing warning)", + confidence_score=0.5, + error=None, + ) + + +def _truncate_enhancement_description(text: str, max_length: int = 5000) -> str: + """Truncate enhancement description to fit the model limit. + + Args: + text: The text to truncate. + max_length: Maximum length allowed. + + Returns: + Truncated text. + """ + if len(text) <= max_length: + return text + return text[: max_length - 3] + "..." + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +async def enhance_bounty_description( + bounty_id: str, + title: str, + description: str, + request: EnhanceRequest, + created_by: str = "system", +) -> EnhanceResponse: + """Enhance a bounty description using multiple LLM providers. + + This is the main entry point for the AI Bounty Description Enhancer. + It calls each configured LLM provider, collects the results, and + stores them for later approval or rejection. + + Args: + bounty_id: The UUID of the bounty to enhance. + title: The current bounty title. + description: The current bounty description. + request: The enhancement request payload. + created_by: The user or agent requesting the enhancement. + + Returns: + An EnhanceResponse with the results from each provider. + """ + record_id = str(uuid.uuid4()) + now = datetime.now(timezone.utc) + + results: list[LLMEnhancementResult] = [] + for provider in request.providers: + result = _call_llm_provider(provider, title, description, request.custom_prompt) + results.append(result) + + succeeded = [r for r in results if r.error is None] + failed = [r for r in results if r.error is not None] + + record = EnhancementRecord( + id=record_id, + bounty_id=bounty_id, + original_title=title, + original_description=description, + results=results, + status=EnhancementStatus.PENDING, + created_by=created_by, + created_at=now, + updated_at=now, + ) + + if bounty_id not in _enhancements: + _enhancements[bounty_id] = [] + _enhancements[bounty_id].append(record) + + if succeeded and not failed: + message = f"Enhanced bounty description using {len(succeeded)} provider(s). Review and approve to apply." + elif succeeded and failed: + message = f"Enhanced using {len(succeeded)} provider(s). {len(failed)} provider(s) failed: {', '.join(r.error for r in failed if r.error)}" + else: + message = "All enhancement providers failed. Check API key configuration." + + return EnhanceResponse( + enhancement_id=record_id, + bounty_id=bounty_id, + results=results, + status=EnhancementStatus.PENDING, + created_at=now, + message=message, + ) + + +async def get_enhancements_for_bounty( + bounty_id: str, +) -> EnhancementListResponse: + """Get all enhancement records for a bounty. + + Args: + bounty_id: The UUID of the bounty. + + Returns: + An EnhancementListResponse with all enhancement records. + """ + records = _enhancements.get(bounty_id, []) + return EnhancementListResponse( + items=list(reversed(records)), # newest first + total=len(records), + bounty_id=bounty_id, + ) + + +async def approve_enhancement( + bounty_id: str, + enhancement_id: str, + provider: LLMProvider, +) -> Optional[EnhancementRecord]: + """Approve an enhancement result and update the bounty description. + + Args: + bounty_id: The UUID of the bounty. + enhancement_id: The ID of the enhancement record. + provider: The LLM provider whose result to approve. + + Returns: + The updated EnhancementRecord, or None if not found. + """ + records = _enhancements.get(bounty_id, []) + for record in records: + if record.id == enhancement_id: + # Find the selected provider's result + for result in record.results: + if result.provider == provider and result.error is None: + record.selected_result = result + record.status = EnhancementStatus.APPROVED + record.updated_at = datetime.now(timezone.utc) + + # Update the bounty description in the service layer + # (the actual bounty update is handled by the API layer) + return record + # Provider not found or had error + return None + return None + + +async def reject_enhancement( + bounty_id: str, + enhancement_id: str, +) -> Optional[EnhancementRecord]: + """Reject an enhancement request. + + Args: + bounty_id: The UUID of the bounty. + enhancement_id: The ID of the enhancement record. + + Returns: + The updated EnhancementRecord, or None if not found. + """ + records = _enhancements.get(bounty_id, []) + for record in records: + if record.id == enhancement_id: + record.status = EnhancementStatus.REJECTED + record.updated_at = datetime.now(timezone.utc) + return record + return None + + +async def get_enhancement( + bounty_id: str, + enhancement_id: str, +) -> Optional[EnhancementRecord]: + """Get a specific enhancement record. + + Args: + bounty_id: The UUID of the bounty. + enhancement_id: The ID of the enhancement record. + + Returns: + The EnhancementRecord, or None if not found. + """ + records = _enhancements.get(bounty_id, []) + for record in records: + if record.id == enhancement_id: + return record + return None \ No newline at end of file diff --git a/backend/tests/test_bounty_enhance.py b/backend/tests/test_bounty_enhance.py new file mode 100644 index 000000000..5075ccef3 --- /dev/null +++ b/backend/tests/test_bounty_enhance.py @@ -0,0 +1,437 @@ +"""Tests for the AI Bounty Description Enhancer API (Issue #848). + +Covers: +- POST /api/bounties/{bounty_id}/enhance — Trigger enhancement +- GET /api/bounties/{bounty_id}/enhancements — List enhancements +- GET /api/bounties/{bounty_id}/enhancements/{id} — Get specific enhancement +- POST /api/bounties/{bounty_id}/enhancements/{id}/approve — Approve enhancement +- POST /api/bounties/{bounty_id}/enhancements/{id}/reject — Reject enhancement +- Edge cases: missing API keys, empty description, non-existent bounty +""" + +import os + +os.environ.setdefault("DATABASE_URL", "sqlite+aiosqlite:///:memory:") +os.environ.setdefault("SECRET_KEY", "test-secret-key-for-ci") +os.environ.setdefault("AUTH_ENABLED", "false") + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.api.bounty_enhance import router as bounty_enhance_router +from app.models.bounty_enhance import LLMProvider, EnhancementStatus + + +# --------------------------------------------------------------------------- +# Test app & client +# --------------------------------------------------------------------------- + +_test_app = FastAPI() +_test_app.include_router(bounty_enhance_router, prefix="/api") + +# Include the bounties router for creating test bounties +from app.api.bounties import router as bounties_router +from app.api.auth import get_current_user +from app.models.user import UserResponse + +MOCK_USER = UserResponse( + id="test-user-id", + github_id="test-github-id", + username="testuser", + email="test@example.com", + avatar_url="http://example.com/avatar.png", + wallet_address="test-wallet-address", + wallet_verified=True, + created_at="2026-03-20T22:00:00Z", + updated_at="2026-03-20T22:00:00Z", +) + + +async def override_get_current_user(): + return MOCK_USER + + +_test_app.include_router(bounties_router, prefix="/api") +_test_app.dependency_overrides[get_current_user] = override_get_current_user + + +@pytest.fixture +def client(): + """Create a test client with a fresh app.""" + return TestClient(_test_app) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +VALID_BOUNTY = { + "title": "Fix Login Page", + "description": "The login page has a bug where users cannot log in with their Google account. " + "It shows a 500 error when clicking the Google OAuth button.", + "tier": 1, + "reward_amount": 100.0, + "category": "backend", + "required_skills": ["python", "fastapi", "oauth"], + "created_by": "test-user", +} + + +def _create_mock_bounty(client, **overrides) -> dict: + """Seed a mock bounty via the HTTP API for testing.""" + payload = {**VALID_BOUNTY, **overrides} + resp = client.post("/api/bounties", json=payload) + assert resp.status_code == 201, f"Create failed: {resp.text}" + return resp.json() + + +# --------------------------------------------------------------------------- +# Tests - POST /api/bounties/{bounty_id}/enhance +# --------------------------------------------------------------------------- + + +class TestEnhanceBounty: + """Tests for triggering AI description enhancement.""" + + def test_enhance_bounty_not_found(self, client): + """Should return 404 when the bounty does not exist.""" + resp = client.post( + "/api/bounties/non-existent-id/enhance", + json={"providers": ["claude"]}, + ) + assert resp.status_code == 404 + data = resp.json() + assert "not found" in data.get("detail", "").lower() + + def test_enhance_bounty_no_api_keys(self, client): + """Should return results with error for providers without API keys.""" + for key in ["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GOOGLE_API_KEY"]: + os.environ.pop(key, None) + + bounty = _create_mock_bounty(client) + + resp = client.post( + f"/api/bounties/{bounty['id']}/enhance", + json={"providers": ["claude"]}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["bounty_id"] == bounty["id"] + assert len(data["results"]) == 1 + assert data["results"][0]["error"] is not None + assert "API key not configured" in data["results"][0]["error"] + assert data["status"] == "pending" + + def test_enhance_bounty_multiple_providers_no_keys(self, client): + """Should handle multiple providers when none have API keys.""" + for key in ["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GOOGLE_API_KEY"]: + os.environ.pop(key, None) + + bounty = _create_mock_bounty(client) + + resp = client.post( + f"/api/bounties/{bounty['id']}/enhance", + json={"providers": ["claude", "codex", "gemini"]}, + ) + assert resp.status_code == 200 + data = resp.json() + assert len(data["results"]) == 3 + for result in data["results"]: + assert result["error"] is not None + assert "API key not configured" in result["error"] + + def test_enhance_bounty_empty_description(self, client): + """Should return 400 when the bounty has no description.""" + bounty = _create_mock_bounty(client, description="") + + resp = client.post( + f"/api/bounties/{bounty['id']}/enhance", + json={"providers": ["claude"]}, + ) + assert resp.status_code == 400 + data = resp.json() + assert "no description" in data.get("detail", "").lower() + + def test_enhance_bounty_with_custom_prompt(self, client): + """Should accept custom prompt in the request.""" + for key in ["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GOOGLE_API_KEY"]: + os.environ.pop(key, None) + + bounty = _create_mock_bounty(client) + + resp = client.post( + f"/api/bounties/{bounty['id']}/enhance", + json={ + "providers": ["claude"], + "custom_prompt": "Focus on adding security considerations", + }, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["bounty_id"] == bounty["id"] + + def test_enhance_bounty_invalid_provider(self, client): + """Should return 422 for invalid provider names.""" + bounty = _create_mock_bounty(client) + + resp = client.post( + f"/api/bounties/{bounty['id']}/enhance", + json={"providers": ["invalid_provider"]}, + ) + assert resp.status_code == 422 + + def test_enhance_bounty_default_providers(self, client): + """Should use all three providers when none specified.""" + for key in ["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GOOGLE_API_KEY"]: + os.environ.pop(key, None) + + bounty = _create_mock_bounty(client) + + resp = client.post( + f"/api/bounties/{bounty['id']}/enhance", + json={}, + ) + assert resp.status_code == 200 + data = resp.json() + assert len(data["results"]) == 3 + + +# --------------------------------------------------------------------------- +# Tests - GET /api/bounties/{bounty_id}/enhancements +# --------------------------------------------------------------------------- + + +class TestListEnhancements: + """Tests for listing enhancement records.""" + + def test_list_enhancements_empty(self, client): + """Should return empty list for a bounty with no enhancements.""" + bounty = _create_mock_bounty(client) + + resp = client.get(f"/api/bounties/{bounty['id']}/enhancements") + assert resp.status_code == 200 + data = resp.json() + assert data["items"] == [] + assert data["total"] == 0 + assert data["bounty_id"] == bounty["id"] + + def test_list_enhancements_not_found(self, client): + """Should return 404 for non-existent bounty.""" + resp = client.get("/api/bounties/non-existent-id/enhancements") + assert resp.status_code == 404 + + def test_list_enhancements_after_enhance(self, client): + """Should list enhancement records after triggering one.""" + for key in ["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GOOGLE_API_KEY"]: + os.environ.pop(key, None) + + bounty = _create_mock_bounty(client) + + # Trigger an enhancement + client.post( + f"/api/bounties/{bounty['id']}/enhance", + json={"providers": ["claude"]}, + ) + + # List enhancements + resp = client.get(f"/api/bounties/{bounty['id']}/enhancements") + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 1 + assert len(data["items"]) == 1 + assert data["items"][0]["bounty_id"] == bounty["id"] + + +# --------------------------------------------------------------------------- +# Tests - GET /api/bounties/{bounty_id}/enhancements/{id} +# --------------------------------------------------------------------------- + + +class TestGetEnhancement: + """Tests for getting a specific enhancement record.""" + + def test_get_enhancement_not_found(self, client): + """Should return 404 for non-existent enhancement.""" + resp = client.get( + "/api/bounties/non-existent-bounty/enhancements/non-existent-id" + ) + assert resp.status_code == 404 + + def test_get_enhancement_success(self, client): + """Should return the enhancement record.""" + for key in ["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GOOGLE_API_KEY"]: + os.environ.pop(key, None) + + bounty = _create_mock_bounty(client) + + # Trigger an enhancement + enhance_resp = client.post( + f"/api/bounties/{bounty['id']}/enhance", + json={"providers": ["claude"]}, + ) + enhance_data = enhance_resp.json() + enhancement_id = enhance_data["enhancement_id"] + + # Get the specific enhancement + resp = client.get( + f"/api/bounties/{bounty['id']}/enhancements/{enhancement_id}" + ) + assert resp.status_code == 200 + data = resp.json() + assert data["id"] == enhancement_id + assert data["bounty_id"] == bounty["id"] + assert data["original_title"] == "Fix Login Page" + assert "The login page has a bug" in data["original_description"] + + +# --------------------------------------------------------------------------- +# Tests - POST /api/bounties/{bounty_id}/enhancements/{id}/approve +# --------------------------------------------------------------------------- + + +class TestApproveEnhancement: + """Tests for approving an enhancement result.""" + + def test_approve_enhancement_not_found(self, client): + """Should return 404 for non-existent enhancement.""" + resp = client.post( + "/api/bounties/non-existent-bounty/enhancements/non-existent/approve", + json={"action": "approve"}, + ) + assert resp.status_code == 404 + + def test_approve_enhancement_with_no_successful_results(self, client): + """Should return 400 when all providers failed.""" + for key in ["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GOOGLE_API_KEY"]: + os.environ.pop(key, None) + + bounty = _create_mock_bounty(client) + + # Trigger enhancement (all providers will fail) + enhance_resp = client.post( + f"/api/bounties/{bounty['id']}/enhance", + json={"providers": ["claude"]}, + ) + enhance_data = enhance_resp.json() + + # Try to approve + resp = client.post( + f"/api/bounties/{bounty['id']}/enhancements/{enhance_data['enhancement_id']}/approve", + json={"action": "approve"}, + ) + assert resp.status_code == 400 + data = resp.json() + assert "No successful enhancement" in data.get("detail", "") + + def test_approve_enhancement_wrong_action(self, client): + """Should validate the action field.""" + for key in ["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GOOGLE_API_KEY"]: + os.environ.pop(key, None) + + bounty = _create_mock_bounty(client) + + # Trigger enhancement + enhance_resp = client.post( + f"/api/bounties/{bounty['id']}/enhance", + json={"providers": ["claude"]}, + ) + enhance_data = enhance_resp.json() + + # Try to approve with wrong action + resp = client.post( + f"/api/bounties/{bounty['id']}/enhancements/{enhance_data['enhancement_id']}/approve", + json={"action": "reject"}, + ) + assert resp.status_code == 400 + data = resp.json() + assert "reject" in data.get("detail", "").lower() + + +# --------------------------------------------------------------------------- +# Tests - POST /api/bounties/{bounty_id}/enhancements/{id}/reject +# --------------------------------------------------------------------------- + + +class TestRejectEnhancement: + """Tests for rejecting an enhancement.""" + + def test_reject_enhancement_not_found(self, client): + """Should return 404 for non-existent enhancement.""" + resp = client.post( + "/api/bounties/non-existent-bounty/enhancements/non-existent/reject", + ) + assert resp.status_code == 404 + + def test_reject_enhancement_success(self, client): + """Should mark enhancement as rejected.""" + for key in ["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GOOGLE_API_KEY"]: + os.environ.pop(key, None) + + bounty = _create_mock_bounty(client) + + # Trigger enhancement + enhance_resp = client.post( + f"/api/bounties/{bounty['id']}/enhance", + json={"providers": ["claude"]}, + ) + enhance_data = enhance_resp.json() + enhancement_id = enhance_data["enhancement_id"] + + # Reject the enhancement + resp = client.post( + f"/api/bounties/{bounty['id']}/enhancements/{enhancement_id}/reject", + ) + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "rejected" + assert data["id"] == enhancement_id + + # Verify it's still in the list + list_resp = client.get(f"/api/bounties/{bounty['id']}/enhancements") + list_data = list_resp.json() + assert list_data["total"] == 1 + + # Verify the record shows as rejected + get_resp = client.get( + f"/api/bounties/{bounty['id']}/enhancements/{enhancement_id}" + ) + assert get_resp.json()["status"] == "rejected" + + +# --------------------------------------------------------------------------- +# Tests - Service layer edge cases +# --------------------------------------------------------------------------- + + +class TestServiceEdgeCases: + """Tests for the service layer directly.""" + + def test_llm_provider_enum_values(self): + """Should have correct provider enum values.""" + assert LLMProvider.CLAUDE.value == "claude" + assert LLMProvider.CODEX.value == "codex" + assert LLMProvider.GEMINI.value == "gemini" + + def test_enhancement_status_enum_values(self): + """Should have correct status enum values.""" + assert EnhancementStatus.PENDING.value == "pending" + assert EnhancementStatus.APPROVED.value == "approved" + assert EnhancementStatus.REJECTED.value == "rejected" + + def test_call_llm_provider_no_api_key(self): + """Should return error result without calling API.""" + from app.services.bounty_enhancer_service import _call_llm_provider + + # Ensure no API key + os.environ.pop("ANTHROPIC_API_KEY", None) + + result = _call_llm_provider( + provider=LLMProvider.CLAUDE, + title="Test Title", + description="Test description", + ) + assert result.error is not None + assert "API key not configured" in result.error + assert result.provider == LLMProvider.CLAUDE + assert result.confidence_score == 0.0 \ No newline at end of file