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
55 changes: 7 additions & 48 deletions app/(main)/evaluations/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useRouter, useParams } from "next/navigation";
import { apiFetch } from "@/app/lib/apiClient";
import { invalidateConfigCache } from "@/app/lib/utils";
import { useAuth } from "@/app/lib/context/AuthContext";
import { useApp } from "@/app/lib/context/AppContext";
import { usePromptImprovement } from "@/app/hooks/usePromptImprovement";
import type {
EvalJob,
EvalJobApiResponse,
Expand Down Expand Up @@ -51,7 +51,7 @@
DownloadIcon,
} from "@/app/components/icons";

export default function EvaluationReport() {

Check warning on line 54 in app/(main)/evaluations/[id]/page.tsx

View workflow job for this annotation

GitHub Actions / lint-and-build

Function 'EvaluationReport' has too many statements (35). Maximum allowed is 20
const router = useRouter();
const params = useParams();
const toast = useToast();
Expand All @@ -71,8 +71,12 @@
const [isConfigModalOpen, setIsConfigModalOpen] = useState(false);
const [exportFormat, setExportFormat] = useState<"row" | "grouped">("row");
const [isResyncing, setIsResyncing] = useState(false);
const [isImprovingPrompt, setIsImprovingPrompt] = useState(false);
const [showNoTracesModal, setShowNoTracesModal] = useState(false);
const { isImprovingPrompt, handleIteratePrompt } = usePromptImprovement({
jobId,
apiKey,
isAuthenticated,
});

const fetchJobDetails = useCallback(async () => {
if (!isAuthenticated || !jobId) return;
Expand Down Expand Up @@ -182,51 +186,6 @@
}
};

const handleIteratePrompt = async () => {
if (!isAuthenticated || !jobId) return;
setIsImprovingPrompt(true);
try {
const data = await apiFetch<{
success: boolean;
error?: string;
data?: {
config_id?: string;
id?: string;
version?: number;
} | null;
}>(`/api/evaluations/${jobId}/improve-prompt`, apiKey, {
method: "POST",
body: JSON.stringify({}),
});

if (!data.success) {
toast.error(data.error || "Failed to iterate on prompt");
return;
}

invalidateConfigCache();
toast.success("Prompt iteration created");

const newConfigId = data.data?.config_id ?? data.data?.id;
const newVersion = data.data?.version;
if (newConfigId) {
const versionParam =
typeof newVersion === "number" ? `&version=${newVersion}` : "";
router.push(
`/configurations/prompt-editor?config=${encodeURIComponent(
newConfigId,
)}${versionParam}&from=evaluations`,
);
}
} catch (err: unknown) {
toast.error(
`Failed to iterate on prompt: ${err instanceof Error ? err.message : "Unknown error"}`,
);
} finally {
setIsImprovingPrompt(false);
}
};

const handleResync = async () => {
if (!isAuthenticated || !jobId) return;

Expand Down Expand Up @@ -429,7 +388,7 @@
</div>

<div className="flex-1 overflow-auto p-6 bg-bg-secondary">
<div className="max-w-7xl mx-auto space-y-6">
<div className="mx-auto space-y-6">
{hasScore && isNewFormat ? (
<>
<MetricsOverview
Expand Down
47 changes: 43 additions & 4 deletions app/api/evaluations/[id]/improve-prompt/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,27 @@
import { NextRequest, NextResponse } from "next/server";
import { apiClient } from "@/app/lib/apiClient";
import { markJobPending } from "@/app/lib/store/promptImprovementStore";
import { readWebhookSecret } from "@/app/lib/webhookSecret";
import type { LLMJobImmediatePublic } from "@/app/lib/types/promptImprovement";

function resolveCallbackUrl(request: NextRequest): string {
const secretResult = readWebhookSecret();
if (!secretResult.ok || !secretResult.secret) {
throw new Error(
secretResult.reason ?? "prompt_improvement_webhook_secret_missing",
);
}
const configured =
process.env.NEXT_PUBLIC_APP_URL ?? process.env.APP_URL ?? null;
const base = configured
? configured.replace(/\/$/, "")
: `${request.headers.get("x-forwarded-proto") ?? "https"}://${
request.headers.get("x-forwarded-host") ??
request.headers.get("host") ??
request.nextUrl.host
}`;
Comment thread
Ayush8923 marked this conversation as resolved.
return `${base}/api/webhooks/prompt-improvement?secret_value=${encodeURIComponent(secretResult.secret)}`;
}

export async function POST(
request: NextRequest,
Expand All @@ -8,22 +30,39 @@ export async function POST(
try {
const { id } = await params;

let body: unknown = undefined;
let userBody: Record<string, unknown> = {};
try {
body = await request.json();
const parsed = await request.json();
if (parsed && typeof parsed === "object") {
userBody = parsed as Record<string, unknown>;
}
} catch {
body = {};
userBody = {};
}

const payload = {
...userBody,
callback_url: userBody.callback_url ?? resolveCallbackUrl(request),
};

const { status, data } = await apiClient(
request,
`/api/v1/evaluations/${id}/improve-prompt`,
{
method: "POST",
body: JSON.stringify(body ?? {}),
body: JSON.stringify(payload),
},
);

if (status === 202 && data && typeof data === "object") {
const envelope = data as {
success?: boolean;
data?: LLMJobImmediatePublic;
};
const jobId = envelope.data?.job_id;
if (jobId) markJobPending(jobId);
}

return NextResponse.json(data ?? { error: "Backend error" }, { status });
} catch (error: unknown) {
console.error("improve-prompt proxy error:", error);
Expand Down
66 changes: 66 additions & 0 deletions app/api/webhooks/prompt-improvement/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from "next/server";
import {
getJobSnapshot,
saveJobSnapshot,
} from "@/app/lib/store/promptImprovementStore";
import { readWebhookSecret } from "@/app/lib/webhookSecret";
import type { PromptImprovementJobPublic } from "@/app/lib/types/promptImprovement";

function isAuthorized(request: NextRequest): boolean {
const secretResult = readWebhookSecret();
if (!secretResult.ok || !secretResult.secret) return false;
const provided = request.nextUrl.searchParams.get("secret_value");
return provided === secretResult.secret;
}

export async function POST(request: NextRequest) {
try {
if (!isAuthorized(request)) {
return NextResponse.json(
{ success: false, error: "unauthorized" },
{ status: 401 },
);
}
const body = (await request.json()) as {
success?: boolean;
data?: PromptImprovementJobPublic;
error?: string | null;
};
if (!body.data || !body.data.job_id) {
return NextResponse.json(
{ success: false, error: "invalid_payload" },
{ status: 400 },
);
}
saveJobSnapshot(body.data);
return NextResponse.json({ success: true, data: { received: true } });
} catch (error) {
console.error("prompt-improvement webhook error:", error);
return NextResponse.json(
{
success: false,
error:
error instanceof Error ? error.message : "webhook_processing_failed",
},
{ status: 500 },
);
}
}

export async function GET(request: NextRequest) {
const jobId = request.nextUrl.searchParams.get("job_id");
if (!jobId) {
return NextResponse.json(
{ success: false, error: "job_id_required" },
{ status: 400 },
);
}
const snapshot = getJobSnapshot(jobId);
if (!snapshot) {
return NextResponse.json(
{ success: false, error: "job_not_found", data: null },
{ status: 404 },
);
}
return NextResponse.json({ success: true, data: snapshot });
}
89 changes: 89 additions & 0 deletions app/api/webhooks/prompt-improvement/stream/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { NextRequest } from "next/server";
import {
getJobSnapshot,
subscribeJobSnapshot,
} from "@/app/lib/store/promptImprovementStore";
import type { PromptImprovementJobSnapshot } from "@/app/lib/types/promptImprovement";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

export async function GET(request: NextRequest) {
const jobId = request.nextUrl.searchParams.get("job_id");
if (!jobId) {
return new Response("job_id_required", { status: 400 });
}

const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
const send = (event: string, data: unknown) => {
try {
controller.enqueue(
encoder.encode(
`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`,
),
);
} catch {
// stream closed
}
};

const finish = () => {
clearInterval(heartbeat);
unsubscribe();
try {
controller.close();
} catch {
// already closed
}
};

const push = (snap: PromptImprovementJobSnapshot) => {
send("snapshot", snap);
if (snap.status === "SUCCESS" || snap.status === "FAILED") {
finish();
}
};

// Prime with current state (or a synthetic PENDING when store hasn't
// seen it yet — keeps the client from thinking the stream is stalled).
const current = getJobSnapshot(jobId);
if (current) {
push(current);
} else {
send("snapshot", {
job_id: jobId,
status: "PENDING",
config_version: null,
error_message: null,
updated_at: new Date().toISOString(),
});
}

const unsubscribe = subscribeJobSnapshot(jobId, push);

// every 20s so intermediaries (load balancers, browsers) don't
// consider the connection idle.
const heartbeat = setInterval(() => {
try {
controller.enqueue(encoder.encode(`: ping\n\n`));
} catch {
finish();
}
}, 20000);
Comment thread
Ayush8923 marked this conversation as resolved.

const abort = () => finish();
request.signal.addEventListener("abort", abort);
},
});

return new Response(stream, {
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
},
});
}
Loading
Loading