fix(ui): keep keyword tier rules that target operator-defined tiers when hydrating the edit modal (#37413)

This commit is contained in:
tin-berri 2026-08-20 12:34:38 -07:00 committed by GitHub
parent d491a3d75c
commit 79cac36564
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 35 additions and 9 deletions

View file

@ -14,7 +14,8 @@ export type ComplexityTier = "SIMPLE" | "MEDIUM" | "COMPLEX" | "REASONING";
export interface KeywordTierRule {
id: string;
keywords: string[];
tier: ComplexityTier;
/** A built-in tier name, or with a custom tier set, one of the defined tier names. */
tier: string;
}
interface KeywordTierRulesProps {
@ -99,7 +100,7 @@ const KeywordTierRules: React.FC<KeywordTierRulesProps> = ({ rules, onChange, ti
<Select
items={tierOptions(tierLabels)}
value={rule.tier}
onValueChange={(tier: ComplexityTier | null) => tier && updateRule(rule.id, { tier })}
onValueChange={(tier: string | null) => tier && updateRule(rule.id, { tier })}
>
<SelectTrigger aria-label={`Route keyword rule ${index + 1} to tier`} className="w-full">
<SelectValue />

View file

@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { hydrateKeywordTierRules, serializeKeywordTierRules } from "./complexity_router_keywords";
describe("hydrateKeywordTierRules", () => {
it("keeps a rule whose tier is operator-defined instead of silently deleting it on edit", () => {
const stored = [
{ keywords: ["invoice"], tier: "MEDIUM" },
{ keywords: ["pentest", "vulnerability"], tier: "SECURITY_REVIEW" },
];
expect(hydrateKeywordTierRules(stored)).toEqual([
{ id: "stored-0", keywords: ["invoice"], tier: "MEDIUM" },
{ id: "stored-1", keywords: ["pentest", "vulnerability"], tier: "SECURITY_REVIEW" },
]);
});
it("round-trips through serialize without loss", () => {
const stored = [{ keywords: ["pentest"], tier: "SECURITY_REVIEW" }];
expect(serializeKeywordTierRules(hydrateKeywordTierRules(stored))).toEqual(stored);
});
it("still drops rows that are not rules at all", () => {
expect(hydrateKeywordTierRules([{ keywords: [], tier: "MEDIUM" }, { keywords: ["x"] }, "junk", null])).toEqual([]);
});
});

View file

@ -1,18 +1,18 @@
import { ComplexityTier, KeywordTierRule } from "./KeywordTierRules";
import { KeywordTierRule } from "./KeywordTierRules";
/**
* Stored shape of a keyword tier rule inside `complexity_router_config`. The UI's
* KeywordTierRule carries an extra `id` used only as a React key, so it is stripped on the
* way out and synthesized on the way back in. Both the create form and the edit modal go
* through here so the two directions cannot drift.
* through here so the two directions cannot drift. The tier is any active tier name: a
* built-in one, or with tier_definitions, an operator-defined one, so hydration must not
* filter on the built-in set or an edit would silently delete a custom tier's rules.
*/
export interface StoredKeywordTierRule {
keywords: string[];
tier: ComplexityTier;
tier: string;
}
const TIERS: ReadonlySet<string> = new Set<ComplexityTier>(["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"]);
const asKeywords = (value: unknown): string[] =>
Array.isArray(value)
? value.filter((keyword): keyword is string => typeof keyword === "string").map((keyword) => keyword.trim())
@ -40,7 +40,7 @@ export const hydrateKeywordTierRules = (value: unknown): KeywordTierRule[] => {
const record = entry as Record<string, unknown>;
const keywords = asKeywords(record.keywords).filter(Boolean);
const tier = record.tier;
if (keywords.length === 0 || typeof tier !== "string" || !TIERS.has(tier)) return [];
return [{ id: `stored-${index}`, keywords, tier: tier as ComplexityTier }];
if (keywords.length === 0 || typeof tier !== "string" || !tier.trim()) return [];
return [{ id: `stored-${index}`, keywords, tier }];
});
};