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:
2026-08-13 03:00:31 +04:00
parent 35c6ff5a71
commit 8015fdbb79
24 changed files with 2644 additions and 172 deletions
+50 -7
View File
@@ -342,7 +342,7 @@ func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string)
switch role {
case roleCancel:
h.clarifyStore.Delete(dialogueIDOf(ctx))
h.completeClarifyTop(ctx)
return clarifyCancelled, true
case roleSideQuery:
// He asked something of his own WITHOUT leaving the flow. The question
@@ -419,8 +419,6 @@ func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string)
if stillOpen(q.Missing, whenTextOf(q), merged) {
return h.reaskOrGiveUp(ctx, q, merged, text, taken), true
}
h.clarifyStore.Delete(dialogueIDOf(ctx))
// One gap filled is not the same as a complete request. askClarify parks
// only the first gap, because one question per turn is the rule, but a
// reminder wants both a subject and a time. "напомни" with neither used to
@@ -431,6 +429,7 @@ func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string)
if reply, asked := h.askRemainingGap(ctx, q, intent, merged); asked {
return reply, true
}
h.completeClarifyTop(ctx)
// Rebuild the decision as if it had routed cleanly, then run it down the
// normal path. Clarify is deliberately false and the intent is unchanged:
@@ -446,6 +445,27 @@ func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string)
return h.finishClarified(ctx, dec), true
}
// completeClarifyTop finishes only the active question. A nested question can
// sit above a flow that was suspended by a side request or repair; deleting the
// dialogue id here erased both. If one survives underneath, restart its clock
// from the moment it is spoken again and attach its question to this turn.
func (h *reactiveHandler) completeClarifyTop(ctx context.Context) {
if h.clarifyStore == nil {
return
}
_, resumed := h.clarifyStore.CompleteTop(dialogueIDOf(ctx), h.now())
if resumed == nil || len(resumed.Missing) == 0 {
return
}
question, ok := clarifyResumedFor(resumed.Missing[0])
if !ok {
return
}
if rt := turnRouteFrom(ctx); rt != nil {
rt.resume = question
}
}
// noteDropped records that the parked request was let go this turn, so runTurn
// can say it in front of whatever these words are answered with. Nothing to
// record outside runTurn — a unit test calling one resolver has no turn to glue
@@ -487,7 +507,7 @@ func (h *reactiveHandler) noteSuspended(ctx context.Context, q *dialogue.Pending
return
}
if !q.CanResume() {
h.clarifyStore.Delete(dialogueIDOf(ctx))
h.completeClarifyTop(ctx)
h.noteDropped(ctx)
log.Printf("voice: clarify — letting the question about %s go: %d asides in a row, %d rides in all", q.Missing[0], q.Suspends, q.Rides)
return
@@ -576,7 +596,7 @@ func (h *reactiveHandler) reaskOrGiveUp(ctx context.Context, q *dialogue.Pending
question, _ = h.questionFor(q.Missing[0], q.Attempts+1, whenTextOf(q), merged, taken)
}
if question == "" || !q.CanAsk() {
h.clarifyStore.Delete(dialogueIDOf(ctx))
h.completeClarifyTop(ctx)
log.Printf("voice: clarify — gave up on %v after %d question(s), answer was %q", q.Missing, q.Attempts, text)
return clarifyGaveUp
}
@@ -590,15 +610,38 @@ func (h *reactiveHandler) reaskOrGiveUp(ctx context.Context, q *dialogue.Pending
return question
}
// finishClarified runs a completed decision through the same steps a freshly
// routed one takes: remember the turn, act, then phrase.
// finishClarified completes a decision whose parked gaps were already checked
// by resolveClarifyAnswer. It still records the turn for a later correction;
// the old path made anything completed through dialogue uncorrectable (V-573).
func (h *reactiveHandler) finishClarified(ctx context.Context, dec router.Decision) string {
return h.finishRebuilt(ctx, dec, false)
}
// finishRepaired validates a decision rebuilt from an older utterance. Unlike
// resolveClarifyAnswer, repair has not passed the current slot gate, so it must
// ask about any missing argument before acting (V-573).
func (h *reactiveHandler) finishRepaired(ctx context.Context, dec router.Decision) string {
return h.finishRebuilt(ctx, dec, true)
}
func (h *reactiveHandler) finishRebuilt(ctx context.Context, dec router.Decision, validate bool) string {
if validate && (dec.Clarify || len(missingFor(dec)) > 0) {
if reply := h.hexisBeforeClarify(ctx, dec); reply != "" {
return reply
}
if question, asked := h.askClarify(ctx, dec); asked {
return question
}
}
if h.dialogueSessions != nil {
now := h.now()
prev := h.dialogueSessions.Get(dialogueIDOf(ctx), now)
dec = followUpMerge(prev, dec, now)
h.rememberTurn(ctx, prev, dec, now)
}
if !dec.Clarify {
h.recordTurn(dec.Utterance, dec.Intent)
}
reply := h.applyAction(ctx, dec)
if reply == "" {
reply = h.replier.Reply(ctx, dec)