feat(recipe-studio): add LLM trace modes and reasoning content extraction support
This commit is contained in:
parent
b7ee065ffd
commit
891739a56a
7 changed files with 172 additions and 13 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
? [
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue