feat(recipe-studio): add LLM trace modes and reasoning content extraction support

This commit is contained in:
Shine1i 2026-03-01 12:01:48 +01:00
commit 891739a56a
7 changed files with 172 additions and 13 deletions

View file

@ -2,9 +2,62 @@ from __future__ import annotations
import base64
import io
from pathlib import Path
from typing import Any
def _pil_to_preview_payload(image: Any) -> dict[str, Any]:
buffer = io.BytesIO()
image.convert("RGB").save(buffer, format="JPEG", quality=85)
return {
"type": "image",
"mime": "image/jpeg",
"width": image.width,
"height": image.height,
"data": base64.b64encode(buffer.getvalue()).decode("ascii"),
}
def _open_pil_image_from_bytes(raw_bytes: bytes):
from PIL import Image # type: ignore
with Image.open(io.BytesIO(raw_bytes)) as image:
return image.copy()
def _to_pil_from_hf_image_dict(value: Any) -> Any | None:
if not isinstance(value, dict):
return None
raw_bytes = value.get("bytes")
if isinstance(raw_bytes, (bytes, bytearray)) and len(raw_bytes) > 0:
try:
return _open_pil_image_from_bytes(bytes(raw_bytes))
except (OSError, ValueError):
pass
if (
isinstance(raw_bytes, list)
and len(raw_bytes) > 0
and all(isinstance(item, int) and 0 <= item <= 255 for item in raw_bytes)
):
try:
return _open_pil_image_from_bytes(bytes(raw_bytes))
except (OSError, ValueError):
pass
path_value = value.get("path")
if isinstance(path_value, str) and path_value.strip():
try:
from PIL import Image # type: ignore
with Image.open(Path(path_value)) as image:
return image.copy()
except (OSError, ValueError, TypeError):
return None
return None
def to_jsonable(value: Any) -> Any:
"""Convert numpy/pandas-ish values into plain JSON-safe values."""
try:
@ -39,17 +92,12 @@ def _to_preview_image_payload(value: Any) -> dict[str, Any] | None:
return None
if not isinstance(value, PILImage):
return None
hf_image = _to_pil_from_hf_image_dict(value)
if hf_image is None:
return None
value = hf_image
buffer = io.BytesIO()
value.convert("RGB").save(buffer, format="JPEG", quality=85)
return {
"type": "image",
"mime": "image/jpeg",
"width": value.width,
"height": value.height,
"data": base64.b64encode(buffer.getvalue()).decode("ascii"),
}
return _pil_to_preview_payload(value)
def to_preview_jsonable(value: Any) -> Any:

View file

@ -6,6 +6,11 @@ import {
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Switch } from "@/components/ui/switch";
import {
Select,
@ -15,7 +20,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { type ReactElement, type RefObject, useMemo } from "react";
import { type ReactElement, type RefObject, useMemo, useState } from "react";
import { useRecipeStudioStore } from "../../stores/recipe-studio";
import { isLikelyImageValue } from "../../utils/image-preview";
import type { LlmConfig } from "../../types";
@ -44,6 +49,15 @@ const CODE_LANG_OPTIONS = [
"sql:ansi",
];
const TRACE_MODE_OPTIONS = ["none", "last_message", "all_messages"] as const;
function normalizeTraceMode(value: string): LlmConfig["with_trace"] {
if (value === "last_message" || value === "all_messages") {
return value;
}
return "none";
}
type LlmGeneralTabProps = {
config: LlmConfig;
modelConfigAliases: string[];
@ -120,6 +134,9 @@ export function LlmGeneralTab({
};
const imageContextToggleId = `${config.id}-image-context-enabled`;
const imageContextColumnId = `${config.id}-image-context-column`;
const traceModeId = `${config.id}-trace-mode`;
const reasoningToggleId = `${config.id}-reasoning-content`;
const [advancedOpen, setAdvancedOpen] = useState(false);
return (
<div className="space-y-4">
@ -285,6 +302,65 @@ export function LlmGeneralTab({
</div>
)}
</div>
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger asChild={true}>
<button
type="button"
className="flex w-full items-center justify-between text-left text-xs text-muted-foreground"
>
<span className="font-semibold uppercase">Advanced</span>
<span>{advancedOpen ? "Hide" : "Show"}</span>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-3 space-y-4">
<div className="grid gap-2">
<FieldLabel
label="Trace capture"
htmlFor={traceModeId}
hint="Adds {column}__trace for debugging/replay."
/>
<Select
value={config.with_trace ?? "none"}
onValueChange={(value) =>
onUpdate({
// biome-ignore lint/style/useNamingConvention: api schema
with_trace: normalizeTraceMode(value),
})
}
>
<SelectTrigger className="nodrag w-full" id={traceModeId}>
<SelectValue placeholder="Select trace mode" />
</SelectTrigger>
<SelectContent>
{TRACE_MODE_OPTIONS.map((traceMode) => (
<SelectItem key={traceMode} value={traceMode}>
{traceMode}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between gap-3 rounded-2xl border border-border/60 px-3 py-3">
<div>
<FieldLabel
label="Extract reasoning content"
htmlFor={reasoningToggleId}
hint="Adds {column}__reasoning_content when model provides it."
/>
</div>
<Switch
id={reasoningToggleId}
checked={config.extract_reasoning_content === true}
onCheckedChange={(checked) =>
onUpdate({
// biome-ignore lint/style/useNamingConvention: api schema
extract_reasoning_content: checked,
})
}
/>
</div>
</CollapsibleContent>
</Collapsible>
{config.llm_type === "structured" && (
<div className="grid gap-2">
<FieldLabel

View file

@ -158,6 +158,8 @@ export type LlmImageContextConfig = {
column_name: string;
};
export type LlmTraceType = "none" | "last_message" | "all_messages";
export type LlmConfig = {
id: string;
kind: "llm";
@ -184,6 +186,10 @@ export type LlmConfig = {
// ui-only, serialized into multi_modal_context for DataDesigner
// biome-ignore lint/style/useNamingConvention: ui schema
image_context?: LlmImageContextConfig;
// biome-ignore lint/style/useNamingConvention: api schema
with_trace?: LlmTraceType;
// biome-ignore lint/style/useNamingConvention: api schema
extract_reasoning_content?: boolean;
};
export type ModelProviderConfig = {

View file

@ -210,6 +210,10 @@ export function makeLlmConfig(
// biome-ignore lint/style/useNamingConvention: api schema
column_name: "",
},
// biome-ignore lint/style/useNamingConvention: api schema
with_trace: "none",
// biome-ignore lint/style/useNamingConvention: api schema
extract_reasoning_content: false,
scores:
llmType === "judge"
? [

View file

@ -9,6 +9,14 @@ import {
readString,
} from "../helpers";
function parseTraceMode(value: unknown): LlmConfig["with_trace"] {
const traceRaw = readString(value) ?? "none";
if (traceRaw === "last_message" || traceRaw === "all_messages") {
return traceRaw;
}
return "none";
}
export function parseLlm(
column: Record<string, unknown>,
name: string,
@ -64,6 +72,9 @@ export function parseLlm(
}
}
const withTrace = parseTraceMode(column.with_trace);
const extractReasoningContent = column.extract_reasoning_content === true;
return {
id,
kind: "llm",
@ -82,6 +93,10 @@ export function parseLlm(
output_format: normalizeOutputFormat(column.output_format),
// biome-ignore lint/style/useNamingConvention: api schema
tool_alias: readString(column.tool_alias) ?? "",
// biome-ignore lint/style/useNamingConvention: api schema
with_trace: withTrace,
// biome-ignore lint/style/useNamingConvention: api schema
extract_reasoning_content: extractReasoningContent,
scores: llmType === "judge" ? scores : undefined,
// biome-ignore lint/style/useNamingConvention: ui schema
image_context: imageContext,

View file

@ -39,6 +39,10 @@ export function buildLlmColumn(
multi_modal_context: buildImageContext(config, errors),
// biome-ignore lint/style/useNamingConvention: api schema
tool_alias: toolAlias || undefined,
// biome-ignore lint/style/useNamingConvention: api schema
with_trace: config.with_trace ?? "none",
// biome-ignore lint/style/useNamingConvention: api schema
extract_reasoning_content: config.extract_reasoning_content === true,
};
if (config.llm_type === "code") {
@ -103,8 +107,6 @@ export function buildLlmColumn(
// biome-ignore lint/style/useNamingConvention: api schema
column_type: "llm-text",
...base,
// biome-ignore lint/style/useNamingConvention: api schema
with_trace: "none",
};
}

View file

@ -1,6 +1,8 @@
import type { NodeConfig } from "../types";
import { isValidSex, parseAgeRange, parseIntNumber, parseNumber } from "./parse";
const TRACE_MODES = new Set(["none", "last_message", "all_messages"]);
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: validation rules
export function getConfigErrors(config: NodeConfig | null): string[] {
if (!config) {
@ -178,6 +180,12 @@ export function getConfigErrors(config: NodeConfig | null): string[] {
errors.push("Image context column is required.");
}
}
if (
config.with_trace &&
!TRACE_MODES.has(config.with_trace)
) {
errors.push("Trace mode must be none, last_message, or all_messages.");
}
}
if (config.kind === "expression") {
if (!config.expr.trim()) {