From 552ab591e6f5e056834fea2af6e8981d356824f9 Mon Sep 17 00:00:00 2001 From: jakedahn Date: Fri, 11 Apr 2025 12:59:32 -0600 Subject: [PATCH 01/12] getting started on user feedback --- toolshed/env.ts | 1 + toolshed/routes/ai/llm/generateText.ts | 8 +++ toolshed/routes/ai/llm/instrumentation.ts | 8 ++- toolshed/routes/ai/llm/llm.handlers.ts | 68 +++++++++++++++++++++-- toolshed/routes/ai/llm/llm.index.ts | 3 +- toolshed/routes/ai/llm/llm.routes.ts | 61 ++++++++++++++++++++ 6 files changed, 141 insertions(+), 8 deletions(-) diff --git a/toolshed/env.ts b/toolshed/env.ts index f3c49a9856..71d7623834 100644 --- a/toolshed/env.ts +++ b/toolshed/env.ts @@ -44,6 +44,7 @@ const EnvSchema = z.object({ // LLM Observability Tool CTTS_AI_LLM_PHOENIX_PROJECT: z.string().default(""), CTTS_AI_LLM_PHOENIX_URL: z.string().default(""), + CTTS_AI_LLM_PHOENIX_API_URL: z.string().default(""), CTTS_AI_LLM_PHOENIX_API_KEY: z.string().default(""), // =========================================================================== diff --git a/toolshed/routes/ai/llm/generateText.ts b/toolshed/routes/ai/llm/generateText.ts index ec6b90f106..90cd2cf8de 100644 --- a/toolshed/routes/ai/llm/generateText.ts +++ b/toolshed/routes/ai/llm/generateText.ts @@ -1,4 +1,5 @@ import { streamText } from "ai"; +import { trace } from "@opentelemetry/api"; import { findModel, TASK_MODELS } from "./models.ts"; @@ -36,6 +37,7 @@ export interface GenerateTextResult { message: { role: "user" | "assistant"; content: string }; messages: { role: "user" | "assistant"; content: string }[]; stream?: ReadableStream; + spanId?: string; } // Configure the model parameters for JSON mode based on provider @@ -202,8 +204,12 @@ export async function generateText( metadata: params.metadata, }; + // This is where the LLM API call is made const llmStream = await streamText(streamParams as any); + // Get the active span ID from OpenTelemetry + const spanId = trace.getActiveSpan()?.spanContext().spanId; + // If not streaming, handle regular response if (!params.stream) { let result = ""; @@ -237,6 +243,7 @@ export async function generateText( return { message: messages[messages.length - 1], messages: [...messages], + spanId, }; } @@ -300,5 +307,6 @@ export async function generateText( message: messages[messages.length - 1], messages: [...messages], stream, + spanId, }; } diff --git a/toolshed/routes/ai/llm/instrumentation.ts b/toolshed/routes/ai/llm/instrumentation.ts index b4e7866118..61d98c8887 100644 --- a/toolshed/routes/ai/llm/instrumentation.ts +++ b/toolshed/routes/ai/llm/instrumentation.ts @@ -27,7 +27,13 @@ export function register() { }, }), spanFilter: (span) => { - return isOpenInferenceSpan(span); + // console.log("SPAN", span); + const includeSpanCriteria = [ + isOpenInferenceSpan(span), + span.attributes["http.route"] == "/api/ai/llm", // Include the root span, which is in the hono app + span.instrumentationLibrary.name.includes("@vercel/otel"), // Include the actual LLM API fetch span + ]; + return includeSpanCriteria.some((c) => c); }, }), ], diff --git a/toolshed/routes/ai/llm/llm.handlers.ts b/toolshed/routes/ai/llm/llm.handlers.ts index b55e7df907..51afe63b08 100644 --- a/toolshed/routes/ai/llm/llm.handlers.ts +++ b/toolshed/routes/ai/llm/llm.handlers.ts @@ -1,11 +1,16 @@ import * as HttpStatusCodes from "stoker/http-status-codes"; import type { AppRouteHandler } from "@/lib/types.ts"; -import type { GenerateTextRoute, GetModelsRoute } from "./llm.routes.ts"; +import type { + FeedbackRoute, + GenerateTextRoute, + GetModelsRoute, +} from "./llm.routes.ts"; import { ALIAS_NAMES, ModelList, MODELS, TASK_MODELS } from "./models.ts"; import * as cache from "./cache.ts"; import type { Context } from "@hono/hono"; import { generateText as generateTextCore } from "./generateText.ts"; import { findModel } from "./models.ts"; +import env from "@/env.ts"; const withoutMetadata = (obj: any) => { const { metadata, ...rest } = obj; @@ -113,10 +118,10 @@ export const generateText: AppRouteHandler = async (c) => { JSON.stringify(withoutMetadata(payload)), ); const cachedResult = await cache.loadItem(cacheKey); - if (cachedResult) { - const lastMessage = cachedResult.messages[cachedResult.messages.length - 1]; - return c.json(lastMessage); - } + // if (cachedResult) { + // const lastMessage = cachedResult.messages[cachedResult.messages.length - 1]; + // return c.json(lastMessage); + // } const persistCache = async ( messages: { role: string; content: string }[], @@ -160,7 +165,11 @@ export const generateText: AppRouteHandler = async (c) => { // If response is not streaming, save to cache and return the message if (!payload.stream) { await persistCache(result.messages); - return c.json(result.message); + const response = c.json(result.message); + if (result.spanId) { + response.headers.set("x-ct-llm-trace-id", result.spanId); + } + return response; } return new Response(result.stream, { @@ -169,6 +178,7 @@ export const generateText: AppRouteHandler = async (c) => { "Transfer-Encoding": "chunked", "Cache-Control": "no-cache", Connection: "keep-alive", + ...(result.spanId ? { "x-ct-llm-trace-id": result.spanId } : {}), }, }); } catch (error) { @@ -177,3 +187,49 @@ export const generateText: AppRouteHandler = async (c) => { return c.json({ error: message }, HttpStatusCodes.BAD_REQUEST); } }; + +/** + * Handler for POST /feedback endpoint + * Submits user feedback on an LLM response to Phoenix + */ +export const submitFeedback: AppRouteHandler = async (c) => { + const payload = await c.req.json(); + + try { + const phoenixPayload = { + data: [ + { + span_id: payload.span_id, + name: payload.name || "user feedback", + annotator_kind: payload.annotator_kind || "HUMAN", + result: payload.result, + metadata: payload.metadata || {}, + }, + ], + }; + + const response = await fetch( + `${env.CTTS_AI_LLM_PHOENIX_API_URL}/span_annotations?sync=false`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "Accept": "application/json", + "api_key": env.CTTS_AI_LLM_PHOENIX_API_KEY, + }, + body: JSON.stringify(phoenixPayload), + }, + ); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Phoenix API error: ${response.status} ${errorText}`); + } + + return c.json({ success: true }); + } catch (error) { + console.error("Error submitting feedback:", error); + const message = error instanceof Error ? error.message : "Unknown error"; + return c.json({ error: message }, HttpStatusCodes.BAD_REQUEST); + } +}; diff --git a/toolshed/routes/ai/llm/llm.index.ts b/toolshed/routes/ai/llm/llm.index.ts index dddcf21baf..43579f367c 100644 --- a/toolshed/routes/ai/llm/llm.index.ts +++ b/toolshed/routes/ai/llm/llm.index.ts @@ -11,7 +11,8 @@ if (env.CTTS_AI_LLM_PHOENIX_PROJECT) { const router = createRouter() .openapi(routes.getModels, handlers.getModels) - .openapi(routes.generateText, handlers.generateText); + .openapi(routes.generateText, handlers.generateText) + .openapi(routes.feedback, handlers.submitFeedback); router.use( "/api/ai/llm/*", diff --git a/toolshed/routes/ai/llm/llm.routes.ts b/toolshed/routes/ai/llm/llm.routes.ts index 2ff1b2345a..7c1d556c1d 100644 --- a/toolshed/routes/ai/llm/llm.routes.ts +++ b/toolshed/routes/ai/llm/llm.routes.ts @@ -72,6 +72,18 @@ export type GetModelsRouteQueryParams = z.infer< typeof GetModelsRouteQueryParams >; +export const FeedbackSchema = z.object({ + span_id: z.string(), + name: z.string().default("user feedback"), + annotator_kind: z.enum(["HUMAN", "LLM"]).default("HUMAN"), + result: z.object({ + label: z.string().optional(), + score: z.number().optional(), + explanation: z.string().optional(), + }), + metadata: z.record(z.unknown()).optional(), +}); + // Route definitions export const getModels = createRoute({ path: "/api/ai/llm/models", @@ -157,5 +169,54 @@ export const generateText = createRoute({ }, }); +export const feedback = createRoute({ + path: "/api/ai/llm/feedback", + method: "post", + tags, + request: { + body: { + content: { + "application/json": { + schema: FeedbackSchema.openapi({ + example: { + span_id: "67f6740bbe1ddc3f", + name: "correctness", + annotator_kind: "HUMAN", + result: { + label: "correct", + score: 1, + explanation: "The response answered the question I asked", + }, + }, + }), + }, + }, + }, + }, + responses: { + [HttpStatusCodes.OK]: jsonContent( + z.object({ + success: z.boolean(), + }).openapi({ + example: { + success: true, + }, + }), + "Feedback submitted successfully", + ), + [HttpStatusCodes.BAD_REQUEST]: { + content: { + "application/json": { + schema: z.object({ + error: z.string(), + }), + }, + }, + description: "Invalid request parameters", + }, + }, +}); + export type GetModelsRoute = typeof getModels; export type GenerateTextRoute = typeof generateText; +export type FeedbackRoute = typeof feedback; From d451a2cbcda48157a83e798b9884014ef904a852 Mon Sep 17 00:00:00 2001 From: jakedahn Date: Mon, 14 Apr 2025 12:15:53 -0600 Subject: [PATCH 02/12] fixing feedback endpoint schema --- toolshed/routes/ai/llm/llm.handlers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/toolshed/routes/ai/llm/llm.handlers.ts b/toolshed/routes/ai/llm/llm.handlers.ts index 51afe63b08..3718be8510 100644 --- a/toolshed/routes/ai/llm/llm.handlers.ts +++ b/toolshed/routes/ai/llm/llm.handlers.ts @@ -226,7 +226,7 @@ export const submitFeedback: AppRouteHandler = async (c) => { throw new Error(`Phoenix API error: ${response.status} ${errorText}`); } - return c.json({ success: true }); + return c.json({ success: true }, HttpStatusCodes.OK); } catch (error) { console.error("Error submitting feedback:", error); const message = error instanceof Error ? error.message : "Unknown error"; From fcb2e22db7b64d29c1f1a1881cca785055cb7b6e Mon Sep 17 00:00:00 2001 From: jakedahn Date: Mon, 14 Apr 2025 12:50:33 -0600 Subject: [PATCH 03/12] fixing up the phoenix annotation request --- toolshed/routes/ai/llm/llm.handlers.ts | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/toolshed/routes/ai/llm/llm.handlers.ts b/toolshed/routes/ai/llm/llm.handlers.ts index 3718be8510..4f0a4e119c 100644 --- a/toolshed/routes/ai/llm/llm.handlers.ts +++ b/toolshed/routes/ai/llm/llm.handlers.ts @@ -208,17 +208,19 @@ export const submitFeedback: AppRouteHandler = async (c) => { ], }; + const phoenixAnnotationPayload = { + method: "POST", + headers: { + "Content-Type": "application/json", + "Accept": "application/json", + "Authorization": `Bearer ${env.CTTS_AI_LLM_PHOENIX_API_KEY}`, + }, + body: JSON.stringify(phoenixPayload), + }; + const response = await fetch( `${env.CTTS_AI_LLM_PHOENIX_API_URL}/span_annotations?sync=false`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - "Accept": "application/json", - "api_key": env.CTTS_AI_LLM_PHOENIX_API_KEY, - }, - body: JSON.stringify(phoenixPayload), - }, + phoenixAnnotationPayload, ); if (!response.ok) { From e15a1ba77f470362e7a3eff3f2c829d74982e8d5 Mon Sep 17 00:00:00 2001 From: jakedahn Date: Mon, 14 Apr 2025 14:38:45 -0600 Subject: [PATCH 04/12] wiring up a feedback form --- jumble/src/components/ActionBar.tsx | 4 +- jumble/src/components/FeedbackActions.tsx | 139 +++++++++++++++++++ jumble/src/components/FeedbackDialog.tsx | 139 +++++++++++++++++++ jumble/src/contexts/ActionManagerContext.tsx | 1 + jumble/src/services/feedback.ts | 75 ++++++++++ jumble/src/views/Shell.tsx | 2 + jumble/vite.config.ts | 1 + 7 files changed, 359 insertions(+), 2 deletions(-) create mode 100644 jumble/src/components/FeedbackActions.tsx create mode 100644 jumble/src/components/FeedbackDialog.tsx create mode 100644 jumble/src/services/feedback.ts diff --git a/jumble/src/components/ActionBar.tsx b/jumble/src/components/ActionBar.tsx index 3dc870bc3b..b8efb56e82 100644 --- a/jumble/src/components/ActionBar.tsx +++ b/jumble/src/components/ActionBar.tsx @@ -39,7 +39,7 @@ export function ActionBar() { @@ -52,7 +52,7 @@ export function ActionBar() { return ( diff --git a/jumble/src/components/FeedbackActions.tsx b/jumble/src/components/FeedbackActions.tsx new file mode 100644 index 0000000000..79905c96ba --- /dev/null +++ b/jumble/src/components/FeedbackActions.tsx @@ -0,0 +1,139 @@ +import { useEffect, useState } from "react"; +import { Action, useActionManager } from "@/contexts/ActionManagerContext.tsx"; +import { FeedbackDialog } from "@/components/FeedbackDialog.tsx"; +import { submitFeedback } from "@/services/feedback.ts"; +import { MdSend, MdThumbDownOffAlt, MdThumbUpOffAlt } from "react-icons/md"; + +// For demo purposes - in a real app, this would come from the current response or context +const CURRENT_SPAN_ID = "48fc49c695cdc4f3"; + +export function FeedbackActions() { + // States for the feedback dialog + const [isFeedbackDialogOpen, setIsFeedbackDialogOpen] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [initialScore, setInitialScore] = useState(1); + const [showFeedbackButtons, setShowFeedbackButtons] = useState(false); + const { registerAction } = useActionManager(); + + // Handler functions for feedback actions + const toggleFeedbackButtons = () => { + setShowFeedbackButtons((prev) => !prev); + }; + + const handleOpenFeedback = (score: number) => { + console.log( + `Opening ${score === 1 ? "positive" : "negative"} feedback dialog`, + ); + setInitialScore(score); + setIsFeedbackDialogOpen(true); + // Hide the feedback buttons when opening the modal + setShowFeedbackButtons(false); + }; + + const handleCloseFeedback = () => { + setIsFeedbackDialogOpen(false); + }; + + const handleSubmitFeedback = async ( + data: { score: number; explanation: string; userInfo?: any }, + ) => { + console.log("Submitting feedback:", data); + setIsSubmitting(true); + + try { + await submitFeedback( + { + score: data.score, + explanation: data.explanation, + spanId: CURRENT_SPAN_ID, + }, + data.userInfo, + ); + + setIsFeedbackDialogOpen(false); + alert("Feedback submitted successfully! Thank you for your input."); + } catch (error) { + alert( + `Error submitting feedback: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } finally { + setIsSubmitting(false); + } + }; + + // Register all actions + useEffect(() => { + // Create actions array for batch registration + const actions: Action[] = []; + + // Register the main feedback toggle button + const toggleAction: Action = { + id: "feedback-toggle", + label: "Feedback", + icon: , + onClick: toggleFeedbackButtons, + priority: 30, + className: showFeedbackButtons ? "bg-blue-100" : "", + }; + + actions.push(toggleAction); + + // Register all actions and collect unregister functions + const unregisterFunctions = actions.map((action) => registerAction(action)); + + // Return combined cleanup function + return () => { + unregisterFunctions.forEach((unregister) => unregister()); + }; + }, [showFeedbackButtons, registerAction]); + + // Render feedback dialog and the popup buttons + return ( + <> + {/* Feedback dialog for submissions */} + + + {/* Popup buttons that appear when feedback button is clicked */} + {showFeedbackButtons && ( +
+ {/* This spacer accounts for the main button height */} +
+ + {/* Thumbs down button */} +
+ +
+ + {/* Thumbs up button */} +
+ +
+
+ )} + + ); +} diff --git a/jumble/src/components/FeedbackDialog.tsx b/jumble/src/components/FeedbackDialog.tsx new file mode 100644 index 0000000000..8762ed383f --- /dev/null +++ b/jumble/src/components/FeedbackDialog.tsx @@ -0,0 +1,139 @@ +import { useEffect, useState } from "react"; +import { fetchUserInfo } from "@/services/feedback.ts"; + +interface FeedbackDialogProps { + isOpen: boolean; + onClose: () => void; + onSubmit: ( + data: { score: number; explanation: string; userInfo?: any }, + ) => Promise; + initialScore: number; + isSubmitting?: boolean; +} + +export function FeedbackDialog({ + isOpen, + onClose, + onSubmit, + initialScore, + isSubmitting = false, +}: FeedbackDialogProps) { + const [explanation, setExplanation] = useState(""); + + // Reset explanation when dialog opens with a new score + useEffect(() => { + if (isOpen) { + setExplanation(""); + } + }, [isOpen, initialScore]); + + // Log when dialog open state changes + useEffect(() => { + console.log( + "FeedbackDialog isOpen:", + isOpen, + "initialScore:", + initialScore, + ); + }, [isOpen, initialScore]); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (!isOpen) return; + + if (e.key === "Escape") { + onClose(); + } else if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { + e.preventDefault(); + if (!isSubmitting && explanation.trim()) { + submitFeedback(); + } + } + }; + + globalThis.addEventListener("keydown", handleKeyDown); + return () => globalThis.removeEventListener("keydown", handleKeyDown); + }, [isOpen, onClose, onSubmit, explanation, isSubmitting, initialScore]); + + if (!isOpen) return null; + + const submitFeedback = async () => { + if (!explanation.trim()) return; + + console.log("Submitting feedback form with explanation:", explanation); + + // Fetch user info only when submitting + try { + const userInfo = await fetchUserInfo(); + console.log("Fetched user info before submission:", userInfo); + + // Submit feedback with the user info + await onSubmit({ + score: initialScore, + explanation, + userInfo, + }); + } catch (error) { + console.error("Error fetching user info during submission:", error); + // Continue with submission even if user info fetch fails + await onSubmit({ score: initialScore, explanation }); + } + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + submitFeedback(); + }; + + return ( +
+
+

+ {initialScore === 1 ? "Positive Feedback" : "Improvement Feedback"} +

+
+
+ +