Harden semantic boundaries and repair dialogue state
Replace nearest-neighbour personal routing with a frozen class-balanced linear head measured on historical, stratified, cross-validation, holdout, and fresh challenge gates (V-702). Close the four repair handoff holes, preserve nested clarification flows, and route Russian possession statements through structural grammar rather than lexical exceptions (V-573). Owner explicitly requested direct commits to master.
This commit is contained in:
@@ -285,6 +285,37 @@ func (s *ClarifyStore) Pop(id string, now time.Time) *PendingQuestion {
|
||||
return q
|
||||
}
|
||||
|
||||
// CompleteTop removes the live question being completed and returns the flow
|
||||
// that was suspended underneath it, if any. The returned question stays parked;
|
||||
// callers use it only to make that surviving state audible again. A stale top
|
||||
// expires the whole stack, matching Peek and Pop.
|
||||
func (s *ClarifyStore) CompleteTop(id string, now time.Time) (completed, resumed *PendingQuestion) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
stack := s.stacks[id]
|
||||
if len(stack) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
completed = stack[len(stack)-1]
|
||||
if completed.IsExpired(now) {
|
||||
delete(s.stacks, id)
|
||||
return nil, nil
|
||||
}
|
||||
if len(stack) == 1 {
|
||||
delete(s.stacks, id)
|
||||
return completed, nil
|
||||
}
|
||||
stack = stack[:len(stack)-1]
|
||||
s.stacks[id] = stack
|
||||
resumed = stack[len(stack)-1]
|
||||
// The surviving flow is spoken again now, so its answer window starts now.
|
||||
// A completed nested request also ends the run of asides around it; Rides is
|
||||
// deliberately retained as the lifetime bound for this flow.
|
||||
resumed.Asked = now
|
||||
resumed.Suspends = 0
|
||||
return completed, resumed
|
||||
}
|
||||
|
||||
// Depth — how many questions are parked for this id, expired ones included.
|
||||
// Diagnostic; the arbiter in V-560 reads it to know it is inside a flow.
|
||||
func (s *ClarifyStore) Depth(id string) int {
|
||||
|
||||
@@ -62,6 +62,22 @@ func TestStackPoppedEntryIsGone(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteTopKeepsSuspendedFlow(t *testing.T) {
|
||||
s := NewClarifyStore(time.Minute)
|
||||
bottom := parked("напомни", pendingBase)
|
||||
top := parked("погода", pendingBase)
|
||||
s.Push("voice", bottom)
|
||||
s.Push("voice", top)
|
||||
|
||||
completed, resumed := s.CompleteTop("voice", pendingBase)
|
||||
if completed != top || resumed != bottom {
|
||||
t.Fatalf("completed=%p resumed=%p, want top=%p bottom=%p", completed, resumed, top, bottom)
|
||||
}
|
||||
if got := s.Depth("voice"); got != 1 {
|
||||
t.Fatalf("depth=%d, want one surviving flow", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Past MaxStackDepth the oldest entry comes back to the caller instead of
|
||||
// vanishing — it is the caller's job to say it was dropped.
|
||||
func TestStackDepthBoundReturnsTheDroppedEntry(t *testing.T) {
|
||||
|
||||
@@ -53,20 +53,17 @@ func TestStage0Contention(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// matchingGrammars — every grammar whose pattern matches AND whose Build
|
||||
// accepts, in the daemon's order. Route stops at the first; this does not.
|
||||
// matchingGrammars — every regexp or structural grammar that accepts, in the
|
||||
// daemon's order. Route stops at the first; this does not.
|
||||
func matchingGrammars(grammars []router.Grammar, utterance string) []string {
|
||||
stripped, hadWake := router.StripWakeToken(utterance)
|
||||
var out []string
|
||||
for _, g := range grammars {
|
||||
m := g.Pattern.FindStringSubmatch(utterance)
|
||||
if m == nil && hadWake {
|
||||
m = g.Pattern.FindStringSubmatch(stripped)
|
||||
_, matched, ok := g.Evaluate(utterance)
|
||||
if !matched && hadWake {
|
||||
_, matched, ok = g.Evaluate(stripped)
|
||||
}
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
if _, ok := g.Build(m); !ok {
|
||||
if !matched || !ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, g.Name)
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/kami/maven/internal/lexicon"
|
||||
)
|
||||
|
||||
var possessionQuestionWords = func() map[string]bool {
|
||||
words := make(map[string]bool)
|
||||
for _, word := range append(lexicon.Interrogatives(), lexicon.NarrativeRequests()...) {
|
||||
words[word] = true
|
||||
}
|
||||
return words
|
||||
}()
|
||||
|
||||
// PossessionStatementGrammar recognises the Russian possessive construction
|
||||
// “у меня …” as a statement to remember. Russian has no present-tense “have”:
|
||||
// the preposition and genitive pronoun are the grammatical predicate, so a
|
||||
// sentence such as “у меня новый ноутбук” contains no verb for a generic
|
||||
// sentence parser to anchor on. Both the hash floor and the deployed routing
|
||||
// heads have measured this exact shape as a query and would try to answer it
|
||||
// instead of recording it.
|
||||
//
|
||||
// This is a token grammar, not a phrase regexp or a noun list. The two-token
|
||||
// frame is a closed grammatical construction and the remainder stays open.
|
||||
// Questions and explicit capture/reminder/narrative requests retain their
|
||||
// narrower routes; only a plain declaration is claimed.
|
||||
func PossessionStatementGrammar() Grammar {
|
||||
return Grammar{Name: "possession-statement", Decide: possessionStatementDecision}
|
||||
}
|
||||
|
||||
func possessionStatementDecision(utterance string) (Decision, bool) {
|
||||
tokens := planTokens(utterance)
|
||||
if len(tokens) < 3 || tokens[0] != "у" || tokens[1] != "меня" {
|
||||
return Decision{}, false
|
||||
}
|
||||
if possessionQuestionShaped(utterance) || CarriesCaptureVerb(utterance) || carriesReminderVerbTokens(tokens) {
|
||||
return Decision{}, false
|
||||
}
|
||||
return Decision{
|
||||
Stage: 0,
|
||||
Intent: IntentNote,
|
||||
Confidence: 1,
|
||||
Slots: Slots{Text: utterance},
|
||||
}, true
|
||||
}
|
||||
|
||||
// possessionQuestionShaped is the question half of this grammar over Russian
|
||||
// word structure. IsQuestionShaped intentionally tokenises punctuation away,
|
||||
// which makes the interrogative root in indefinite pronouns look like a
|
||||
// question: “что-то сломалось”, “кто-нибудь пришёл”, “когда-то работало”. Here
|
||||
// that would defeat the open possession statement this grammar exists for.
|
||||
//
|
||||
// Hyphenated indefinite forms are a closed grammatical construction, not a
|
||||
// phrase list: interrogative+{то, либо, нибудь}, or кое+interrogative. Every
|
||||
// other exact interrogative/narrative token remains a question, as does a
|
||||
// question mark. No noun or payload vocabulary is involved.
|
||||
func possessionQuestionShaped(text string) bool {
|
||||
if strings.HasSuffix(strings.TrimSpace(text), "?") {
|
||||
return true
|
||||
}
|
||||
for _, lexeme := range possessionLexemes(text) {
|
||||
if possessionQuestionWords[lexeme] {
|
||||
return true
|
||||
}
|
||||
parts := strings.Split(lexeme, "-")
|
||||
if indefiniteQuestionCompound(parts) {
|
||||
continue
|
||||
}
|
||||
for _, part := range parts {
|
||||
if possessionQuestionWords[part] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func possessionLexemes(text string) []string {
|
||||
return strings.FieldsFunc(strings.ToLower(text), func(r rune) bool {
|
||||
return !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '-'
|
||||
})
|
||||
}
|
||||
|
||||
func indefiniteQuestionCompound(parts []string) bool {
|
||||
if len(parts) != 2 {
|
||||
return false
|
||||
}
|
||||
if parts[0] == "кое" && possessionQuestionWords[parts[1]] {
|
||||
return true
|
||||
}
|
||||
if !possessionQuestionWords[parts[0]] {
|
||||
return false
|
||||
}
|
||||
switch parts[1] {
|
||||
case "то", "либо", "нибудь":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func carriesReminderVerbTokens(tokens []string) bool {
|
||||
for _, verb := range lexicon.ReminderVerbs() {
|
||||
if hasTok(tokens, verb) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPossessionStatementGrammarClaimsOpenRemainder(t *testing.T) {
|
||||
g := PossessionStatementGrammar()
|
||||
for _, utterance := range []string{
|
||||
"у меня новый ноутбук",
|
||||
"У меня сломался велосипед.",
|
||||
"у меня после отпуска другая работа",
|
||||
"у меня что-то сломалось",
|
||||
"у меня кто-нибудь дома",
|
||||
"у меня когда-то был велосипед",
|
||||
"у меня кое-что изменилось",
|
||||
} {
|
||||
d, matched, ok := g.Evaluate(utterance)
|
||||
if !matched || !ok {
|
||||
t.Errorf("%q was not claimed", utterance)
|
||||
continue
|
||||
}
|
||||
if d.Intent != IntentNote || d.Slots.Text != utterance || d.Stage != 0 {
|
||||
t.Errorf("%q => %+v, want an anchored note preserving the utterance", utterance, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPossessionStatementGrammarDefersNarrowerRequests(t *testing.T) {
|
||||
g := PossessionStatementGrammar()
|
||||
for _, utterance := range []string{
|
||||
"что у меня сегодня?",
|
||||
"у меня когда встреча?",
|
||||
"у меня когда встреча",
|
||||
"у меня новый ноутбук?",
|
||||
"у меня новый ноутбук, запиши это",
|
||||
"у меня новый ноутбук, напомни настроить его",
|
||||
"расскажи, что у меня в календаре",
|
||||
"у тебя новый ноутбук",
|
||||
} {
|
||||
if _, _, ok := g.Evaluate(utterance); ok {
|
||||
t.Errorf("%q was claimed as a plain possession statement", utterance)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPossessionStatementBeatsStatisticalQueryGuess(t *testing.T) {
|
||||
emb := NewHashEmbedder(64)
|
||||
classifier := NewClassifier(emb)
|
||||
if err := classifier.AddExample(context.Background(), IntentQuery, "у меня новый ноутбук"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := New(Config{
|
||||
Grammars: []Grammar{PossessionStatementGrammar()},
|
||||
Classifier: classifier,
|
||||
Threshold: 0,
|
||||
})
|
||||
d, err := r.Route(context.Background(), "у меня новый ноутбук", refNow())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d.Intent != IntentNote || d.Stage != 0 {
|
||||
t.Fatalf("route = %+v, want the structural statement grammar before the statistical query guess", d)
|
||||
}
|
||||
}
|
||||
@@ -82,14 +82,13 @@ func (r *Router) Route(ctx context.Context, utterance string, now time.Time) (De
|
||||
// rule from one whose pattern never fired.
|
||||
var declinedBuild map[int]bool
|
||||
for i, g := range r.grammars {
|
||||
m := g.Pattern.FindStringSubmatch(utterance)
|
||||
if m == nil && hadWake {
|
||||
m = g.Pattern.FindStringSubmatch(stripped)
|
||||
d, matched, ok := g.Evaluate(utterance)
|
||||
if !matched && hadWake {
|
||||
d, matched, ok = g.Evaluate(stripped)
|
||||
}
|
||||
if m == nil {
|
||||
if !matched {
|
||||
continue
|
||||
}
|
||||
d, ok := g.Build(m)
|
||||
if !ok {
|
||||
if declinedBuild == nil {
|
||||
declinedBuild = map[int]bool{}
|
||||
|
||||
@@ -21,6 +21,31 @@ type Grammar struct {
|
||||
Name string
|
||||
Pattern *regexp.Regexp // matched against the raw utterance
|
||||
Build func(match []string) (Decision, bool)
|
||||
// Decide is the non-regexp form for a grammar whose evidence is structural
|
||||
// rather than textual. Exactly one of Decide or Pattern+Build is set. It
|
||||
// keeps token/grammar parsers first-class instead of wrapping them in a
|
||||
// catch-all regexp merely to fit this type.
|
||||
Decide func(utterance string) (Decision, bool)
|
||||
}
|
||||
|
||||
// Evaluate applies one grammar. matched distinguishes a regexp whose outer
|
||||
// shape matched but whose Build declined from a rule that never matched; the
|
||||
// decision trace uses that distinction. A structural Decide has no weaker
|
||||
// outer pattern, so accepted is also its matched result.
|
||||
func (g Grammar) Evaluate(utterance string) (d Decision, matched, accepted bool) {
|
||||
if g.Decide != nil {
|
||||
d, accepted = g.Decide(utterance)
|
||||
return d, accepted, accepted
|
||||
}
|
||||
if g.Pattern == nil || g.Build == nil {
|
||||
return Decision{}, false, false
|
||||
}
|
||||
m := g.Pattern.FindStringSubmatch(utterance)
|
||||
if m == nil {
|
||||
return Decision{}, false, false
|
||||
}
|
||||
d, accepted = g.Build(m)
|
||||
return d, true, accepted
|
||||
}
|
||||
|
||||
// wakeWordAct — "maven, restart nginx" / "maven restart nginx" → the remainder
|
||||
|
||||
@@ -61,5 +61,10 @@ func StageZeroGrammars(acts ActMatcher) []Grammar {
|
||||
// last overall because it matches on the first word alone: "расскажи про
|
||||
// X" is a world question the model called a fact (Vikunja #498).
|
||||
grammars = append(grammars, NarrativeQueryGrammars()...)
|
||||
// Last because it is deliberately broad over the open remainder of a
|
||||
// grammatical declaration. Every explicit question, command, capture and
|
||||
// narrative request above gets first refusal; this catches the Russian
|
||||
// present-tense possession statement the statistical floors call a query.
|
||||
grammars = append(grammars, PossessionStatementGrammar())
|
||||
return grammars
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user