Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 84 additions & 6 deletions backend/app/api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand Down
40 changes: 40 additions & 0 deletions frontend/app/api/paraphrase/route.ts
Original file line number Diff line number Diff line change
@@ -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 }
);
}
}
20 changes: 18 additions & 2 deletions frontend/app/components/AnswerCard.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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;
}

/**
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -271,14 +277,24 @@ export default function AnswerCard({

{(!isCompareMode || activeTab === "answer") && (
<>
<div className="flex items-center gap-2 mb-3">
<div className="flex items-center gap-2 mb-3 flex-wrap">
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-green-50 dark:bg-green-900/30 text-green-600 dark:text-green-400 flex-shrink-0">
<Sparkles size={14} />
</span>
<span className={`text-xs font-semibold px-2.5 py-1 rounded-full flex items-center gap-1.5 ${confidenceClasses}`}>
<ConfidenceIcon confidence={confidence} size={12} />
Confidence: {confidencePercent}%
</span>
{isParaphrasing && (
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full bg-purple-500/10 text-purple-600 dark:text-purple-400 flex items-center gap-1 animate-pulse">
✨ AI enhancing...
</span>
)}
{isParaphrased && !isParaphrasing && (
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full bg-purple-500/10 text-purple-600 dark:text-purple-400 flex items-center gap-1">
✨ AI Enhanced
</span>
)}
</div>

<div className="mb-4">
Expand Down
108 changes: 107 additions & 1 deletion frontend/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -152,6 +156,100 @@ export default function Home() {
const chatEndRef = useRef<HTMLDivElement>(null);
const chatHistoryRef = useRef<HTMLDivElement>(null);

// Queue of answer IDs pending AI paraphrase (triggered on tab switch)
const paraphraseQueueRef = useRef<Set<string>>(new Set());
// Ref to access latest history without re-registering the visibility listener
const historyRef = useRef<QAPair[]>(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) {
Expand Down Expand Up @@ -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) {
Expand All @@ -270,6 +369,8 @@ export default function Home() {
confidence: data.confidence,
mode: retrievalMode,
comparison: data.comparison,
isParaphrased: false,
isParaphrasing: false,
};

setHistory((prev) => {
Expand All @@ -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");
Expand Down Expand Up @@ -441,6 +545,8 @@ export default function Home() {
onCollapsedChange={(collapsed) => {
setExpandedAnswerId(collapsed ? null : pair.id);
}}
isParaphrased={pair.isParaphrased}
isParaphrasing={pair.isParaphrasing}
/>
))}

Expand Down
Loading