Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion config/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ Decision fragments may reference `modelRefs[].lora_name`, but those adapter name
`config/algorithm/` is organized by routing policy:

- `looper/`: multi-model execution policies such as `confidence`, `ratings`, and `remom`
- `selection/`: candidate-selection policies such as `elo`, `router_dc`, `automix`, and `latency_aware`
- `selection/`: candidate-selection policies such as `elo`, `router_dc`, `automix`, `session_aware`, and `latency_aware`

Each supported algorithm now has its own tutorial page under `website/docs/tutorials/algorithm/`.

Expand Down
9 changes: 9 additions & 0 deletions config/algorithm/selection/session-aware.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
algorithm:
type: session_aware
session_aware:
fallback_method: hybrid
min_turns_before_switch: 2
stay_bias: 0.3
quality_gap_multiplier: 1.15
handoff_penalty_weight: 0.9
remaining_turn_weight: 0.45
42 changes: 26 additions & 16 deletions config/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -467,22 +467,6 @@ routing:
- name: support_escalated
gte: 0.45

session_states:
- name: session_routing
fields:
- name: turn_number
type: int
- name: current_model
type: string
- name: cumulative_cost_usd
type: float
- name: retry_count_ema
type: float
- name: quality_score_ema
type: float
- name: kv_cache_warm
type: float

decisions:
- name: static_business_route
description: Static fallback for standard business traffic.
Expand Down Expand Up @@ -787,6 +771,32 @@ routing:
cost_weight: 0.1
quality_gap_threshold: 0.08
normalize_scores: true
- name: session_continuation_route
description: Session-aware route that prefers staying on the current technical model mid-conversation.
priority: 136
rules:
operator: AND
conditions:
- type: session
name: session_present
- type: domain
name: "computer science"
modelRefs:
- model: qwen3-8b
use_reasoning: false
- model: qwen3-32b
lora_name: computer-science-expert
use_reasoning: true
algorithm:
type: session_aware
session_aware:
fallback_method: hybrid
min_turns_before_switch: 2
stay_bias: 0.3
quality_gap_multiplier: 1.15
handoff_penalty_weight: 0.9
remaining_turn_weight: 0.45

- name: spanish_rl_route
description: RL-driven route for Spanish-language traffic with Milvus RAG.
priority: 135
Expand Down
22 changes: 22 additions & 0 deletions config/signal/session/runtime-facts.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
routing:
signals:
session:
- name: session_present
description: Requests that belong to an existing multi-turn conversation.
fact: session_present
predicate:
gte: 1
- name: warm_cache_continuation
description: Prefer staying on the warmed model when the same conversation continues.
fact: cache_warmth
previous_model: qwen3-8b
predicate:
gte: 0.6
- name: expensive_handoff
description: Detect costly mid-session upgrades into the premium coding model.
fact: handoff_penalty
intent_or_domain: computer science
previous_model: qwen3-8b
candidate_model: qwen3-32b
predicate:
gte: 0.15
1 change: 1 addition & 0 deletions dashboard/backend/handlers/topology.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ type RouterMatchedSignals struct {
Structure []string `json:"structure,omitempty"`
Complexity []string `json:"complexity,omitempty"`
Modality []string `json:"modality,omitempty"`
Session []string `json:"session,omitempty"`
Authz []string `json:"authz,omitempty"`
Jailbreak []string `json:"jailbreak,omitempty"`
PII []string `json:"pii,omitempty"`
Expand Down
1 change: 1 addition & 0 deletions dashboard/backend/handlers/topology_response.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ func topologySignalMappings(matchedSignals *RouterMatchedSignals) []topologySign
{signalType: "structure", names: matchedSignals.Structure, defaultConfidence: 1.0, reason: "Structure rule matched", addPath: true},
{signalType: "complexity", names: matchedSignals.Complexity, defaultConfidence: 0.9, reason: "Complexity level matched", addPath: true},
{signalType: "modality", names: matchedSignals.Modality, defaultConfidence: 1.0, reason: "Modality signal matched", addPath: true},
{signalType: "session", names: matchedSignals.Session, defaultConfidence: 1.0, reason: "Session signal matched", addPath: true},
{signalType: "authz", names: matchedSignals.Authz, defaultConfidence: 1.0, reason: "Authorization signal matched", addPath: true},
{signalType: "jailbreak", names: matchedSignals.Jailbreak, defaultConfidence: 1.0, reason: "Jailbreak signal matched", addPath: true},
{signalType: "pii", names: matchedSignals.PII, defaultConfidence: 1.0, reason: "PII signal matched", addPath: true},
Expand Down
3 changes: 3 additions & 0 deletions dashboard/frontend/src/pages/ConfigPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,9 @@ const ConfigPage: React.FC<ConfigPageProps> = ({ activeSection = 'global-config'
case 'Modality':
cfg.signals.modality = (cfg.signals.modality || []).filter(s => s.name !== targetName)
break
case 'Session':
cfg.signals.session = (cfg.signals.session || []).filter(s => s.name !== targetName)
break
case 'Authz':
cfg.signals.role_bindings = (cfg.signals.role_bindings || []).filter(s => s.name !== targetName)
break
Expand Down
4 changes: 4 additions & 0 deletions dashboard/frontend/src/pages/ConfigPageDecisionsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -259,8 +259,12 @@ export default function ConfigPageDecisionsSection({
])
case 'modality':
return config?.signals?.modality?.map((m) => m.name) || []
case 'session':
return config?.signals?.session?.map((rule) => rule.name) || []
case 'authz':
return config?.signals?.role_bindings?.map((binding) => binding.name) || []
case 'kb':
return config?.signals?.kb?.map((rule) => rule.name) || []
case 'jailbreak':
return config?.signals?.jailbreak?.map((rule) => rule.name) || []
case 'pii':
Expand Down
98 changes: 97 additions & 1 deletion dashboard/frontend/src/pages/ConfigPageSignalsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,18 @@ export default function ConfigPageSignalsSection({
})
})

effectiveSignals?.session?.forEach(session => {
const scope = [session.fact, session.previous_model, session.candidate_model]
.filter(Boolean)
.join(' • ')
allSignals.push({
name: session.name,
type: 'Session',
summary: scope || session.description || 'Session-derived runtime fact',
rawData: session,
})
})

effectiveSignals?.role_bindings?.forEach(binding => {
const subjectCount = binding.subjects?.length || 0
allSignals.push({
Expand Down Expand Up @@ -519,6 +531,22 @@ export default function ConfigPageSignalsSection({
{ label: 'Description', value: signal.rawData.description || 'N/A', fullWidth: true },
]
})
} else if (signal.type === 'Session') {
sections.push({
title: 'Session Signal',
fields: [
{ label: 'Fact', value: signal.rawData.fact || 'N/A' },
{ label: 'Intent / Domain', value: signal.rawData.intent_or_domain || 'Any' },
{ label: 'Previous Model', value: signal.rawData.previous_model || 'Any' },
{ label: 'Candidate Model', value: signal.rawData.candidate_model || 'Any' },
{
label: 'Predicate',
value: signal.rawData.predicate ? JSON.stringify(signal.rawData.predicate, null, 2) : 'Always match',
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This UI treats a missing session predicate as "Always match" and allows saving predicate: undefined, but the router-side config validator currently rejects session signals without a predicate. Align the frontend with the backend contract (either require a predicate here, or change backend validation/runtime semantics to allow omitted predicates).

Suggested change
value: signal.rawData.predicate ? JSON.stringify(signal.rawData.predicate, null, 2) : 'Always match',
value: signal.rawData.predicate
? JSON.stringify(signal.rawData.predicate, null, 2)
: 'Missing predicate (invalid configuration)',

Copilot uses AI. Check for mistakes.
fullWidth: true,
},
{ label: 'Description', value: signal.rawData.description || 'N/A', fullWidth: true },
]
})
} else if (signal.type === 'Authz') {
sections.push({
title: 'Role Binding',
Expand Down Expand Up @@ -617,6 +645,11 @@ export default function ConfigPageSignalsSection({
easy_candidates: '',
composer_operator: 'AND',
composer_conditions: '',
session_fact: '',
session_predicate: JSON.stringify({ gte: 1 }, null, 2),
session_intent_or_domain: '',
session_previous_model: '',
session_candidate_model: '',
jailbreak_threshold: 0.65,
jailbreak_method: 'classifier',
include_history: false,
Expand Down Expand Up @@ -658,6 +691,11 @@ export default function ConfigPageSignalsSection({
easy_candidates: (signal.rawData.easy?.candidates || []).join('\n'),
composer_operator: signal.rawData.composer?.operator || 'AND',
composer_conditions: signal.rawData.composer?.conditions?.map((c: { type: string; name: string }) => `${c.type}:${c.name}`).join('\n') || '',
session_fact: signal.type === 'Session' ? signal.rawData.fact || '' : '',
session_predicate: signal.type === 'Session' && signal.rawData.predicate ? JSON.stringify(signal.rawData.predicate, null, 2) : defaultForm.session_predicate,
session_intent_or_domain: signal.type === 'Session' ? signal.rawData.intent_or_domain || '' : '',
session_previous_model: signal.type === 'Session' ? signal.rawData.previous_model || '' : '',
session_candidate_model: signal.type === 'Session' ? signal.rawData.candidate_model || '' : '',
jailbreak_threshold: signal.rawData.threshold ?? 0.65,
jailbreak_method: signal.rawData.method || 'classifier',
include_history: !!signal.rawData.include_history,
Expand All @@ -681,7 +719,7 @@ export default function ConfigPageSignalsSection({
name: 'type',
label: 'Type',
type: 'select',
options: ['Keywords', 'Embeddings', 'Domain', 'Preference', 'Fact Check', 'User Feedback', 'Reask', 'Language', 'Context', 'Structure', 'Complexity', 'Modality', 'Authz', 'Jailbreak', 'PII', 'KB'],
options: ['Keywords', 'Embeddings', 'Domain', 'Preference', 'Fact Check', 'User Feedback', 'Reask', 'Language', 'Context', 'Structure', 'Complexity', 'Modality', 'Session', 'Authz', 'Jailbreak', 'PII', 'KB'],
required: true,
description: 'Fields are validated based on the selected type.'
},
Expand Down Expand Up @@ -861,6 +899,43 @@ export default function ConfigPageSignalsSection({
description: 'Phrases representing easy/simple queries',
shouldHide: conditionallyHideFieldExceptType('Complexity')
},
{
name: 'session_fact',
label: 'Fact (session only)',
type: 'text',
placeholder: 'session_present',
description: 'Runtime-derived session fact name consumed by the router.',
shouldHide: conditionallyHideFieldExceptType('Session')
},
{
name: 'session_predicate',
label: 'Predicate (session only)',
type: 'textarea',
placeholder: '{\n "gte": 1\n}',
description: 'Optional numeric predicate JSON applied to the runtime fact value.',
shouldHide: conditionallyHideFieldExceptType('Session')
},
{
name: 'session_intent_or_domain',
label: 'Intent / Domain (session only)',
type: 'text',
placeholder: 'computer science',
shouldHide: conditionallyHideFieldExceptType('Session')
},
{
name: 'session_previous_model',
label: 'Previous Model (session only)',
type: 'text',
placeholder: 'qwen3-8b',
shouldHide: conditionallyHideFieldExceptType('Session')
},
{
name: 'session_candidate_model',
label: 'Candidate Model (session only)',
type: 'text',
placeholder: 'qwen3-32b',
shouldHide: conditionallyHideFieldExceptType('Session')
},
{
name: 'role',
label: 'Role (authz only)',
Expand Down Expand Up @@ -1231,6 +1306,27 @@ export default function ConfigPageSignalsSection({
]
break
}
case 'Session': {
const fact = (formData.session_fact || '').trim()
if (!fact) {
throw new Error('Fact is required for session signals.')
}
const predicateText = (formData.session_predicate || '').trim()
const predicate = predicateText ? JSON.parse(predicateText) : undefined
newConfig.signals.session = [
...(newConfig.signals.session || []),
{
name,
description: formData.description || undefined,
fact,
predicate,
intent_or_domain: (formData.session_intent_or_domain || '').trim() || undefined,
previous_model: (formData.session_previous_model || '').trim() || undefined,
candidate_model: (formData.session_candidate_model || '').trim() || undefined,
}
]
break
}
case 'Authz': {
const role = (formData.role || '').trim()
if (!role) {
Expand Down
2 changes: 2 additions & 0 deletions dashboard/frontend/src/pages/DashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,11 @@ const SIGNAL_COLORS: Record<string, string> = {
context: '#D7BA7D',
complexity: '#569CD6',
modality: '#D4D4D4',
session: '#7AA2F7',
authz: '#F48771',
jailbreak: '#F48771',
pii: '#FF6B6B',
kb: '#9A7BFF',
}

const MiniFlowDiagram: React.FC<FlowProps> = React.memo(({ signals, decisions, models, plugins }) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -700,7 +700,7 @@ function fieldsForKey(key: RouterSystemKey): FieldConfig[] {
case 'model_selection':
return [
{ name: 'enabled', label: 'Enable Model Selection', type: 'boolean' },
{ name: 'default_algorithm', label: 'Method', type: 'select', options: ['knn', 'kmeans', 'svm', 'elo', 'router_dc', 'automix', 'hybrid'], required: true },
{ name: 'default_algorithm', label: 'Method', type: 'select', options: ['knn', 'kmeans', 'svm', 'elo', 'router_dc', 'automix', 'hybrid', 'session_aware'], required: true },
{ name: 'models_path', label: 'ML Models Path', type: 'text', placeholder: 'models/model_selection' },
{ name: 'knn', label: 'KNN Config (JSON)', type: 'json' },
{ name: 'kmeans', label: 'KMeans Config (JSON)', type: 'json' },
Expand All @@ -709,6 +709,7 @@ function fieldsForKey(key: RouterSystemKey): FieldConfig[] {
{ name: 'router_dc', label: 'RouterDC Config (JSON)', type: 'json' },
{ name: 'automix', label: 'AutoMix Config (JSON)', type: 'json' },
{ name: 'hybrid', label: 'Hybrid Config (JSON)', type: 'json' },
{ name: 'session_aware', label: 'Session-Aware Config (JSON)', type: 'json' },
]
case 'api':
return [{ name: 'batch_classification', label: 'Batch Classification (JSON)', type: 'json', placeholder: '{"metrics":{"enabled":true}}' }]
Expand Down Expand Up @@ -775,6 +776,7 @@ function editDataForKey(key: RouterSystemKey, data: unknown): EditFormData {
router_dc: asObject(selection?.router_dc) || {},
automix: asObject(selection?.automix) || {},
hybrid: asObject(selection?.hybrid) || {},
session_aware: asObject(selection?.session_aware) || {},
}
}
const objectData = asObject(data)
Expand Down Expand Up @@ -829,6 +831,7 @@ function saveForKey(key: RouterSystemKey, data: EditFormData): Partial<ConfigDat
router_dc: asObject(data.router_dc) || {},
automix: asObject(data.automix) || {},
hybrid: asObject(data.hybrid) || {},
session_aware: asObject(data.session_aware) || {},
ml: {
models_path: data.models_path,
knn: asObject(data.knn) || {},
Expand Down
9 changes: 9 additions & 0 deletions dashboard/frontend/src/pages/configPageSupport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -622,6 +622,14 @@ export interface ModelSelectionConfig {
quality_gap_threshold?: number
normalize_scores?: boolean
}
session_aware?: {
fallback_method?: string
min_turns_before_switch?: number
stay_bias?: number
quality_gap_multiplier?: number
handoff_penalty_weight?: number
remaining_turn_weight?: number
}
ml?: {
models_path?: string
embedding_dim?: number
Expand Down Expand Up @@ -1176,6 +1184,7 @@ export type SignalType =
| 'Structure'
| 'Complexity'
| 'Modality'
| 'Session'
| 'Authz'
| 'Jailbreak'
| 'PII'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,19 @@ export const AlgorithmNode = memo<NodeProps<AlgorithmNodeData>>(({ data }) => {
}
return parts.length > 0 ? parts.join(', ') : null
}
if (algorithm.type === 'session_aware' && algorithm.session_aware) {
const parts: string[] = []
if (algorithm.session_aware.fallback_method) {
parts.push(`fallback ${algorithm.session_aware.fallback_method}`)
}
if (algorithm.session_aware.min_turns_before_switch !== undefined) {
parts.push(`min turns ${algorithm.session_aware.min_turns_before_switch}`)
}
if (algorithm.session_aware.stay_bias !== undefined) {
parts.push(`stay bias ${algorithm.session_aware.stay_bias}`)
}
return parts.length > 0 ? parts.join(', ') : null
}
return null
}

Expand Down
Loading
Loading