fa98e4722e
The guard answers one question — may this utterance become an executable action — as a three-way policy gate (permissive / blocked / ambiguous) and never decides what the utterance is. Rules are the encoding of the measured slice-20 dev-pool discriminators: 126 capability-question rows are 42/42/42 addressed / bare-ability / bare-future; 127 bare можешь+пожалуйста rows are 100% action; can-you-please is 100% action. Reuses the shipped prohibition parser, morph finiteness and lexicon fillers; reason vocabulary is closed. Slice 21 (task/725, brief after the accepted slice 20).
660 lines
24 KiB
Go
660 lines
24 KiB
Go
// Guard is the slice-21 deterministic execution-frame engine (experiment-only).
|
||
//
|
||
// It answers one question: given an utterance, what is its execution-frame
|
||
// eligibility as a three-way gate — permissive, blocked, ambiguous — and why.
|
||
// It never decides what an utterance IS (that stays with the route classifier);
|
||
// it only decides whether an utterance may become an executable action at all.
|
||
// The policy is asymmetric on purpose: blocked and ambiguous must never
|
||
// execute, and permissive only means "no blocking speech-act evidence exists",
|
||
// not "execute this".
|
||
//
|
||
// It reuses the shipped deterministic routers rather than inventing new ones:
|
||
//
|
||
// router.ParseCommandProhibition / IsCommandProhibition direct negative commands
|
||
// morph.IsVerbForm / morph.Lemma verb mood and finiteness
|
||
// lexicon.IsFillerParticle / FirstPerson() politeness and first-person frames
|
||
//
|
||
// Everything else is closed-class evidence measured on the frozen slice-20 dev
|
||
// pool (§"measured discriminators" in the brief): 126 capability-question rows
|
||
// split 42/42/42 across ты-addressed, bare ability (умеешь), and bare future
|
||
// (сможешь) modality; 127 bare "можешь, пожалуйста" rows are 100% action;
|
||
// "can you … , please" (English frame + Russian imperative) is 100% action.
|
||
// The rules below are the encoding of precisely those numbers.
|
||
//
|
||
// The reason vocabulary is a closed set. Additions are design decisions that
|
||
// must land in the report, not silent new branches.
|
||
package main
|
||
|
||
import (
|
||
"strings"
|
||
"unicode"
|
||
|
||
"github.com/kami/maven/internal/lexicon"
|
||
"github.com/kami/maven/internal/morph"
|
||
"github.com/kami/maven/internal/router"
|
||
)
|
||
|
||
// Eligibility is the three-way execution-frame verdict.
|
||
type Eligibility int
|
||
|
||
const (
|
||
Permissive Eligibility = iota // no blocking speech-act evidence; downstream route decides
|
||
Blocked // a speech act forbids execution (negation, question, report, …)
|
||
Ambiguous // not enough evidence either way; must not execute
|
||
)
|
||
|
||
func (e Eligibility) String() string {
|
||
switch e {
|
||
case Permissive:
|
||
return "permissive"
|
||
case Blocked:
|
||
return "blocked"
|
||
default:
|
||
return "ambiguous"
|
||
}
|
||
}
|
||
|
||
// Reason is a closed set of structural explanations for a verdict.
|
||
type Reason string
|
||
|
||
const (
|
||
ReasonCommandProhibition Reason = "command_prohibition"
|
||
ReasonCapabilityQuestion Reason = "capability_question"
|
||
ReasonReportedSpeech Reason = "reported_speech"
|
||
ReasonQuotation Reason = "quotation"
|
||
ReasonHypothetical Reason = "hypothetical"
|
||
ReasonNegatedCommand Reason = "negated_command"
|
||
ReasonExplicitRequest Reason = "explicit_request"
|
||
ReasonAmbiguousModal Reason = "ambiguous_modal"
|
||
ReasonNoRequestEvidence Reason = "no_request_evidence"
|
||
)
|
||
|
||
func (r Reason) String() string { return string(r) }
|
||
|
||
// Frame is the verdict for one utterance. Eligibility decides; Reasons explain.
|
||
// A frame may carry more than one reason (e.g. a quoted reported command).
|
||
type Frame struct {
|
||
Eligibility Eligibility
|
||
Reasons []Reason
|
||
}
|
||
|
||
// maybeWord is a single-token or multi-token closed expression, e.g. the
|
||
// token "не мог бы" covers the three tokens не мog бы when matched as a
|
||
// contiguous run ("бы" is itself a bound marker). Multi-token members are
|
||
// matched over the reconstructed token text, never over raw text, so
|
||
// punctuation boundaries do not defeat them.
|
||
type maybeWord struct {
|
||
single []string
|
||
multi []string // matched as contiguous lowercased token runs
|
||
}
|
||
|
||
func (w maybeWord) in(toks []string, joined string) bool {
|
||
if hasAny(toks, w.single) {
|
||
return true
|
||
}
|
||
for _, m := range w.multi {
|
||
tm := strings.Join(tokens(m), " ")
|
||
if tm != "" && strings.Contains(joined, tm) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// ── closed evidence sets (all measured on the slice-20 dev pool) ──────────
|
||
|
||
// wakeAddr is stripped from the left of an utterance before command-form
|
||
// detection: "мавен, выключи свет" and "выключи свет" must ride the same
|
||
// frame. Closed: the names Maven answers to in the dev pool.
|
||
var wakeAddr = []string{"мавен", "maven", "мавэн", "алекса", "алиса", "окей", "эй", "hey"}
|
||
|
||
// ruAddress are the second-person Russian address tokens. "ты можешь …"
|
||
// (with or without politeness) is 42/42 capability-question in the dev pool,
|
||
// so any addressed Russian can-form is a capability question, never a request.
|
||
var ruAddress = []string{"ты", "тебе", "тебя", "тобой", "тобою", "вы", "вас", "вам", "вами"}
|
||
|
||
// enAddress is the English second-person address. Unlike Russian, "can you …
|
||
// , please" is 96/96 action in the dev pool (English modal frame around a
|
||
// Russian imperative), so English address alone never blocks: it routes to the
|
||
// politeness arm.
|
||
var enAddress = []string{"you", "u", "your"}
|
||
|
||
// ruCanForms are the present-can verb forms. Bare (no address) "можешь …,
|
||
// пожалуйста" is 127/127 action; bare "можешь …" with no politeness is the
|
||
// ambiguous bucket (no such rows exist in dev — conservative default).
|
||
var ruCanForms = []string{"можешь", "можете", "могу", "можем"}
|
||
|
||
// ruAbilityForms are future/ability modal forms that read as a question of
|
||
// capability regardless of politeness: "сможешь открыть окно, пожалуйста" and
|
||
// "умеешь ли ты …" are 0/84 action in the dev pool, so even a polite bare
|
||
// form never grants execution. "мог(ла) бы …" and "смог(ла) бы …" are the
|
||
// conditional-politeness mask over the same boundary — except the leading
|
||
// politeness construction "не мог бы ты …", which the prohibition parser
|
||
// already classifies as ordinary modal politeness and must stay permissive.
|
||
var ruAbilityForms = maybeWord{
|
||
single: []string{
|
||
"сможешь", "сможете", "смогу", "сможем", "сумеешь", "сумеете",
|
||
"умеешь", "умеете", "способна", "способен", "способно", "способны",
|
||
"смог", "смогла", "смогли", "мог", "могла", "могли",
|
||
},
|
||
multi: []string{
|
||
"смог бы", "смогла бы", "смогли бы", "мог бы", "могла бы", "могли бы",
|
||
"смочь бы", "мочь бы",
|
||
},
|
||
}
|
||
|
||
// politeNegativeModal is the leading "не мог бы ты/вы …" politeness framing the
|
||
// prohibition parser exempts as ordinary modal politeness. When it leads the
|
||
// utterance the capability stage declines and the frame reads as a request.
|
||
var politeNegativeModal = []string{
|
||
"не мог бы", "не могла бы", "не могли бы", "не смог бы", "не смогла бы", "не смогли бы",
|
||
}
|
||
|
||
// enCanForms are the English modal can/could tokens.
|
||
var enCanForms = []string{"can", "could"}
|
||
|
||
// politeness is the closed set of politeness fillers. пожалуйста/плиз/please
|
||
// are already closed-class filler particles in the lexicon; the добр-forms
|
||
// are the only additions the dev pool exercises.
|
||
var politeness = maybeWord{
|
||
single: []string{"пожалуйста", "плиз", "please"},
|
||
multi: []string{"будь добр", "будьте добры", "был бы добр", "были бы добры"},
|
||
}
|
||
|
||
// reportVerbs are the past/third-person report verbs — the frame that reports
|
||
// a command rather than issuing it. Second-person imperatives ("скажи",
|
||
// "расскажи", "напомни") are deliberately absent: those are requests to
|
||
// report, and their clause forms part of the current utterance, not a
|
||
// replayed order. Matched as closed list (a report verb outside it is a data
|
||
// gap, noted in the report).
|
||
var reportVerbs = []string{
|
||
"сказал", "сказала", "сказали", "говорил", "говорила", "говорили",
|
||
"говорит", "говорят", "попросил", "попросила", "попросили",
|
||
"просил", "просила", "просили", "написал", "написала", "написали",
|
||
"пишет", "приказал", "приказала", "приказали", "велел", "велела",
|
||
"велели", "скомандовал", "скомандовала", "рекомендовал", "рекомендовала",
|
||
"посоветовал", "посоветовала", "сообщил", "сообщила", "сообщили",
|
||
"объявил", "объявила", "велено", "сказано", "написано", "записано",
|
||
}
|
||
|
||
// reportNouns name a quoted or reported text: "фраза «выключи свет»" is a
|
||
// quotation, not a command.
|
||
var reportNouns = []string{
|
||
"фраза", "фразы", "фразе", "фразу", "слово", "слова", "слове", "словом",
|
||
"выражение", "выражения", "цитата", "цитату", "цитате",
|
||
"название", "текст", "сообщение", "письмо", "заметка", "заметку",
|
||
}
|
||
|
||
// hypothesisMarkers open a conditional scope.
|
||
var hypothesisMarkers = []string{"если", "ежели", "коли", "кабы", "if"}
|
||
|
||
// illocutionVerbs make a first-person or impersonal clause a request even
|
||
// without an imperative form ("я хочу …", "мне нужно …", "надо …").
|
||
var illocutionVerbs = maybeWord{
|
||
single: []string{
|
||
"хочу", "хотел", "хотела", "хотелось", "желаю", "прошу", "просим",
|
||
"просил", "просила", "просили", "попросить",
|
||
"надо", "нужно", "следует", "пора", "требуется", "придётся", "придется",
|
||
"могу", "давай", "давайте",
|
||
},
|
||
multi: []string{
|
||
"хотел бы", "хотела бы", "хочу чтобы", "хотел чтобы", "хотела чтобы",
|
||
"могу ли",
|
||
},
|
||
}
|
||
|
||
// ── token helpers ─────────────────────────────────────────────────────────
|
||
|
||
// tokens lowercases and splits on anything that is not a letter or digit,
|
||
// matching the router's planTokens discipline ("что-дальше" tokenises like
|
||
// "что дальше").
|
||
func tokens(text string) []string {
|
||
return strings.FieldsFunc(strings.ToLower(text), func(r rune) bool {
|
||
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
|
||
})
|
||
}
|
||
|
||
func hasTok(toks []string, w string) bool {
|
||
for _, t := range toks {
|
||
if t == w {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func hasAny(toks, ws []string) bool {
|
||
for _, w := range ws {
|
||
if hasTok(toks, w) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func indexTok(toks []string, w string) int {
|
||
for i, t := range toks {
|
||
if t == w {
|
||
return i
|
||
}
|
||
}
|
||
return -1
|
||
}
|
||
|
||
// isFiniteVerb reports a verb form that is not the dictionary (infinitive)
|
||
// form: "выключи" is finite, "выключить" is not. A finite verb at command
|
||
// position is positive request evidence; an infinitive is not.
|
||
func isFiniteVerb(tok string) bool {
|
||
if !morph.IsVerbForm(tok) {
|
||
return false
|
||
}
|
||
return morph.Lemma(tok) != tok
|
||
}
|
||
|
||
// isInfinitive reports a token that morph resolves to its own dictionary form
|
||
// (the lemma ends in the infinitive ending by construction).
|
||
func isInfinitive(tok string) bool {
|
||
if !morph.IsVerbForm(tok) {
|
||
return false
|
||
}
|
||
return morph.Lemma(tok) == tok
|
||
}
|
||
|
||
func anyInfinitive(toks []string) bool {
|
||
for _, t := range toks {
|
||
if isInfinitive(t) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func anyFiniteVerb(toks []string) bool {
|
||
for _, t := range toks {
|
||
if isFiniteVerb(t) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func hasAnyVerb(toks []string) bool {
|
||
for _, t := range toks {
|
||
if morph.IsVerbForm(t) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func blocked(rs ...Reason) Frame { return Frame{Eligibility: Blocked, Reasons: rs} }
|
||
func ambiguous(rs ...Reason) Frame {
|
||
return Frame{Eligibility: Ambiguous, Reasons: rs}
|
||
}
|
||
func permissive(rs ...Reason) Frame {
|
||
return Frame{Eligibility: Permissive, Reasons: rs}
|
||
}
|
||
|
||
// ── quoted spans ─────────────────────────────────────────────────────────
|
||
|
||
// quotedSpan is a maximal quoted interval in the normalized text.
|
||
type quotedSpan struct{ content string }
|
||
|
||
// quotePairs covers the quoting styles the dev pool and brief fixtures use:
|
||
// Russian guillemets, curly double/single quotes, and straight quotes.
|
||
var quotePairs = []struct{ open, close string }{
|
||
{"«", "»"}, {"„", "\""}, {"“", "”"}, {"‚", "‘"}, {"‘", "’"}, {"'", "'"}, {"\"", "\""},
|
||
}
|
||
|
||
// extractQuotedSpans returns the contents of quoted spans in order, in rune
|
||
// index space (the text is normalized, so glyphs are single runes). An
|
||
// unbalanced delimiter yields no span (best-effort; the conservative
|
||
// fallback then applies).
|
||
func extractQuotedSpans(t string) []quotedSpan {
|
||
runes := []rune(t)
|
||
var out []quotedSpan
|
||
i := 0
|
||
for i < len(runes) {
|
||
matched := false
|
||
for _, p := range quotePairs {
|
||
po := []rune(p.open)
|
||
pc := []rune(p.close)
|
||
if i+len(po) > len(runes) || string(runes[i:i+len(po)]) != p.open {
|
||
continue
|
||
}
|
||
j := i + len(po)
|
||
for j+len(pc) <= len(runes) && string(runes[j:j+len(pc)]) != p.close {
|
||
j++
|
||
}
|
||
out = append(out, quotedSpan{content: string(runes[i+len(po) : j])})
|
||
i = j + len(pc)
|
||
matched = true
|
||
break
|
||
}
|
||
if !matched {
|
||
i++
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// ── rule stages (evaluated in this order; a decision is final) ────────────
|
||
|
||
// Evaluate derives the execution-frame verdict for one utterance.
|
||
func Evaluate(text string) Frame {
|
||
t := router.NormalizeMatchText(text)
|
||
if strings.TrimSpace(t) == "" {
|
||
return ambiguous(ReasonNoRequestEvidence)
|
||
}
|
||
toks := tokens(t)
|
||
joined := strings.Join(toks, " ")
|
||
|
||
// 1. Quotation: a command inside a quoted span is not a command being
|
||
// issued now. With a reporting frame outside it is a quotation; a bare
|
||
// quote is at best ambiguous.
|
||
if f, ok := stageQuotation(t, toks, joined); ok {
|
||
return f
|
||
}
|
||
|
||
// 2. Reported speech: a past/third-person report verb governing a command
|
||
// clause reports an order to someone else, it does not issue one.
|
||
if f, ok := stageReport(t, toks, joined); ok {
|
||
return f
|
||
}
|
||
|
||
// 3. Hypothetical: a command scope opened by "если/if" that does not
|
||
// continue as a real condition→command is not an execution request.
|
||
if f, ok := stageHypothesis(toks, joined); ok {
|
||
return f
|
||
}
|
||
|
||
// 4. Direct negative commands: the shipped prohibition parser.
|
||
if router.IsCommandProhibition(t) {
|
||
return blocked(ReasonCommandProhibition)
|
||
}
|
||
|
||
// 5. Advisory negatives: "не надо/не стоит/не нужно …".
|
||
if f, ok := stageAdvisoryNegation(toks); ok {
|
||
return f
|
||
}
|
||
|
||
// 6. Capability and permission modality (the measured core).
|
||
if f, ok := stageCapability(toks, joined); ok {
|
||
return f
|
||
}
|
||
|
||
// 7. Trailing question mark with no modal at play: an uncertain posture,
|
||
// never a confirmed executable request.
|
||
if strings.HasSuffix(t, "?") {
|
||
return ambiguous(ReasonAmbiguousModal)
|
||
}
|
||
|
||
// 8. Positive request evidence.
|
||
if hasRequestEvidence(toks, joined) {
|
||
return permissive(ReasonExplicitRequest)
|
||
}
|
||
|
||
// 9. No execution pressure at all.
|
||
return ambiguous(ReasonNoRequestEvidence)
|
||
}
|
||
|
||
// stageQuotation blocks a quoted command when a reporting frame surrounds it.
|
||
func stageQuotation(t string, toks []string, joined string) (Frame, bool) {
|
||
spans := extractQuotedSpans(t)
|
||
if len(spans) == 0 {
|
||
return Frame{}, false
|
||
}
|
||
commandSpan := false
|
||
for _, sp := range spans {
|
||
if isCommandishWithin(tokens(sp.content), strings.Join(tokens(sp.content), " ")) {
|
||
commandSpan = true
|
||
break
|
||
}
|
||
}
|
||
if !commandSpan {
|
||
return Frame{}, false
|
||
}
|
||
reasons := []Reason{ReasonQuotation}
|
||
if hasReportFrame(toks, joined) {
|
||
reasons = append(reasons, ReasonReportedSpeech)
|
||
return Frame{Eligibility: Blocked, Reasons: reasons}, true
|
||
}
|
||
// a bare quoted command has no reporting frame: refusable but not a
|
||
// definite prohibition either (it is at least ambiguous)
|
||
return Frame{Eligibility: Ambiguous, Reasons: reasons}, true
|
||
}
|
||
|
||
// isCommandishWithin reports the span content carrying command or capability
|
||
// polarity itself — imperative, prohibition, or a can-form.
|
||
func isCommandishWithin(toks []string, joined string) bool {
|
||
if len(toks) == 0 {
|
||
return false
|
||
}
|
||
if router.IsCommandProhibition(strings.Join(toks, " ")) {
|
||
return true
|
||
}
|
||
if hasAny(toks, ruCanForms) || ruAbilityForms.in(toks, joined) || hasAny(toks, enCanForms) {
|
||
return true
|
||
}
|
||
return anyFiniteVerb(toks)
|
||
}
|
||
|
||
func hasReportFrame(toks []string, joined string) bool {
|
||
if hasAny(toks, reportVerbs) {
|
||
return true
|
||
}
|
||
return hasAny(toks, reportNouns)
|
||
}
|
||
|
||
// stageReport blocks when a report frame governs a command clause: an
|
||
// infinitive after the report verb, or a quoted imperative. Second-person
|
||
// imperatives like "скажи/расскажи" are not in reportVerbs, so a request to
|
||
// report ("расскажи мне, что сказал папа") passes through.
|
||
func stageReport(t string, toks []string, joined string) (Frame, bool) {
|
||
if !hasReportFrame(toks, joined) {
|
||
return Frame{}, false
|
||
}
|
||
last := -1
|
||
for i, w := range toks {
|
||
if hasTok(reportVerbs, w) || hasTok(reportNouns, w) {
|
||
last = i
|
||
}
|
||
}
|
||
if last < 0 {
|
||
return Frame{}, false
|
||
}
|
||
after := toks[last+1:]
|
||
if len(after) == 0 {
|
||
return Frame{}, false
|
||
}
|
||
// a quoted command after the frame counts as the governed clause
|
||
for _, sp := range extractQuotedSpans(t) {
|
||
if isCommandishWithin(tokens(sp.content), strings.Join(tokens(sp.content), " ")) {
|
||
return blocked(ReasonReportedSpeech, ReasonQuotation), true
|
||
}
|
||
}
|
||
if anyInfinitive(after) || hasAny(after, []string{"что", "чтобы", "чтоб"}) {
|
||
return blocked(ReasonReportedSpeech), true
|
||
}
|
||
return Frame{}, false
|
||
}
|
||
|
||
// stageHypothesis blocks a conditional scope whose clauses are hypothetical
|
||
// (infinitive or subjunctive "бы") rather than a real condition→command.
|
||
// "если будет дождь, выключи полив" keeps its imperative continuation and
|
||
// passes through; it is a real conditional request, not a hypothetical.
|
||
func stageHypothesis(toks []string, joined string) (Frame, bool) {
|
||
idx := -1
|
||
for _, m := range hypothesisMarkers {
|
||
if i := indexTok(toks, m); i >= 0 && (idx < 0 || i < idx) {
|
||
idx = i
|
||
}
|
||
}
|
||
if idx < 0 {
|
||
return Frame{}, false
|
||
}
|
||
post := toks[idx+1:]
|
||
if len(post) == 0 || hasTok(post, "бы") || anyInfinitive(post) {
|
||
return blocked(ReasonHypothetical), true
|
||
}
|
||
// a real condition clause is not hypothetical: «если будет дождь,
|
||
// выключи полив» is a request. The dev dict does not cover «будет», so
|
||
// the imperative is looked for anywhere, not just after the marker
|
||
// («выключи свет, если будет дождь»).
|
||
if !anyFiniteVerb(toks) {
|
||
return blocked(ReasonHypothetical), true
|
||
}
|
||
return permissive(ReasonExplicitRequest), true
|
||
}
|
||
|
||
// stageAdvisoryNegation blocks "не надо/не нужно/не стоит/не следует …".
|
||
// (absent from the dev pool; covered by brief fixtures)
|
||
func stageAdvisoryNegation(toks []string) (Frame, bool) {
|
||
if len(toks) < 3 || toks[0] != "не" {
|
||
return Frame{}, false
|
||
}
|
||
if !hasTok(toks[1:2], "надо") && !hasTok(toks[1:2], "нужно") &&
|
||
!hasTok(toks[1:2], "стоит") && !hasTok(toks[1:2], "следует") &&
|
||
!hasTok(toks[1:2], "требуется") {
|
||
return Frame{}, false
|
||
}
|
||
rest := toks[2:]
|
||
if anyInfinitive(rest) || anyFiniteVerb(rest) || hasAnyVerb(rest) {
|
||
return blocked(ReasonNegatedCommand), true
|
||
}
|
||
return Frame{}, false
|
||
}
|
||
|
||
// stageCapability encodes the measured modal matrix. Returns a decision when
|
||
// modality alone settles the frame.
|
||
func stageCapability(toks []string, joined string) (Frame, bool) {
|
||
// "не мог бы ты …, пожалуйста" style conditional politeness is ordinary
|
||
// modal politeness (the prohibition parser exempts it as such): a request.
|
||
for _, pref := range politeNegativeModal {
|
||
if strings.HasPrefix(joined, pref) {
|
||
return permissive(ReasonExplicitRequest), true
|
||
}
|
||
}
|
||
|
||
// "… ли" directly after a can-form is a polar capability question:
|
||
// "могу ли я …", "можешь ли ты …", "умеешь ли ты …", "можно ли …".
|
||
// Checked before the modality arms so the polar reading wins.
|
||
if hasTok(toks, "ли") {
|
||
for i := 1; i < len(toks); i++ {
|
||
if toks[i] != "ли" {
|
||
continue
|
||
}
|
||
prev := toks[i-1]
|
||
if hasTok(ruCanForms, prev) || prev == "можно" || hasTok(ruAbilityForms.single, prev) {
|
||
return blocked(ReasonCapabilityQuestion), true
|
||
}
|
||
}
|
||
}
|
||
|
||
ruAddr := hasAny(toks, ruAddress)
|
||
ruCan := hasAny(toks, ruCanForms)
|
||
ruAbil := ruAbilityForms.in(toks, joined)
|
||
enCan := hasAny(toks, enCanForms)
|
||
polite := politeness.in(toks, joined)
|
||
|
||
// addressed Russian can-form: capability question, always blocked.
|
||
// ("ты можешь выключить свет, пожалуйста" included — 42/42 non-action.)
|
||
if ruAddr && (ruCan || ruAbil) {
|
||
return blocked(ReasonCapabilityQuestion), true
|
||
}
|
||
|
||
// ability forms (future/conditional/умеешь) are capability even bare and
|
||
// even polite: "сможешь открыть окно, пожалуйста" is 7/7 non-action.
|
||
if ruAbil && !ruCan {
|
||
return blocked(ReasonCapabilityQuestion), true
|
||
}
|
||
|
||
// bare Russian present can-form: politeness is the request marker.
|
||
if ruCan && !ruAddr {
|
||
if polite {
|
||
return Frame{}, false // modal-request positive evidence is found later
|
||
}
|
||
return ambiguous(ReasonAmbiguousModal), true
|
||
}
|
||
|
||
// English can/could: "can you …, please" is a request (96/96 action in the
|
||
// dev pool; the frame wraps a Russian imperative). Without politeness it
|
||
// reads as a capability question and stays ambiguous.
|
||
if enCan && !ruAddr {
|
||
if polite {
|
||
return Frame{}, false // positive modal-request evidence later
|
||
}
|
||
return ambiguous(ReasonAmbiguousModal), true
|
||
}
|
||
|
||
// "можно" (permission): "можно ли …" is a permission question; a bare
|
||
// "можно …" is a politeness-implicature request.
|
||
if hasTok(toks, "можно") {
|
||
if hasTok(toks, "ли") {
|
||
return blocked(ReasonCapabilityQuestion), true
|
||
}
|
||
return Frame{}, false
|
||
}
|
||
|
||
// bare "могу": a self-capability statement, not a request.
|
||
if hasTok(toks, "могу") && !hasTok(toks, "ли") {
|
||
return ambiguous(ReasonAmbiguousModal), true
|
||
}
|
||
|
||
return Frame{}, false
|
||
}
|
||
|
||
// hasRequestEvidence is the positive permissive trigger, reached only after
|
||
// every block/ambiguity stage above has declined.
|
||
func hasRequestEvidence(toks []string, joined string) bool {
|
||
polite := politeness.in(toks, joined)
|
||
enCan := hasAny(toks, enCanForms)
|
||
|
||
// 1. politeness + a verb (or an English modal) is explicit request
|
||
// evidence: "можешь выключить свет, пожалуйста", "can you останови …,
|
||
// please", "выключи свет, пожалуйста".
|
||
if polite && (hasAnyVerb(toks) || enCan) {
|
||
return true
|
||
}
|
||
|
||
// 2. first-person illocution frame: "я хочу …", "мне нужно …".
|
||
if hasAny(toks, lexicon.FirstPerson()) && illocutionVerbs.in(toks, joined) {
|
||
return true
|
||
}
|
||
|
||
// 3. impersonal need: "надо …", "нужно …", "пора …".
|
||
if hasAny(toks, []string{"надо", "нужно", "следует", "пора", "требуется", "придётся", "придется"}) {
|
||
return true
|
||
}
|
||
|
||
// 4. permission-implicature request: "можно выключить свет".
|
||
if hasTok(toks, "можно") && !hasTok(toks, "ли") {
|
||
return true
|
||
}
|
||
|
||
// 5. reminder request in the parser's own exemption scope: the
|
||
// prohibition parser declines «не забудь напомнить про свет» as a
|
||
// reminder, not a prohibition — carry that into a request.
|
||
if strings.HasPrefix(joined, "не забудь") && hasAny(toks, lexicon.ReminderVerbs()) {
|
||
return true
|
||
}
|
||
|
||
// 6. leading finite verb (imperative or otherwise tensed verb at command
|
||
// position): "выключи свет", "покажи что запущено". Address and filler
|
||
// particles are stripped first, so "мавен, выключи свет" rides the same
|
||
// frame.
|
||
lead := toks
|
||
for len(lead) > 0 {
|
||
first := lead[0]
|
||
if !lexicon.IsFillerParticle(first) && !hasTok(wakeAddr, first) && !hasTok(ruAddress, first) {
|
||
break
|
||
}
|
||
lead = lead[1:]
|
||
}
|
||
if len(lead) > 0 && isFiniteVerb(lead[0]) {
|
||
return true
|
||
}
|
||
|
||
return false
|
||
}
|