Merge task/467 so the sweep tail can reach the three mechanisms (V-528)

attentionq.go, repair.go and internal/router/complaint.go carry the last
hand-written Russian patterns of the V-522 sweep, and they live on task/467.
internal/lexicon, internal/morph and cmd/mavend/topics.go live here. One of
the two had to move.

Four conflicts, and one of them is a real collision rather than a mechanical
one. Both branches wrote the narrative stage 0 rule. This side had
NarrativeQueryGrammars, plural, with the rest-of-day rule beside it and the
verb alternation built from the lexicon; task/467 had NarrativeQueryGrammar,
singular, which extracts the topic into Slots.Text, refuses a bare "расскажи",
and excludes the shapes that are chat ("расскажи о себе", "историю на ночь").
Resolved by keeping this side's container and this side's lexicon-built
pattern, and taking every behaviour only the other side had: the topic slot,
the empty-topic refusal, chatNarrativeTopics, and its wiring position after
TaskCaptureGrammar so "запиши" still beats "расскажи".

The rest: queryFeeds keeps task/467's conditional claim (V-474 supersedes the
unconditional one), rank.go keeps Spoken and drops pluralTasksRU because
say.CountWord is the one copy of Russian count agreement, and vendor/ was
re-vendored — the merged modules.txt claimed replaces for nexus and praxis
that neither go.mod has.

Routing fixture 58/82, unchanged from both sides.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 19:00:32 +04:00
62 changed files with 2658 additions and 80 deletions
+76 -11
View File
@@ -199,6 +199,20 @@ func AgendaQueryGrammars() []Grammar {
Pattern: regexp.MustCompile(`(?i)(^|\s)(план|дел)[а-я]*\s+(на|в|во|по)\s+` + dayWordPattern + `(\s|[?!.]|$)`),
Build: agendaQueryBuild,
},
{
// "что дальше?" — the rest of the day, with no possessive and no
// plan word for the rules above to anchor on, so neither claimed
// it and the model called it a fact (Vikunja #498). The predicate
// for the same utterance already exists as IsRestOfDayQuery, one
// layer down in the query chain; this is what gets the turn there.
//
// "и что там дальше" and "что потом дальше" are the same question,
// and "what's next" splits into two tokens, hence the optional
// middles rather than plain adjacency.
Name: "rest-of-day-query",
Pattern: regexp.MustCompile(`(?i)^\s*(и\s+)?(что|чего|what'?s?)\s+(там\s+|ещё\s+|еще\s+|потом\s+|у\s+меня\s+)?(дальше|next)(\s|[?!.]|$)`),
Build: agendaQueryBuild,
},
{
// A named event with no calendar word at all: "когда планёрка?",
// "во сколько созвон". He is asking when something on his calendar
@@ -211,6 +225,11 @@ func AgendaQueryGrammars() []Grammar {
}
}
// chatNarrativeTopics — the things "расскажи X" asks for that are not
// questions about the world. She is being asked to entertain or to describe
// herself, and the query chain has no source for either.
var chatNarrativeTopics = regexp.MustCompile(`(?i)(анекдот|шутк|сказк|истори[юи]\s+на\s+ночь|о\s+себе|про\s+себя|о\s+нас|про\s+нас)`)
// NarrativeQueryGrammars — stage-0 grammars for the two question shapes that
// carry no question mark and no interrogative, and so reached the resident
// model with nothing deterministic in front of them (Vikunja #498).
@@ -255,15 +274,27 @@ var entertainmentNouns = []string{
"joke", "jokes", "story",
}
// narrativeQueryBuild — the narrative shape is a query unless he also said one
// of the capture verbs, or asked for entertainment. "расскажи и запиши" is him
// asking for a note, and stage 0 must not take either off the cascade.
// narrativeQueryBuild — the narrative shape is a query carrying its topic,
// unless he also said one of the capture verbs, asked for entertainment, or
// asked about her. "расскажи и запиши" is him asking for a note, and stage 0
// must not take either off the cascade.
//
// The topic goes into Slots.Text rather than the whole utterance: the query
// chain looks things up by it, and "расскажи мне про Ватерлоо" is a question
// about Ватерлоо.
func narrativeQueryBuild(m []string) (Decision, bool) {
rest := ""
if len(m) > 3 {
rest = m[3]
topic := strings.TrimSpace(m[2])
// "расскажи" with nothing after it is a conversational opener, and there is
// no topic to look up.
if topic == "" {
return Decision{}, false
}
for _, t := range planTokens(rest) {
// Against the whole utterance, not the topic: "о себе" has its preposition
// eaten by the pattern, leaving a bare "себе".
if chatNarrativeTopics.MatchString(m[0]) {
return Decision{}, false
}
for _, t := range planTokens(topic) {
for _, v := range captureVerbs {
if t == v {
return Decision{}, false
@@ -275,7 +306,12 @@ func narrativeQueryBuild(m []string) (Decision, bool) {
}
}
}
return agendaQueryBuild(m)
return Decision{
Stage: 0,
Intent: IntentQuery,
Confidence: 1.0,
Slots: Slots{Text: topic},
}, true
}
// narrativeQueryPattern — "расскажи про X", built from the lexicon rather than
@@ -285,9 +321,36 @@ func narrativeQueryBuild(m []string) (Decision, bool) {
//
// Anchored at the start, which was the point of the old literal and still is:
// "запиши что мне рассказали" is a capture, and a narrative verb buried
// mid-utterance is not the shape.
// mid-utterance is not the shape. The dative and the preposition are eaten so
// the topic slot comes out clean: "расскажи мне про Ватерлоо" leaves
// "Ватерлоо".
var narrativeQueryPattern = regexp.MustCompile(
`(?i)^\s*(` + strings.Join(lexicon.NarrativeRequests(), "|") + `)(\s+(.*))?$`)
`(?is)^\s*(` + strings.Join(lexicon.NarrativeRequests(), "|") +
`)(?:\s+(?:мне|нам|us|me))?(?:\s+(?:про|о|об|about))?(\s+.+)$`)
// FeedQueryGrammar — stage-0 rule for "что нового в лентах?", routed to
// IntentQuery so it reaches queryFeeds.
//
// Same shape of defect as the agenda grammars: the model calls it system, and
// replySystem has no feeds arm, so the documented utterance of task 258 step 1
// answered "пока не умею отвечать на этот вопрос." while the same question
// worded with "новостях" worked (Vikunja #474).
//
// An ask word at the front and a feed noun after it are both required, which
// is the same pair ParseFeedQuery wants. "что нового?" on its own is a greeting
// — the most common opener in the language — and vagueNouns in feeds.go exists
// to keep it out of the feed reader; routing it to query here would put it
// back. "у меня новая лента в инстаграме" carries the noun without the ask and
// stays the statement it is.
func FeedQueryGrammar() Grammar {
return Grammar{
Name: "feed-query",
// (\s|[?!.]|$) rather than \b, which is ASCII-only and never fires next
// to a Cyrillic letter.
Pattern: regexp.MustCompile(`(?i)^\s*(что|какие|расскажи|покажи|почитай|прочитай)\s+.*(лент|новостн)[а-я]*(\s|[?!.]|$)`),
Build: agendaQueryBuild,
}
}
// dayWordPattern — the day words an agenda question can name. Weekdays appear
// in the accusative and prepositional forms the questions actually use ("в
@@ -345,7 +408,9 @@ func isCyrillic(s string) bool {
return s != ""
}
// agendaQueryBuild — shared Build for the agenda grammars. Confidence 1.0 on
// agendaQueryBuild — shared Build for the agenda grammars and the feed one,
// which all do the same single thing: keep the utterance out of IntentSystem
// and let the query chain decide who answers it. Confidence 1.0 on
// the intent only: the utterance travels intact and the query chain's own
// matchers decide the rest.
func agendaQueryBuild(m []string) (Decision, bool) {