Skip to content

Commit 699a70e

Browse files
committed
feat(realtime): classifier argument slots via constrained completion
Hybrid classify-then-complete: a classifier option's canned tool call can declare typed argument slots (number | enum | string, with defaults and prompt hints) referenced as "{{name}}" in the arguments template. When the option wins, the slots are filled by a short grammar-constrained completion that continues the exact scoring prompt — rendered by the same cached ScoreClassifier, so the llama.cpp prompt cache is already warm — with the chosen route JSON re-opened at the first slot field. A GBNF grammar pins the field skeleton and frees only the values; temperature 0, a couple dozen tokens at most (~300ms on a desktop CPU for two slots). Slot declarations and hints ride the option descriptions in the shared system prompt, informing scoring and the fill alike at no per-turn token cost. The localai.classifier.result event carries the final arguments and a fill_latency_ms. On inference failure the slots' defaults apply; a slot without a default fails the response (or falls through with fallback.mode: generate). Slot filling requires completion alongside score in the scoring model's known_usecases. Verified end-to-end on the Pi drone demo: "fly forward three meters" in distance mode classifies forward and infers {"distance": 3, "units": "meters"} in ~310ms, and the drone flies exactly 3 units. Assisted-by: Claude:claude-fable-5 Signed-off-by: Richard Palethorpe <io@richiejp.com>
1 parent 61df6c8 commit 699a70e

11 files changed

Lines changed: 781 additions & 28 deletions

File tree

core/config/meta/registry.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -647,7 +647,7 @@ func DefaultRegistry() map[string]FieldMetaOverride {
647647
"pipeline.classifier.options": {
648648
Section: "pipeline",
649649
Label: "Classifier Options",
650-
Description: "The intents the classifier scores each turn against. Each option has an id (also the scored route label — keep it short), a description of when it applies, an optional canned spoken reply, and an optional canned tool call {name, arguments}. Clients can replace the list per session via session.update localai_classifier.",
650+
Description: "The intents the classifier scores each turn against. Each option has an id (also the scored route label — keep it short), a description of when it applies, an optional canned spoken reply, and an optional canned tool call {name, arguments}. A tool may also declare slots ([{name, type: number|enum|string, values, default, hint}]) whose \"{{name}}\" placeholders in arguments are filled by a short grammar-constrained completion when the option wins — the hybrid between prefill-only classification and full generation (requires completion in the scoring model's known_usecases). Clients can replace the list per session via session.update localai_classifier.",
651651
Component: "json-editor",
652652
Order: 92,
653653
},

core/config/model_config.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -729,8 +729,20 @@ type PipelineClassifierOption struct {
729729
type PipelineClassifierTool struct {
730730
Name string `yaml:"name" json:"name"`
731731
// Arguments is a plain YAML map; the realtime session marshals it to
732-
// the JSON arguments string of the emitted function call.
732+
// the JSON arguments string of the emitted function call. With Slots
733+
// it is a template: "{{name}}" values are filled by a constrained
734+
// completion when the option wins.
733735
Arguments map[string]any `yaml:"arguments,omitempty" json:"arguments,omitempty"`
736+
// Slots declares the inferred arguments; see types.ClassifierSlot.
737+
Slots []PipelineClassifierSlot `yaml:"slots,omitempty" json:"slots,omitempty"`
738+
}
739+
740+
type PipelineClassifierSlot struct {
741+
Name string `yaml:"name" json:"name"`
742+
Type string `yaml:"type" json:"type"` // number | enum | string
743+
Values []string `yaml:"values,omitempty" json:"values,omitempty"`
744+
Default string `yaml:"default,omitempty" json:"default,omitempty"`
745+
Hint string `yaml:"hint,omitempty" json:"hint,omitempty"`
734746
}
735747

736748
type PipelineClassifierFallback struct {

core/http/endpoints/openai/realtime.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,12 @@ type Model interface {
280280
// pipeline's scoring model (classifier.model, defaulting to the LLM) —
281281
// no autoregressive decode happens.
282282
ClassifyTurn(ctx context.Context, messages schema.Messages, options []types.ClassifierOption, normalization string) ([]router.LabelScore, error)
283+
// FillToolArguments completes the chosen option's argument slots with a
284+
// short grammar-constrained completion that continues the exact scoring
285+
// prompt (so the backend's prompt cache stays warm) and returns the
286+
// spliced tool-arguments JSON — the hybrid between prefill-only
287+
// classification and full generation.
288+
FillToolArguments(ctx context.Context, messages schema.Messages, options []types.ClassifierOption, normalization string, chosen *types.ClassifierOption) (string, error)
283289
PredictConfig() *config.ModelConfig
284290
// Warmup eagerly loads the pipeline's sub-model backends into memory so the
285291
// first realtime turn doesn't pay each backend's cold-start load cost. Loads

core/http/endpoints/openai/realtime_classifier.go

Lines changed: 195 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,15 @@ func classifierConfigFromPipeline(p *config.PipelineClassifier) (*types.Classifi
6565
args = data
6666
}
6767
opt.Tool = &types.ClassifierTool{Name: o.Tool.Name, Arguments: args}
68+
for _, s := range o.Tool.Slots {
69+
opt.Tool.Slots = append(opt.Tool.Slots, types.ClassifierSlot{
70+
Name: s.Name,
71+
Type: s.Type,
72+
Values: s.Values,
73+
Default: s.Default,
74+
Hint: s.Hint,
75+
})
76+
}
6877
}
6978
cc.Options = append(cc.Options, opt)
7079
}
@@ -291,17 +300,44 @@ func classifierRespond(ctx context.Context, session *Session, conv *Conversation
291300
fallbackApplied = cc.FallbackMode()
292301
}
293302

303+
// Hybrid path: a winning option with argument slots gets them filled by
304+
// a constrained completion before anything is emitted, so the result
305+
// event carries the final arguments. An unrecoverable fill failure
306+
// (error and no complete default set) is handled like a scoring
307+
// failure.
308+
filledArgs := ""
309+
var fillLatency time.Duration
310+
if chosen != nil {
311+
var ferr error
312+
filledArgs, fillLatency, ferr = fillChosenArguments(ctx, session, cc, msgs, chosen)
313+
if ferr != nil {
314+
if cc.FallbackMode() == types.ClassifierFallbackGenerate {
315+
xlog.Warn("realtime classifier: slot fill failed; falling back to generation", "error", ferr)
316+
return false
317+
}
318+
sendError(t, "classifier_failed", fmt.Sprintf("classifier slot fill failed: %v", ferr), "", "")
319+
r.outcome = outcomeFailed
320+
return true
321+
}
322+
}
323+
294324
evScores := make([]types.ClassifierScore, len(scores))
295325
for i, s := range scores {
296326
evScores[i] = types.ClassifierScore{ID: s.Label, Score: s.Score}
297327
}
328+
evArgs := ""
329+
if chosen != nil && chosen.Tool != nil && len(chosen.Tool.Slots) > 0 {
330+
evArgs = filledArgs
331+
}
298332
sendEvent(t, types.ClassifierResultEvent{
299-
ResponseID: r.id,
300-
Scores: evScores,
301-
ChosenID: chosenID,
302-
Threshold: cc.Threshold,
303-
Fallback: fallbackApplied,
304-
LatencyMs: latency.Milliseconds(),
333+
ResponseID: r.id,
334+
Scores: evScores,
335+
ChosenID: chosenID,
336+
Threshold: cc.Threshold,
337+
Fallback: fallbackApplied,
338+
LatencyMs: latency.Milliseconds(),
339+
Arguments: evArgs,
340+
FillLatencyMs: fillLatency.Milliseconds(),
305341
})
306342
topScore := 0.0
307343
if best >= 0 {
@@ -310,7 +346,8 @@ func classifierRespond(ctx context.Context, session *Session, conv *Conversation
310346
xlog.Debug("realtime classifier: scored turn",
311347
"chosen", chosenID, "top_score", topScore,
312348
"threshold", cc.Threshold, "fallback", fallbackApplied,
313-
"latency_ms", latency.Milliseconds())
349+
"latency_ms", latency.Milliseconds(),
350+
"arguments", evArgs, "fill_latency_ms", fillLatency.Milliseconds())
314351

315352
if fallbackApplied == types.ClassifierFallbackGenerate {
316353
return false
@@ -328,11 +365,7 @@ func classifierRespond(ctx context.Context, session *Session, conv *Conversation
328365
case chosen != nil:
329366
reply = chosen.Reply
330367
if chosen.Tool != nil {
331-
args := "{}"
332-
if len(chosen.Tool.Arguments) > 0 {
333-
args = string(chosen.Tool.Arguments)
334-
}
335-
toolCalls = []functions.FuncCallResults{{Name: chosen.Tool.Name, Arguments: args}}
368+
toolCalls = []functions.FuncCallResults{{Name: chosen.Tool.Name, Arguments: filledArgs}}
336369
}
337370
case fallbackApplied == types.ClassifierFallbackReply:
338371
reply = cc.Fallback.Reply
@@ -353,3 +386,153 @@ func classifierRespond(ctx context.Context, session *Session, conv *Conversation
353386
emitToolCallItems(ctx, session, conv, t, r, toolCalls, reply != "", toolTurn)
354387
return true
355388
}
389+
390+
// ---- slot filling (hybrid classify-then-complete) --------------------------
391+
//
392+
// A winning option whose tool declares slots gets its argument values from a
393+
// short constrained completion: the prompt is the exact scoring prompt (warm
394+
// in the backend's cache) continued by the chosen route JSON re-opened at the
395+
// first slot field, and a GBNF grammar pins everything except the slot
396+
// values. The generated tail is parsed back through the JSON object it
397+
// completes, and the values are spliced into the tool's argument template.
398+
399+
// gbnfLiteral renders s as a GBNF quoted literal.
400+
func gbnfLiteral(s string) string {
401+
r := strings.NewReplacer(`\`, `\\`, `"`, `\"`, "\n", `\n`)
402+
return `"` + r.Replace(s) + `"`
403+
}
404+
405+
// slotFillGrammar builds the grammar for the completion tail: first slot
406+
// value, then each further slot as a forced `, "<name>": ` literal plus its
407+
// value, then the closing brace.
408+
func slotFillGrammar(slots []types.ClassifierSlot) string {
409+
var root strings.Builder
410+
var rules strings.Builder
411+
needNum, needStr := false, false
412+
root.WriteString("root ::= ")
413+
for i := range slots {
414+
if i > 0 {
415+
root.WriteString(" " + gbnfLiteral(`, "`+slots[i].Name+`": `) + " ")
416+
}
417+
fmt.Fprintf(&root, "slot%d", i)
418+
fmt.Fprintf(&rules, "\nslot%d ::= ", i)
419+
switch slots[i].Type {
420+
case types.ClassifierSlotNumber:
421+
rules.WriteString("num")
422+
needNum = true
423+
case types.ClassifierSlotEnum:
424+
for vi, v := range slots[i].Values {
425+
if vi > 0 {
426+
rules.WriteString(" | ")
427+
}
428+
rules.WriteString(gbnfLiteral(`"` + v + `"`))
429+
}
430+
default: // string
431+
rules.WriteString("str")
432+
needStr = true
433+
}
434+
}
435+
root.WriteString(` "}"`)
436+
if needNum {
437+
rules.WriteString("\nnum ::= \"-\"? [0-9] [0-9]* (\".\" [0-9] [0-9]*)?")
438+
}
439+
if needStr {
440+
rules.WriteString("\nstr ::= \"\\\"\" [^\"\\\\\\n]* \"\\\"\"")
441+
}
442+
return root.String() + rules.String()
443+
}
444+
445+
// parseSlotValues closes the completed route JSON and extracts each slot's
446+
// value as the string form SpliceArguments expects.
447+
func parseSlotValues(chosenID, firstSlot, generated string, slots []types.ClassifierSlot) (map[string]string, error) {
448+
idJSON, _ := json.Marshal(chosenID)
449+
full := `{"route": ` + string(idJSON) + `, "` + firstSlot + `": ` + strings.TrimSpace(generated)
450+
if !strings.HasSuffix(strings.TrimSpace(generated), "}") {
451+
full += "}"
452+
}
453+
dec := json.NewDecoder(strings.NewReader(full))
454+
dec.UseNumber()
455+
var obj map[string]any
456+
if err := dec.Decode(&obj); err != nil {
457+
return nil, fmt.Errorf("classifier: slot completion %q does not parse: %w", generated, err)
458+
}
459+
values := make(map[string]string, len(slots))
460+
for i := range slots {
461+
v, ok := obj[slots[i].Name]
462+
if !ok {
463+
return nil, fmt.Errorf("classifier: slot completion missing %q", slots[i].Name)
464+
}
465+
switch tv := v.(type) {
466+
case json.Number:
467+
values[slots[i].Name] = tv.String()
468+
case string:
469+
values[slots[i].Name] = tv
470+
default:
471+
return nil, fmt.Errorf("classifier: slot %q has unexpected value type %T", slots[i].Name, v)
472+
}
473+
}
474+
return values, nil
475+
}
476+
477+
// fillChosenArguments resolves a winning option's tool arguments: canned
478+
// options pass through, slotted options run the fill completion with a
479+
// default-value recovery when inference fails. The error return is reserved
480+
// for unrecoverable failures (no complete default set).
481+
func fillChosenArguments(ctx context.Context, session *Session, cc *types.ClassifierConfig, msgs schema.Messages, chosen *types.ClassifierOption) (args string, latency time.Duration, err error) {
482+
if chosen.Tool == nil {
483+
return "", 0, nil
484+
}
485+
if len(chosen.Tool.Slots) == 0 {
486+
if len(chosen.Tool.Arguments) > 0 {
487+
return string(chosen.Tool.Arguments), 0, nil
488+
}
489+
return "{}", 0, nil
490+
}
491+
start := time.Now()
492+
args, err = session.ModelInterface.FillToolArguments(ctx, msgs, cc.Options, cc.Normalization, chosen)
493+
latency = time.Since(start)
494+
if err == nil {
495+
return args, latency, nil
496+
}
497+
xlog.Warn("realtime classifier: slot fill failed; trying slot defaults", "option", chosen.ID, "error", err)
498+
defaults, derr := chosen.Tool.SlotDefaults()
499+
if derr != nil {
500+
return "", latency, err
501+
}
502+
args, derr = chosen.Tool.SpliceArguments(defaults)
503+
if derr != nil {
504+
return "", latency, err
505+
}
506+
return args, latency, nil
507+
}
508+
509+
// classifierPolicyDescription renders an option's scoring description,
510+
// appending any slot declarations so the model both weighs the parameters
511+
// during scoring and knows how to fill them ("assume meters…") during the
512+
// slot completion — the hints ride the shared system prompt, costing no
513+
// extra per-turn tokens.
514+
func classifierPolicyDescription(o *types.ClassifierOption) string {
515+
if o.Tool == nil || len(o.Tool.Slots) == 0 {
516+
return o.Description
517+
}
518+
var b strings.Builder
519+
b.WriteString(o.Description)
520+
b.WriteString(" — route parameters:")
521+
for i := range o.Tool.Slots {
522+
s := &o.Tool.Slots[i]
523+
if i > 0 {
524+
b.WriteString(";")
525+
}
526+
b.WriteString(" " + s.Name)
527+
switch s.Type {
528+
case types.ClassifierSlotEnum:
529+
b.WriteString(" (one of: " + strings.Join(s.Values, ", ") + ")")
530+
default:
531+
b.WriteString(" (" + s.Type + ")")
532+
}
533+
if s.Hint != "" {
534+
b.WriteString(", " + s.Hint)
535+
}
536+
}
537+
return b.String()
538+
}

0 commit comments

Comments
 (0)