Skip to content

Commit c6934c5

Browse files
committed
feat(realtime): splice filled slot values into classifier replies
A classifier option's spoken reply can now reference its tool's argument slots ("Going forward {{distance}} {{units}}."): the values inferred by the slot-fill completion — or the recovery defaults — are spliced into the reply as plain text before it is emitted, so what the assistant says confirms what it actually inferred. Placeholders without a value stay literal, and options without slots are untouched. FillToolArguments now returns the raw slot values alongside the spliced arguments JSON to make the reply templating possible. Assisted-by: Claude:claude-fable-5 Signed-off-by: Richard Palethorpe <io@richiejp.com>
1 parent 7450bbe commit c6934c5

9 files changed

Lines changed: 127 additions & 39 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}. 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.",
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 (and, optionally, the reply) 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/http/endpoints/openai/realtime.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -283,9 +283,10 @@ type Model interface {
283283
// FillToolArguments completes the chosen option's argument slots with a
284284
// short grammar-constrained completion that continues the exact scoring
285285
// 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)
286+
// spliced tool-arguments JSON plus the raw slot values (for reply
287+
// templating) — the hybrid between prefill-only classification and full
288+
// generation.
289+
FillToolArguments(ctx context.Context, messages schema.Messages, options []types.ClassifierOption, normalization string, chosen *types.ClassifierOption) (string, map[string]string, error)
289290
PredictConfig() *config.ModelConfig
290291
// Warmup eagerly loads the pipeline's sub-model backends into memory so the
291292
// first realtime turn doesn't pay each backend's cold-start load cost. Loads

core/http/endpoints/openai/realtime_classifier.go

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -306,10 +306,11 @@ func classifierRespond(ctx context.Context, session *Session, conv *Conversation
306306
// (error and no complete default set) is handled like a scoring
307307
// failure.
308308
filledArgs := ""
309+
var fillValues map[string]string
309310
var fillLatency time.Duration
310311
if chosen != nil {
311312
var ferr error
312-
filledArgs, fillLatency, ferr = fillChosenArguments(ctx, session, cc, msgs, chosen)
313+
filledArgs, fillValues, fillLatency, ferr = fillChosenArguments(ctx, session, cc, msgs, chosen)
313314
if ferr != nil {
314315
if cc.FallbackMode() == types.ClassifierFallbackGenerate {
315316
xlog.Warn("realtime classifier: slot fill failed; falling back to generation", "error", ferr)
@@ -363,7 +364,10 @@ func classifierRespond(ctx context.Context, session *Session, conv *Conversation
363364
var toolCalls []functions.FuncCallResults
364365
switch {
365366
case chosen != nil:
366-
reply = chosen.Reply
367+
// The reply may template the filled slot values ("Going forward
368+
// {{distance}} {{units}}.") so what is spoken confirms what was
369+
// actually inferred.
370+
reply = chosen.SpliceReply(fillValues)
367371
if chosen.Tool != nil {
368372
toolCalls = []functions.FuncCallResults{{Name: chosen.Tool.Name, Arguments: filledArgs}}
369373
}
@@ -476,34 +480,35 @@ func parseSlotValues(chosenID, firstSlot, generated string, slots []types.Classi
476480

477481
// fillChosenArguments resolves a winning option's tool arguments: canned
478482
// 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) {
483+
// default-value recovery when inference fails. The slot values ride along
484+
// so the caller can splice them into the spoken reply too. The error return
485+
// is reserved for unrecoverable failures (no complete default set).
486+
func fillChosenArguments(ctx context.Context, session *Session, cc *types.ClassifierConfig, msgs schema.Messages, chosen *types.ClassifierOption) (args string, values map[string]string, latency time.Duration, err error) {
482487
if chosen.Tool == nil {
483-
return "", 0, nil
488+
return "", nil, 0, nil
484489
}
485490
if len(chosen.Tool.Slots) == 0 {
486491
if len(chosen.Tool.Arguments) > 0 {
487-
return string(chosen.Tool.Arguments), 0, nil
492+
return string(chosen.Tool.Arguments), nil, 0, nil
488493
}
489-
return "{}", 0, nil
494+
return "{}", nil, 0, nil
490495
}
491496
start := time.Now()
492-
args, err = session.ModelInterface.FillToolArguments(ctx, msgs, cc.Options, cc.Normalization, chosen)
497+
args, values, err = session.ModelInterface.FillToolArguments(ctx, msgs, cc.Options, cc.Normalization, chosen)
493498
latency = time.Since(start)
494499
if err == nil {
495-
return args, latency, nil
500+
return args, values, latency, nil
496501
}
497502
xlog.Warn("realtime classifier: slot fill failed; trying slot defaults", "option", chosen.ID, "error", err)
498503
defaults, derr := chosen.Tool.SlotDefaults()
499504
if derr != nil {
500-
return "", latency, err
505+
return "", nil, latency, err
501506
}
502507
args, derr = chosen.Tool.SpliceArguments(defaults)
503508
if derr != nil {
504-
return "", latency, err
509+
return "", nil, latency, err
505510
}
506-
return args, latency, nil
511+
return args, defaults, latency, nil
507512
}
508513

509514
// classifierPolicyDescription renders an option's scoring description,

core/http/endpoints/openai/realtime_classifier_test.go

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,18 @@ func classifierResultEvents(t *fakeTransport) []types.ClassifierResultEvent {
5252
return out
5353
}
5454

55+
// replyTexts collects the assistant reply text of every completed output
56+
// item — what a classifier response actually "spoke".
57+
func replyTexts(t *fakeTransport) []string {
58+
var out []string
59+
for _, e := range t.events {
60+
if ev, ok := e.(types.ResponseOutputTextDoneEvent); ok {
61+
out = append(out, ev.Text)
62+
}
63+
}
64+
return out
65+
}
66+
5567
var _ = Describe("classifierConfigFromPipeline", func() {
5668
It("returns nil for an absent block", func() {
5769
cc, err := classifierConfigFromPipeline(nil)
@@ -503,7 +515,7 @@ func slottedTestConfig(threshold float64, fallback *types.ClassifierFallback, de
503515
{
504516
ID: "up",
505517
Description: "the user asks the drone to fly up",
506-
Reply: "Going up.",
518+
Reply: "Going up {{distance}} {{units}}.",
507519
Tool: &types.ClassifierTool{
508520
Name: "move",
509521
Arguments: json.RawMessage(`{"direction":"up","distance":"{{distance}}","units":"{{units}}"}`),
@@ -579,7 +591,8 @@ var _ = Describe("classifierRespond slot filling", func() {
579591
It("emits the filled tool arguments and reports them in the result event", func() {
580592
m := &fakeModel{
581593
classifyScores: []router.LabelScore{{Label: "up", Score: 0.9}, {Label: "greeting", Score: 0.1}},
582-
fillArgs: `{"direction":"up","distance":3,"units":"m"}`,
594+
fillArgs: `{"direction":"up","distance":3,"units":"meters"}`,
595+
fillValues: map[string]string{"distance": "3", "units": "meters"},
583596
}
584597
session := classifierTestSession(m)
585598
conv := &Conversation{}
@@ -595,15 +608,32 @@ var _ = Describe("classifierRespond slot filling", func() {
595608
results := classifierResultEvents(t)
596609
Expect(results).To(HaveLen(1))
597610
Expect(results[0].ChosenID).To(Equal("up"))
598-
Expect(results[0].Arguments).To(MatchJSON(`{"direction":"up","distance":3,"units":"m"}`))
611+
Expect(results[0].Arguments).To(MatchJSON(`{"direction":"up","distance":3,"units":"meters"}`))
599612

600613
var fcArgs string
601614
for _, e := range t.events {
602615
if done, ok := e.(types.ResponseFunctionCallArgumentsDoneEvent); ok {
603616
fcArgs = done.Arguments
604617
}
605618
}
606-
Expect(fcArgs).To(MatchJSON(`{"direction":"up","distance":3,"units":"m"}`))
619+
Expect(fcArgs).To(MatchJSON(`{"direction":"up","distance":3,"units":"meters"}`))
620+
})
621+
622+
It("splices the filled values into a templated reply", func() {
623+
m := &fakeModel{
624+
classifyScores: []router.LabelScore{{Label: "up", Score: 0.9}, {Label: "greeting", Score: 0.1}},
625+
fillArgs: `{"direction":"up","distance":3,"units":"meters"}`,
626+
fillValues: map[string]string{"distance": "3", "units": "meters"},
627+
}
628+
session := classifierTestSession(m)
629+
conv := &Conversation{}
630+
t := &fakeTransport{}
631+
r := &liveResponse{id: "resp-slot-reply"}
632+
633+
handled := classifierRespond(context.Background(), session, conv, t, r, slottedTestConfig(0.35, nil, false), classifierTestHistory, nil, 0)
634+
635+
Expect(handled).To(BeTrue())
636+
Expect(replyTexts(t)).To(ConsistOf("Going up 3 meters."))
607637
})
608638

609639
It("recovers with slot defaults when filling fails", func() {
@@ -626,6 +656,7 @@ var _ = Describe("classifierRespond slot filling", func() {
626656
}
627657
}
628658
Expect(fcArgs).To(MatchJSON(`{"direction":"up","distance":1,"units":"m"}`))
659+
Expect(replyTexts(t)).To(ConsistOf("Going up 1 m."), "the default-recovery reply confirms the defaults")
629660
})
630661

631662
It("fails the response when filling fails and a slot has no default", func() {

core/http/endpoints/openai/realtime_doubles_test.go

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -109,10 +109,12 @@ type fakeModel struct {
109109
classifyCalls int
110110
lastClassifyOptions []types.ClassifierOption
111111

112-
// FillToolArguments scripting: fillArgs is returned verbatim; fillErr
113-
// fails the call. fillCalls counts invocations and lastFillChosen
114-
// records which option's slots the handler asked to fill.
112+
// FillToolArguments scripting: fillArgs/fillValues are returned
113+
// verbatim; fillErr fails the call. fillCalls counts invocations and
114+
// lastFillChosen records which option's slots the handler asked to
115+
// fill.
115116
fillArgs string
117+
fillValues map[string]string
116118
fillErr error
117119
fillCalls int
118120
lastFillChosen *types.ClassifierOption
@@ -127,13 +129,13 @@ type fakeModel struct {
127129
lastMessages schema.Messages
128130
}
129131

130-
func (m *fakeModel) FillToolArguments(_ context.Context, msgs schema.Messages, options []types.ClassifierOption, _ string, chosen *types.ClassifierOption) (string, error) {
132+
func (m *fakeModel) FillToolArguments(_ context.Context, msgs schema.Messages, options []types.ClassifierOption, _ string, chosen *types.ClassifierOption) (string, map[string]string, error) {
131133
m.fillCalls++
132134
m.lastFillChosen = chosen
133135
if m.fillErr != nil {
134-
return "", m.fillErr
136+
return "", nil, m.fillErr
135137
}
136-
return m.fillArgs, nil
138+
return m.fillArgs, m.fillValues, nil
137139
}
138140

139141
func (m *fakeModel) ClassifyTurn(_ context.Context, msgs schema.Messages, options []types.ClassifierOption, _ string) ([]router.LabelScore, error) {

core/http/endpoints/openai/realtime_model.go

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -110,8 +110,8 @@ func (m *transcriptOnlyModel) ClassifyTurn(ctx context.Context, messages schema.
110110
return nil, fmt.Errorf("classifier mode not supported in transcript-only mode")
111111
}
112112

113-
func (m *transcriptOnlyModel) FillToolArguments(ctx context.Context, messages schema.Messages, options []types.ClassifierOption, normalization string, chosen *types.ClassifierOption) (string, error) {
114-
return "", fmt.Errorf("classifier mode not supported in transcript-only mode")
113+
func (m *transcriptOnlyModel) FillToolArguments(ctx context.Context, messages schema.Messages, options []types.ClassifierOption, normalization string, chosen *types.ClassifierOption) (string, map[string]string, error) {
114+
return "", nil, fmt.Errorf("classifier mode not supported in transcript-only mode")
115115
}
116116

117117
func (m *transcriptOnlyModel) TTS(ctx context.Context, text, voice, language string) (string, *proto.Result, error) {
@@ -499,18 +499,18 @@ func (m *wrappedModel) ClassifyTurn(ctx context.Context, messages schema.Message
499499
// re-opened at its first slot, with a grammar pinning everything but the
500500
// slot values. Deterministic (temperature 0), a couple dozen tokens at
501501
// most.
502-
func (m *wrappedModel) FillToolArguments(ctx context.Context, messages schema.Messages, options []types.ClassifierOption, normalization string, chosen *types.ClassifierOption) (string, error) {
502+
func (m *wrappedModel) FillToolArguments(ctx context.Context, messages schema.Messages, options []types.ClassifierOption, normalization string, chosen *types.ClassifierOption) (string, map[string]string, error) {
503503
if chosen == nil || chosen.Tool == nil || len(chosen.Tool.Slots) == 0 {
504-
return "", fmt.Errorf("classifier: option has no slots to fill")
504+
return "", nil, fmt.Errorf("classifier: option has no slots to fill")
505505
}
506506
slots := chosen.Tool.Slots
507507
classifier, err := m.classifierFor(options, normalization)
508508
if err != nil {
509-
return "", err
509+
return "", nil, err
510510
}
511511
prompt, err := classifier.SlotFillPrompt(classifierProbe(messages), chosen.ID, slots[0].Name)
512512
if err != nil {
513-
return "", err
513+
return "", nil, err
514514
}
515515

516516
// The scoring config, narrowed to a deterministic constrained
@@ -519,7 +519,7 @@ func (m *wrappedModel) FillToolArguments(ctx context.Context, messages schema.Me
519519
// score].
520520
cfg := *m.scoreConfig()
521521
if !cfg.HasUsecases(config.FLAG_COMPLETION) {
522-
return "", fmt.Errorf("classifier: slot filling requires completion in the scoring model's known_usecases")
522+
return "", nil, fmt.Errorf("classifier: slot filling requires completion in the scoring model's known_usecases")
523523
}
524524
cfg.Grammar = slotFillGrammar(slots)
525525
maxTokens := 16 + 16*len(slots)
@@ -529,17 +529,21 @@ func (m *wrappedModel) FillToolArguments(ctx context.Context, messages schema.Me
529529

530530
fn, err := backend.ModelInference(ctx, prompt, nil, nil, nil, nil, m.modelLoader, &cfg, m.confLoader, m.appConfig, nil, "", "", nil, nil, nil, nil)
531531
if err != nil {
532-
return "", fmt.Errorf("classifier: slot fill inference: %w", err)
532+
return "", nil, fmt.Errorf("classifier: slot fill inference: %w", err)
533533
}
534534
resp, err := fn()
535535
if err != nil {
536-
return "", fmt.Errorf("classifier: slot fill inference: %w", err)
536+
return "", nil, fmt.Errorf("classifier: slot fill inference: %w", err)
537537
}
538538
values, err := parseSlotValues(chosen.ID, slots[0].Name, resp.Response, slots)
539539
if err != nil {
540-
return "", err
540+
return "", nil, err
541541
}
542-
return chosen.Tool.SpliceArguments(values)
542+
args, err := chosen.Tool.SpliceArguments(values)
543+
if err != nil {
544+
return "", nil, err
545+
}
546+
return args, values, nil
543547
}
544548

545549
func (m *wrappedModel) Warmup(ctx context.Context) error {

core/http/endpoints/openai/types/classifier.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,26 @@ func (t *ClassifierTool) SpliceArguments(values map[string]string) (string, erro
212212
return args, nil
213213
}
214214

215+
// SpliceReply fills "{{name}}" placeholders in the option's spoken reply
216+
// with the same slot values that filled the tool arguments, as plain text
217+
// ("Going {{distance}} {{units}}." → "Going 3 meters."), so the reply can
218+
// confirm what was actually inferred. Values are optional in the reply:
219+
// placeholders without a value stay literal, and options without slots (or
220+
// a nil value set) return the reply verbatim.
221+
func (o *ClassifierOption) SpliceReply(values map[string]string) string {
222+
reply := o.Reply
223+
if o.Tool == nil || len(values) == 0 {
224+
return reply
225+
}
226+
for i := range o.Tool.Slots {
227+
s := &o.Tool.Slots[i]
228+
if v, ok := values[s.Name]; ok && v != "" {
229+
reply = strings.ReplaceAll(reply, slotPlaceholder(s.Name), v)
230+
}
231+
}
232+
return reply
233+
}
234+
215235
// SlotDefaults returns every slot's default value, or an error naming the
216236
// first slot without one — the fill-failure path either recovers with a
217237
// complete default set or not at all.

core/http/endpoints/openai/types/classifier_test.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,4 +267,27 @@ var _ = Describe("ClassifierTool slots", func() {
267267
Expect(err).To(MatchError(ContainSubstring(`"distance"`)))
268268
})
269269
})
270+
271+
Describe("SpliceReply", func() {
272+
option := func(reply string, t *types.ClassifierTool) *types.ClassifierOption {
273+
return &types.ClassifierOption{ID: "up", Description: "d", Reply: reply, Tool: t}
274+
}
275+
276+
It("substitutes slot values as plain text", func() {
277+
o := option("Going up {{distance}} {{units}}.", tool(numberSlot, enumSlot))
278+
Expect(o.SpliceReply(map[string]string{"distance": "3.5", "units": "m"})).To(Equal("Going up 3.5 m."))
279+
})
280+
281+
It("leaves placeholders without a value literal", func() {
282+
o := option("Going up {{distance}} {{units}}.", tool(numberSlot, enumSlot))
283+
Expect(o.SpliceReply(map[string]string{"distance": "3"})).To(Equal("Going up 3 {{units}}."))
284+
})
285+
286+
It("returns the reply verbatim without slots or values", func() {
287+
o := option("Going up {{distance}}.", nil)
288+
Expect(o.SpliceReply(map[string]string{"distance": "3"})).To(Equal("Going up {{distance}}."))
289+
slotted := option("Going up {{distance}}.", tool(numberSlot))
290+
Expect(slotted.SpliceReply(nil)).To(Equal("Going up {{distance}}."))
291+
})
292+
})
270293
})

docs/content/features/openai-realtime.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,9 @@ tool:
242242
hint: assume m when the user gives no units
243243
```
244244

245-
Slot declarations (and hints) are appended to the option's description in the shared system prompt, so they also inform scoring and cost no extra per-turn tokens. The `localai.classifier.result` event carries the final `arguments` and a `fill_latency_ms`. On an inference failure the slots' defaults apply; if any slot lacks a default the response fails (or falls through to generation with `fallback.mode: generate`). Slot filling requires `completion` in the scoring model's `known_usecases` alongside `score`.
245+
The option's spoken `reply` can reference the same placeholders — `reply: "Going forward {{distance}} {{units}}."` — and the filled values are spliced in as plain text before the reply is emitted, so what the assistant says confirms what it inferred. Reply placeholders are optional (one that names no slot stays literal).
246+
247+
Slot declarations (and hints) are appended to the option's description in the shared system prompt, so they also inform scoring and cost no extra per-turn tokens. The `localai.classifier.result` event carries the final `arguments` and a `fill_latency_ms`. On an inference failure the slots' defaults apply (and template the reply); if any slot lacks a default the response fails (or falls through to generation with `fallback.mode: generate`). Slot filling requires `completion` in the scoring model's `known_usecases` alongside `score`.
246248

247249
The concrete scoring model must declare `score` in `known_usecases`. A single llama.cpp model can serve ordinary inference and classification concurrently by declaring multiple use cases, for example `known_usecases: [chat, completion, score]`; LocalAI reserves the scoring slots only when `score` is present. Scoring also requires the unified KV cache, which is enabled by default, so a score-enabled model cannot set `kv_unified:false`.
248250

0 commit comments

Comments
 (0)