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") && ( <> -
+
@@ -279,6 +285,16 @@ export default function AnswerCard({ Confidence: {confidencePercent}% + {isParaphrasing && ( + + ✨ AI enhancing... + + )} + {isParaphrased && !isParaphrasing && ( + + ✨ AI Enhanced + + )}
diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 593850c..62eb8a4 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -66,6 +66,10 @@ interface QAPair { confidence: number; mode?: string; comparison?: ComparisonData | null; + /** Whether this answer has been enhanced by AI paraphrase */ + isParaphrased?: boolean; + /** Whether AI paraphrase is currently loading */ + isParaphrasing?: boolean; } interface QueryResponse { @@ -152,6 +156,100 @@ export default function Home() { const chatEndRef = useRef(null); const chatHistoryRef = useRef(null); + // Queue of answer IDs pending AI paraphrase (triggered on tab switch) + const paraphraseQueueRef = useRef>(new Set()); + // Ref to access latest history without re-registering the visibility listener + const historyRef = useRef(history); + historyRef.current = history; + + /** + * Visibility change listener: when user switches away from the tab, + * trigger AI paraphrase for any pending answers. This uses the idle time + * while the user is on another tab to call the slower Gemini model. + */ + useEffect(() => { + const handleVisibilityChange = () => { + if (document.hidden && paraphraseQueueRef.current.size > 0) { + // User switched away — fire paraphrase for all pending answers + const pendingIds = Array.from(paraphraseQueueRef.current); + paraphraseQueueRef.current.clear(); + + for (const pairId of pendingIds) { + triggerParaphrase(pairId); + } + } + }; + + document.addEventListener("visibilitychange", handleVisibilityChange); + return () => document.removeEventListener("visibilitychange", handleVisibilityChange); + }, []); + + /** + * Trigger AI paraphrase for a specific QA pair. + * Called when user switches tabs (visibilitychange → hidden). + */ + async function triggerParaphrase(pairId: string) { + const pair = historyRef.current.find((p) => p.id === pairId); + if (!pair || pair.isParaphrased) return; + + // Mark as paraphrasing + setHistory((prev) => + prev.map((p) => (p.id === pairId ? { ...p, isParaphrasing: true } : p)) + ); + + try { + const sourcesContext = pair.sources.map((s) => s.snippet).filter(Boolean); + + const response = await fetch("/api/paraphrase", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + question: pair.question, + raw_answer: pair.answerText, + sources_context: sourcesContext.length > 0 ? sourcesContext : null, + }), + }); + + if (response.ok) { + const data = await response.json(); + if (data.status === "success" && data.paraphrased_answer) { + setHistory((prev) => + prev.map((p) => + p.id === pairId + ? { + ...p, + answerText: data.paraphrased_answer, + isParaphrased: true, + isParaphrasing: false, + } + : p + ) + ); + + // Toast notification when user returns + window.dispatchEvent( + new CustomEvent("app-toast", { + detail: { + message: "✨ AI paraphrase ready for your latest answer!", + type: "success", + }, + }) + ); + return; + } + } + + // If failed, just remove paraphrasing state + setHistory((prev) => + prev.map((p) => (p.id === pairId ? { ...p, isParaphrasing: false } : p)) + ); + } catch { + setHistory((prev) => + prev.map((p) => (p.id === pairId ? { ...p, isParaphrasing: false } : p)) + ); + } + } + useEffect(() => { const scrollToBottom = () => { if (chatHistoryRef.current) { @@ -249,10 +347,11 @@ export default function Home() { setError(null); try { + // Phase 1: Fast local-only query (no Gemini, no latency) const response = await fetch("/api/query", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ question: trimmed, mode: retrievalMode }), + body: JSON.stringify({ question: trimmed, mode: retrievalMode, use_ai: false }), }); if (!response.ok) { @@ -270,6 +369,8 @@ export default function Home() { confidence: data.confidence, mode: retrievalMode, comparison: data.comparison, + isParaphrased: false, + isParaphrasing: false, }; setHistory((prev) => { @@ -282,6 +383,9 @@ export default function Home() { // Auto-expand the newly added answer setExpandedAnswerId(newPair.id); + // Phase 2: Add to paraphrase queue — will be triggered when user switches tabs + paraphraseQueueRef.current.add(newPair.id); + setQuestion(""); } catch (err) { setError(err instanceof Error ? err.message : "An error occurred"); @@ -441,6 +545,8 @@ export default function Home() { onCollapsedChange={(collapsed) => { setExpandedAnswerId(collapsed ? null : pair.id); }} + isParaphrased={pair.isParaphrased} + isParaphrasing={pair.isParaphrasing} /> ))}