feat(ui): let an edited tier set write its own classifier opening instructions

The classifier prompt control was replaced by a sentence in custom mode, so an
edited tier set could not steer its classifier at all. That control edits
classifier_llm_config.system_prompt, a whole-prompt replacement the backend
rejects beside tier_definitions, so refusing it there is right.

classification_prompt is the field that fits: the router uses it as the opening
of the prompt and appends the tier bullets and the injection guard after it, so
an operator can state their own instructions and calibration examples without
being able to restate or remove either. It has always been accepted beside
tier_definitions and the dashboard just never exposed it.

Custom mode now gets its own editor for that field, showing the tier bullets it
will append underneath. Built-in routers keep the whole-prompt editor. The
classifierPrompt restriction goes away with the control it described, and the
Restricted helpers move out of ComplexityRouterConfig, which was at its
max-lines ceiling.
This commit is contained in:
Tin Chi Lo 2026-08-26 23:23:33 -07:00
parent bbd45bf37d
commit 8921bfb0fc
11 changed files with 232 additions and 28 deletions

View file

@ -10,6 +10,8 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Switch } from "@/components/ui/switch";
import React from "react";
import ClassifierPromptEditor from "./ClassifierPromptEditor";
import ClassificationPromptEditor from "./ClassificationPromptEditor";
import { Restricted, restrictedBy } from "./TierRestrictions";
import HeuristicScoringConfig from "./HeuristicScoringConfig";
import { useComplexityScorerDefaults } from "@/app/(dashboard)/hooks/autoRouter/useComplexityScorerDefaults";
import {
@ -32,9 +34,6 @@ import {
DEFAULT_HEURISTIC_FIRST_MAX_TIER,
HEURISTIC_FIRST_MAX_TIER_KEYS,
effectiveClassifierType,
heuristicScoringRole,
restrictedBy,
Restricted,
} from "./ComplexityRouterConfig";
const DEFAULT_SCORING_EXPLANATION =
@ -202,6 +201,10 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
onChange({ ...value, heuristic_first_max_tier: tier });
};
const handleClassificationPromptChange = (classificationPrompt: string | undefined) => {
onChange({ ...value, classification_prompt: classificationPrompt });
};
const handleClassifierModelChange = (model: string) => {
onChange({
...value,
@ -414,7 +417,13 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
</div>
<div>
<strong className="block mb-1 font-semibold">Classifier Prompt</strong>
<Restricted by={restrictedBy(value, "classifierPrompt")}>
{value.custom_tier_set ? (
<ClassificationPromptEditor
classificationPrompt={value.classification_prompt}
onChange={handleClassificationPromptChange}
tierRows={value.custom_tier_set.tiers}
/>
) : (
<ClassifierPromptEditor
systemPrompt={value.classifier_llm_config?.system_prompt}
onChange={handleClassifierSystemPromptChange}
@ -422,7 +431,7 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
tierLabels={value.tier_labels}
classificationRubric={classificationRubric}
/>
</Restricted>
)}
</div>
<div>
<strong className="block mb-1 font-semibold">If the classifier fails</strong>

View file

@ -0,0 +1,101 @@
import React, { useState } from "react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { TierRow, activeTierName } from "./tier_rows";
interface ClassificationPromptEditorProps {
classificationPrompt: string | undefined;
onChange: (classificationPrompt: string | undefined) => void;
tierRows: readonly TierRow[];
}
const PLACEHOLDER = `Classify the request into exactly one tier for a payments engineering team.
Examples:
- "bump the copy on the checkout button" -> TRIAGE
- "why is our webhook signature check failing" -> SECURITY_REVIEW`;
const appendedTierBullets = (tierRows: readonly TierRow[]): string =>
tierRows.map((row) => `- ${activeTierName(row)}: ${row.definition}`).join("\n");
const ClassificationPromptEditor: React.FC<ClassificationPromptEditorProps> = ({
classificationPrompt,
onChange,
tierRows,
}) => {
const [isOpen, setIsOpen] = useState(false);
const [draft, setDraft] = useState("");
const isOverridden = Boolean(classificationPrompt?.trim());
const openEditor = () => {
setDraft(classificationPrompt ?? "");
setIsOpen(true);
};
const handleSave = () => {
onChange(draft.trim() || undefined);
setIsOpen(false);
};
return (
<div>
<div className="flex items-center gap-2">
<Button type="button" size="sm" variant="outline" onClick={openEditor}>
{isOverridden ? "Edit opening instructions" : "Change default prompt"}
</Button>
{isOverridden && (
<Button type="button" size="sm" variant="link" onClick={() => onChange(undefined)}>
Reset to default
</Button>
)}
</div>
<p className="mt-1 text-xs text-muted-foreground">
{isOverridden
? "This router opens its classifier prompt with your own instructions."
: "Replace the opening instructions, and write calibration examples of your own. Your tier definitions and the injection guard are always appended below them."}
</p>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent className="sm:max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Classifier prompt</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
Your text is the opening of the classifier prompt, so it is where calibration examples of your own belong.
The router appends your tier definitions and its injection guard underneath, and neither can be edited or
removed from here. Edit the definitions themselves with Edit tiers above.
</p>
<Textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
rows={12}
placeholder={PLACEHOLDER}
aria-label="Classifier opening instructions"
className="mt-3 font-mono text-xs"
/>
<div className="mt-3">
<p className="text-xs font-medium">Always appended below your text</p>
<pre className="mt-1 overflow-x-auto rounded-md bg-muted p-3 font-mono text-xs text-muted-foreground">
{`Tiers:\n${appendedTierBullets(tierRows)}\n\n<injection guard and closing line>`}
</pre>
</div>
<DialogFooter className="mt-4">
<Button type="button" variant="outline" onClick={() => setIsOpen(false)}>
Cancel
</Button>
<Button type="button" onClick={handleSave}>
Save prompt
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};
export default ClassificationPromptEditor;

View file

@ -1298,6 +1298,26 @@ describe("ComplexityRouterConfig tier editing", () => {
).toBeInTheDocument();
});
it("lets an edited tier set write its own opening instructions instead of refusing a prompt outright", () => {
renderWithProviders(<ComplexityRouterConfig {...baseProps} value={customValue} onEditingTiersChange={vi.fn()} />);
fireEvent.click(screen.getByText("Advanced: Classification Method"));
expect(screen.getByText("write calibration examples of your own", { exact: false })).toBeInTheDocument();
expect(screen.queryByText("A replacement prompt drops the tier bullets", { exact: false })).not.toBeInTheDocument();
});
it("keeps the whole-prompt replacement editor on built-in routers, which the backend still accepts there", () => {
renderWithProviders(
<ComplexityRouterConfig
{...baseProps}
value={{ ...defaultValue, classifier_type: "llm", classifier_llm_config: { model: "gpt-4", timeout_ms: 3000 } }}
onEditingTiersChange={vi.fn()}
/>,
);
fireEvent.click(screen.getByText("Advanced: Classification Method"));
expect(screen.getByText("Replace the built-in complexity rubric", { exact: false })).toBeInTheDocument();
expect(screen.queryByText("write calibration examples of your own", { exact: false })).not.toBeInTheDocument();
});
it("leaves built-in routers with their display-name inputs and no restriction copy", () => {
renderWithProviders(<ComplexityRouterConfig {...baseProps} onEditingTiersChange={vi.fn()} />);
expect(screen.getByLabelText("Display name for the Simple tier")).toBeInTheDocument();

View file

@ -13,11 +13,9 @@ import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
type CustomTierSet,
type TierRestriction,
type ActiveTierRow,
type TierRow,
rowParamsByTier,
CUSTOM_TIER_RESTRICTIONS,
MAX_TIER_COUNT,
MAX_TIER_DEFINITION_CHARS,
MAX_TIER_NAME_CHARS,
@ -37,6 +35,7 @@ import React from "react";
import { ModelGroup } from "@/components/llm_calls/fetch_models";
import AdaptiveRoutingConfig from "./AdaptiveRoutingConfig";
import ClassificationMethodConfig from "./ClassificationMethodConfig";
import { Restricted, restrictedBy } from "./TierRestrictions";
import {
REASONING_EFFORT_OPTIONS,
ReasoningEffort,
@ -166,16 +165,6 @@ export const effectiveClassifierType = (
value: Pick<ComplexityRouterConfigValue, "custom_tier_set" | "classifier_type">,
): ClassifierType => (value.custom_tier_set ? "llm" : value.classifier_type);
export const restrictedBy = (
value: Pick<ComplexityRouterConfigValue, "custom_tier_set">,
key: keyof typeof CUSTOM_TIER_RESTRICTIONS,
): TierRestriction | undefined => (value.custom_tier_set ? CUSTOM_TIER_RESTRICTIONS[key] : undefined);
export const Restricted: React.FC<{ by: TierRestriction | undefined; children: React.ReactNode }> = ({
by,
children,
}) => (by ? <span className="block text-sm text-muted-foreground">{by.reason}</span> : <>{children}</>);
const rowOrigin = (row: TierRow, editing: boolean): string => {
if (!editing) return row.id;
return isBuiltInTierName(row.name) ? "built-in" : "custom";
@ -219,6 +208,8 @@ export interface ComplexityRouterConfigValue {
classifier_context_per_turn_chars?: number;
classifier_context_include_assistant_turns?: boolean;
classifier_fallback?: ClassifierFallback;
/** Opening instructions only; the router appends the tier bullets and the injection guard after them. */
classification_prompt?: string;
/** Highest tier the scorer may decide alone under heuristic_first. Required by that type, rejected by the others. */
heuristic_first_max_tier?: string;
session_affinity?: boolean;

View file

@ -0,0 +1,12 @@
import React from "react";
import { CUSTOM_TIER_RESTRICTIONS, CustomTierSet, TierRestriction } from "./tier_rows";
export const restrictedBy = (
value: { custom_tier_set?: CustomTierSet },
key: keyof typeof CUSTOM_TIER_RESTRICTIONS,
): TierRestriction | undefined => (value.custom_tier_set ? CUSTOM_TIER_RESTRICTIONS[key] : undefined);
export const Restricted: React.FC<{ by: TierRestriction | undefined; children: React.ReactNode }> = ({
by,
children,
}) => (by ? <span className="block text-sm text-muted-foreground">{by.reason}</span> : <>{children}</>);

View file

@ -339,6 +339,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
customTierSet: complexityRouterConfig.custom_tier_set,
defaultModel: complexityRouterConfig.default_model,
planModeMinTier: complexityRouterConfig.plan_mode_min_tier,
classificationPrompt: complexityRouterConfig.classification_prompt,
heuristicFirstMaxTier: complexityRouterConfig.heuristic_first_max_tier,
tierLabels: complexityRouterConfig.tier_labels,
classifierType: complexityRouterConfig.classifier_type,

View file

@ -28,6 +28,7 @@ const baseParams: BuildComplexityRouterConfigParams = {
classifierContextWindowSize: undefined,
classifierContextBudgetChars: undefined,
classifierContextIncludeAssistantTurns: undefined,
classificationPrompt: undefined,
classifierFallback: undefined,
sessionAffinity: false,
deploymentAffinity: true,
@ -811,6 +812,25 @@ describe("buildComplexityRouterConfig with an edited tier set", () => {
expect(build({ sessionAffinity: true }).session_affinity).toBe(false);
});
it("writes the operator's opening instructions, trimmed, as classification_prompt", () => {
expect(build({ classificationPrompt: " Route for a payments team.\n\nExamples:\n- x -> CASUAL " })).toMatchObject(
{ classification_prompt: "Route for a payments team.\n\nExamples:\n- x -> CASUAL" },
);
});
it("keeps classification_prompt out of the payload when the operator wrote only whitespace", () => {
expect(build({ classificationPrompt: " \n " })).not.toHaveProperty("classification_prompt");
});
it("never writes classification_prompt on a built-in router, which the backend rejects without tier_definitions", () => {
const payload = buildComplexityRouterConfig({
...baseParams,
classifierType: "llm",
classificationPrompt: "opening instructions",
});
expect(payload).not.toHaveProperty("classification_prompt");
});
it("omits a definition on a built-in name, letting the backend rubric supply it", () => {
const payload = build({
customTierSet: {

View file

@ -102,6 +102,7 @@ export interface BuildComplexityRouterConfigParams {
classifierContextBudgetChars: number | undefined;
classifierContextIncludeAssistantTurns: boolean | undefined;
classifierFallback: ClassifierFallback | undefined;
classificationPrompt: string | undefined;
heuristicFirstMaxTier: string | undefined;
sessionAffinity: boolean;
deploymentAffinity: boolean;
@ -142,6 +143,7 @@ export interface ComplexityRouterConfigPayload {
classifier_context_per_turn_chars?: number;
classifier_context_include_assistant_turns?: boolean;
classifier_fallback?: ClassifierFallback;
classification_prompt?: string;
heuristic_first_max_tier?: string;
session_affinity: boolean;
deployment_affinity: boolean;
@ -257,6 +259,7 @@ export const customTierWireFields = (
customTierSet: CustomTierSet,
classifierLlmConfig: ClassifierLLMConfig | undefined,
planModeMinTierId: string | undefined,
classificationPrompt: string | undefined,
): Partial<ComplexityRouterConfigPayload> => {
const rows = customTierSet.tiers;
const fallback = tierRowById(rows, customTierSet.fallback_tier_id);
@ -269,13 +272,14 @@ export const customTierWireFields = (
})),
...(fallback && { fallback_tier: activeTierName(fallback) }),
classifier_type: "llm",
// Rebuilt from the two fields an edited tier set allows: system_prompt and
// classification_rubric are the classifierPrompt and classificationRubric restrictions, and
// both live inside this object rather than at the top level the omit list covers.
// Rebuilt from the two fields an edited tier set allows. The backend rejects system_prompt and
// classification_rubric beside tier_definitions, and both live inside this object rather than at
// the top level the omit list covers. The opening instructions ride classification_prompt below.
...(classifierLlmConfig && {
classifier_llm_config: { model: classifierLlmConfig.model, timeout_ms: classifierLlmConfig.timeout_ms },
}),
session_affinity: false,
...(classificationPrompt?.trim() && { classification_prompt: classificationPrompt.trim() }),
...(floor && { plan_mode_min_tier: activeTierName(floor) }),
};
};
@ -335,6 +339,7 @@ export const buildComplexityRouterConfig = ({
classifierContextBudgetChars,
classifierContextIncludeAssistantTurns,
classifierFallback,
classificationPrompt,
heuristicFirstMaxTier,
sessionAffinity,
deploymentAffinity,
@ -425,5 +430,8 @@ export const buildComplexityRouterConfig = ({
const kept = Object.fromEntries(
Object.entries(payload).filter(([key]) => !CUSTOM_TIER_STRIPPED_KEYS.includes(key)),
) as ComplexityRouterConfigPayload;
return { ...kept, ...customTierWireFields(customTierSet, classifierLlmConfig, planModeMinTier) };
return {
...kept,
...customTierWireFields(customTierSet, classifierLlmConfig, planModeMinTier, classificationPrompt),
};
};

View file

@ -143,10 +143,6 @@ export const CUSTOM_TIER_RESTRICTIONS = {
],
reason: "The heuristic scorer never runs under an edited tier set, so its inputs have no effect",
},
classifierPrompt: {
omit: [],
reason: "A replacement prompt drops the tier bullets and the injection guard. Your definitions are the rubric",
},
classificationRubric: {
omit: [],
reason: "The preset calibration examples are written against the built-in tiers, which your tier set replaces",

View file

@ -476,9 +476,10 @@ describe("managed keys survive an untouched open-and-save", () => {
reasoning_override_min_score: 0.3,
};
// tier_definitions and fallback_tier cannot sit beside heuristic_first, which this fixture uses, so
// no single stored config can hold every managed key. They get their own round trip below.
const CUSTOM_TIER_ONLY_KEYS = new Set(["tier_definitions", "fallback_tier"]);
// tier_definitions, fallback_tier and classification_prompt cannot sit beside heuristic_first, which
// this fixture uses, so no single stored config can hold every managed key. They get their own round
// trip below.
const CUSTOM_TIER_ONLY_KEYS = new Set(["tier_definitions", "fallback_tier", "classification_prompt"]);
it("carries every managed key a built-in router can hold through hydrate then save", () => {
const hydrated = hydrateComplexityRouterConfig(STORED_ALL_MANAGED, undefined);
@ -528,6 +529,44 @@ describe("managed keys survive an untouched open-and-save", () => {
expect(saved.tiers).toEqual(storedCustom.tiers);
});
it("clears a stored classification_prompt when the operator resets it, rather than preserving it as an unowned key", () => {
const storedCustom = {
tiers: { CASUAL: ["gpt-4o-mini"], AUDIT: ["o1"] },
tier_definitions: [
{ name: "CASUAL", description: "small talk" },
{ name: "AUDIT", description: "security review" },
],
fallback_tier: "CASUAL",
classifier_type: "llm",
classifier_llm_config: { model: "gpt-4o-mini", timeout_ms: 3000 },
classification_prompt: "Route for a payments team.",
};
const hydrated = hydrateComplexityRouterConfig(storedCustom, undefined);
const reset = { ...hydrated, classification_prompt: undefined };
expect(buildUpdatedComplexityRouterConfig(storedCustom, reset)).not.toHaveProperty("classification_prompt");
});
it("round-trips a stored classification_prompt, which an untouched open-and-save must not clear", () => {
const storedCustom = {
tiers: { CASUAL: ["gpt-4o-mini"], AUDIT: ["o1"] },
tier_definitions: [
{ name: "CASUAL", description: "small talk" },
{ name: "AUDIT", description: "security review" },
],
fallback_tier: "CASUAL",
classifier_type: "llm",
classifier_llm_config: { model: "gpt-4o-mini", timeout_ms: 3000 },
classification_prompt: "Route for a payments team.\n\nExamples:\n- refund status -> CASUAL",
};
const hydrated = hydrateComplexityRouterConfig(storedCustom, undefined);
expect(hydrated.classification_prompt).toBe(storedCustom.classification_prompt);
expect(buildUpdatedComplexityRouterConfig(storedCustom, hydrated).classification_prompt).toBe(
storedCustom.classification_prompt,
);
});
it("round-trips the heuristic_first threshold, which save requires and the backend rejects without", () => {
const hydrated = hydrateComplexityRouterConfig(STORED_ALL_MANAGED, undefined);
expect(hydrated.heuristic_first_max_tier).toBe("SIMPLE");

View file

@ -86,6 +86,7 @@ export interface StoredComplexityRouterConfig {
tier_model_configs?: unknown;
default_model?: string | null;
plan_mode_min_tier?: unknown;
classification_prompt?: unknown;
heuristic_first_max_tier?: unknown;
tier_labels?: unknown;
classifier_type?: ClassifierType;
@ -153,6 +154,10 @@ export const hydrateComplexityRouterConfig = (
parsedConfig.classifier_fallback === "default_model" || parsedConfig.classifier_fallback === "heuristic"
? parsedConfig.classifier_fallback
: undefined,
classification_prompt:
typeof parsedConfig.classification_prompt === "string" && parsedConfig.classification_prompt.trim() !== ""
? parsedConfig.classification_prompt
: undefined,
heuristic_first_max_tier:
typeof parsedConfig.heuristic_first_max_tier === "string" && parsedConfig.heuristic_first_max_tier.trim() !== ""
? parsedConfig.heuristic_first_max_tier
@ -189,6 +194,7 @@ export const MANAGED_COMPLEXITY_ROUTER_KEYS = new Set([
"classifier_context_budget_chars",
"classifier_context_include_assistant_turns",
"classifier_fallback",
"classification_prompt",
"heuristic_first_max_tier",
"session_affinity",
"deployment_affinity",
@ -269,6 +275,7 @@ export const buildUpdatedComplexityRouterConfig = (
customTierSet: value.custom_tier_set,
defaultModel: value.default_model,
planModeMinTier: value.plan_mode_min_tier,
classificationPrompt: value.classification_prompt,
heuristicFirstMaxTier: value.heuristic_first_max_tier,
tierLabels: value.tier_labels,
classifierType: value.classifier_type,