diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py index ab9d489..83d0cb3 100644 --- a/backend/app/api/routes.py +++ b/backend/app/api/routes.py @@ -30,6 +30,17 @@ class QueryRequest(BaseModel): """Request body for the query endpoint.""" question: str = Field(min_length=1, max_length=1000) mode: str = Field(default="bm25") # "bm25", "indobert", or "compare" + use_ai: bool = Field(default=True) # If False, skip Gemini and use local-only + + +class ParaphraseRequest(BaseModel): + """Request body for the AI paraphrase endpoint.""" + question: str = Field(min_length=1, max_length=1000) + raw_answer: str = Field(min_length=1, max_length=10000) + sources_context: Optional[list[str]] = Field( + default=None, + description="Code snippets from sources for grounding the paraphrase" + ) class SourceItem(BaseModel): @@ -51,6 +62,13 @@ class QueryResponse(BaseModel): comparison: Optional[dict] = None +class ParaphraseResponse(BaseModel): + """Response body for the AI paraphrase endpoint.""" + paraphrased_answer: str + status: str = "success" + error: Optional[str] = None + + class IngestRequest(BaseModel): """Request body for the ingestion endpoint.""" github_url: str = Field(min_length=1) @@ -170,13 +188,14 @@ async def query_endpoint( indobert_results = [] comparison_data = None - # Load Gemini if available + # Load Gemini if available and requested gemini = None - try: - from app.services.gemini_client import GeminiClient - gemini = GeminiClient() - except (ValueError, ImportError): - pass + if request.use_ai: + try: + from app.services.gemini_client import GeminiClient + gemini = GeminiClient() + except (ValueError, ImportError): + pass if mode == "compare": # Run both retrieval modes @@ -284,6 +303,65 @@ async def query_endpoint( ) +@router.post("/api/paraphrase", response_model=ParaphraseResponse) +async def paraphrase_endpoint( + request: ParaphraseRequest, + api_key: str = Depends(verify_api_key), +) -> ParaphraseResponse: + """Paraphrase a raw answer using Gemini AI. + + This endpoint is designed to be called asynchronously (e.g., when the user + switches tabs) to enhance a local-algorithm answer with AI paraphrasing. + The local answer is returned immediately, and this endpoint is called in + the background to provide a more polished, AI-enhanced version. + """ + try: + from app.services.gemini_client import GeminiClient + gemini = GeminiClient() + except (ValueError, ImportError): + return ParaphraseResponse( + paraphrased_answer=request.raw_answer, + status="skipped", + error="Gemini API key not configured. AI paraphrase unavailable.", + ) + + # Build prompt for paraphrasing + sources_text = "" + if request.sources_context: + sources_text = "\n\n".join( + f"[Source {i+1}]\n{snippet[:500]}" + for i, snippet in enumerate(request.sources_context) + ) + + paraphrase_prompt = ( + "You are a code assistant. The following is a raw answer generated from " + "source code retrieval. Rewrite it in a clearer, more structured, and " + "professional manner while keeping it accurate and grounded in the sources. " + "Keep all [Source N] references intact. Make it easier to read with proper " + "formatting (bold for key terms, code blocks for code). Keep your answer " + "concise.\n\n" + f"Original Question: {request.question}\n\n" + f"Raw Answer:\n{request.raw_answer}\n\n" + ) + if sources_text: + paraphrase_prompt += f"Available Sources:\n{sources_text}\n\n" + + paraphrase_prompt += "Paraphrased Answer:" + + try: + paraphrased = await gemini.generate(paraphrase_prompt) + return ParaphraseResponse( + paraphrased_answer=paraphrased, + status="success", + ) + except Exception as e: + return ParaphraseResponse( + paraphrased_answer=request.raw_answer, + status="error", + error=f"AI paraphrase failed: {str(e)}", + ) + + @router.get("/api/status", response_model=StatusResponse) async def status_endpoint() -> StatusResponse: """Check if a repository has been indexed and return stats.""" diff --git a/frontend/app/api/paraphrase/route.ts b/frontend/app/api/paraphrase/route.ts new file mode 100644 index 0000000..4eae2a3 --- /dev/null +++ b/frontend/app/api/paraphrase/route.ts @@ -0,0 +1,40 @@ +import { NextRequest, NextResponse } from "next/server"; + +/** + * Next.js API route that proxies paraphrase requests to the FastAPI backend. + * This endpoint is called when the user switches tabs, triggering the + * Gemini AI paraphrase in the background while the user is away. + */ + +const BACKEND_URL = process.env.BACKEND_URL || "http://localhost:8000"; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + + const backendResponse = await fetch(`${BACKEND_URL}/api/paraphrase`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-API-Key": request.headers.get("X-API-Key") || "default-key", + }, + body: JSON.stringify(body), + }); + + if (!backendResponse.ok) { + const errorData = await backendResponse.json().catch(() => null); + return NextResponse.json( + { detail: errorData?.detail || `Backend error: ${backendResponse.status}` }, + { status: backendResponse.status } + ); + } + + const data = await backendResponse.json(); + return NextResponse.json(data); + } catch (error) { + return NextResponse.json( + { detail: "Backend service is unavailable for AI paraphrase." }, + { status: 503 } + ); + } +} diff --git a/frontend/app/components/AnswerCard.tsx b/frontend/app/components/AnswerCard.tsx index 4a07e0e..def0e16 100644 --- a/frontend/app/components/AnswerCard.tsx +++ b/frontend/app/components/AnswerCard.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect } from "react"; +import React, { useState } from "react"; import SourceReference, { SourceReferenceData } from "./SourceReference"; import RetrievalStatistics from "./RetrievalStatistics"; import { @@ -27,6 +27,10 @@ export interface AnswerCardProps { } | null; isCollapsed?: boolean; onCollapsedChange?: (collapsed: boolean) => void; + /** Whether AI paraphrase has been applied */ + isParaphrased?: boolean; + /** Whether AI paraphrase is currently loading in background */ + isParaphrasing?: boolean; } /** @@ -191,6 +195,8 @@ export default function AnswerCard({ comparison = null, isCollapsed: controlledCollapsed, onCollapsedChange, + isParaphrased = false, + isParaphrasing = false, }: AnswerCardProps) { const [internalCollapsed, setInternalCollapsed] = useState(false); const [activeTab, setActiveTab] = useState<"answer" | "retrieval" | "evaluation">("answer"); @@ -271,7 +277,7 @@ export default function AnswerCard({ {(!isCompareMode || activeTab === "answer") && ( <> -