Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3dbf67f8f9 | |||
| 84ba217892 | |||
| f179ae2fde | |||
| d00929ac0b | |||
| b6f47fbeb6 | |||
| bfb57c3148 |
@@ -124,6 +124,55 @@ Two caveats worth saying out loud:
|
|||||||
Phrasing was **not** measured. Whether thinking helps there is still open, and now also blocked
|
Phrasing was **not** measured. Whether thinking helps there is still open, and now also blocked
|
||||||
on the same "can we even turn it off" question.
|
on the same "can we even turn it off" question.
|
||||||
|
|
||||||
|
## Clock and calendar rule — 31-07-2026 (Vikunja #374)
|
||||||
|
|
||||||
|
`routeSystem` never said whether "который час" or "какое число завтра" are `system` or
|
||||||
|
`query`, and `system→query ×4` showed up in every run. The rule added says: the clock and the
|
||||||
|
calendar date themselves are `system`; what is *written in* the calendar or in memory
|
||||||
|
("что у меня завтра", "какие есть напоминания") stays `query`; and a time named inside a
|
||||||
|
request ("напомни завтра…") is just a detail of the request, not a reason for `system`.
|
||||||
|
|
||||||
|
That split is not a preference. In `cmd/mavend/voice.go` only `replySystem` owns the clock and
|
||||||
|
the date formatter, so a clock question routed to `query` falls into the embedder + note RAG
|
||||||
|
and answers "не знаю". The agenda, on the other hand, is answered by `ParseCalendarDate` +
|
||||||
|
`CalendarEvents` *inside* the `query` branch, so that side has to stay `query`. The rule sits
|
||||||
|
above the question test because every one of these utterances carries a question word and a
|
||||||
|
later rule would never be reached.
|
||||||
|
|
||||||
|
The fixture is now 77 cases: one calendar-agenda case was added
|
||||||
|
(`ru-query-019` "что у меня стоит в календаре на послезавтра", intent `query`) specifically so
|
||||||
|
an over-broad system rule cannot pass unnoticed. The clock/date cases (`ru-sys-001/002/005`,
|
||||||
|
`en-sys-001`) already existed.
|
||||||
|
|
||||||
|
Three runs, same box, back to back, never concurrently:
|
||||||
|
|
||||||
|
| | baseline | first rule (too broad) | rule as committed |
|
||||||
|
|---|---|---|---|
|
||||||
|
| llm-only intent-only | 59.2% (45/76) | 54.5% (42/77) | 59.7% (46/77) |
|
||||||
|
| llm-only full | 38.2% | 35.1% | 39.0% |
|
||||||
|
| llm-only route errors | 3 | 4 | 5 |
|
||||||
|
| llm-only p50 | 1.09s | 0.91s | 0.93s |
|
||||||
|
| cascade+llm intent-only | 61.8% (47/76) | 58.4% | 62.3% (48/77) |
|
||||||
|
| cascade+llm full | 57.9% | 54.5% | 59.7% |
|
||||||
|
| cascade+llm route errors | 0 | 0 | 0 |
|
||||||
|
| cascade+llm p50 | 0.91s | 0.80s | 1.04s |
|
||||||
|
|
||||||
|
**The targeted bug is fixed and the headline number did not move.** `system→query ×4` is gone
|
||||||
|
in both LLM configurations — the `time` and `date` tags go from 0/2 and 0/2 to 2/2 and 2/2 —
|
||||||
|
but the model then over-applies the rule, and `query→system ×5` plus `reminder→system ×2`
|
||||||
|
appear where they did not exist before. Net accuracy is a wash, inside the noise of a 77-case
|
||||||
|
fixture.
|
||||||
|
|
||||||
|
The first attempt is shown because it is the honest history: it said "спрашивает время, дату
|
||||||
|
или день недели → system" with no scope, which swept up reminders, and it cost 3-5 points. It
|
||||||
|
was tightened once, on the reasoning that a rule capturing "напомни завтра в 7" is simply
|
||||||
|
wrong, and not tuned further. The remaining `query/reminder → system` over-trigger is a new,
|
||||||
|
separate weakness of the sub-1B model and deserves its own task rather than more prompt
|
||||||
|
kneading against a held-out fixture.
|
||||||
|
|
||||||
|
The rule is kept. It is correct about what the daemon can answer, and the failure it replaces
|
||||||
|
was silent ("не знаю" to "который час") while the one it introduces is loud.
|
||||||
|
|
||||||
## Findings
|
## Findings
|
||||||
|
|
||||||
### 1. The resident model does route better — 50.0% vs 36.8%
|
### 1. The resident model does route better — 50.0% vs 36.8%
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
// systemHandler — a handler with nothing but a fixed clock, which is all
|
||||||
|
// replySystem needs.
|
||||||
|
func systemHandler(now time.Time) *reactiveHandler {
|
||||||
|
return &reactiveHandler{now: func() time.Time { return now }}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReplySystemDateOffset — "какое число завтра" must answer tomorrow's
|
||||||
|
// date, not today's (Vikunja #388).
|
||||||
|
func TestReplySystemDateOffset(t *testing.T) {
|
||||||
|
// Thursday, 30 July 2026.
|
||||||
|
now := time.Date(2026, 7, 30, 14, 5, 0, 0, time.UTC)
|
||||||
|
h := systemHandler(now)
|
||||||
|
cases := []struct{ utterance, want string }{
|
||||||
|
{"какое сегодня число", "сегодня четверг, 30 июля 2026 года"},
|
||||||
|
{"какое число", "сегодня четверг, 30 июля 2026 года"},
|
||||||
|
{"какое число завтра", "завтра пятница, 31 июля 2026 года"},
|
||||||
|
{"какое число послезавтра", "послезавтра суббота, 1 августа 2026 года"},
|
||||||
|
{"какое было число вчера", "вчера среда, 29 июля 2026 года"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got := h.replySystem(context.Background(), router.Decision{Utterance: c.utterance})
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("replySystem(%q) = %q, want %q", c.utterance, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A day she cannot work out must not come back as today's date — that is the
|
||||||
|
// same silent wrong answer #388 was about, one step further out.
|
||||||
|
func TestReplySystemUnknownDayIsHonest(t *testing.T) {
|
||||||
|
now := time.Date(2026, 7, 30, 14, 5, 0, 0, time.UTC)
|
||||||
|
h := systemHandler(now)
|
||||||
|
for _, u := range []string{
|
||||||
|
"какое число в пятницу",
|
||||||
|
"какое число через неделю",
|
||||||
|
"какое число в понедельник",
|
||||||
|
} {
|
||||||
|
got := h.replySystem(context.Background(), router.Decision{Utterance: u})
|
||||||
|
if got != onlyNearDaysReply {
|
||||||
|
t.Errorf("replySystem(%q) = %q, want the honest reply", u, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The days she does know must not be caught by the same guard.
|
||||||
|
if got := h.replySystem(context.Background(), router.Decision{Utterance: "какое число завтра"}); got == onlyNearDaysReply {
|
||||||
|
t.Error("завтра was treated as an unknown day")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReplySystemClockCity — the clock arm must not answer local time for a
|
||||||
|
// question about another city (Vikunja #388). She keeps one clock, so every
|
||||||
|
// named place gets the honest "local time only" answer.
|
||||||
|
func TestReplySystemClockCity(t *testing.T) {
|
||||||
|
now := time.Date(2026, 7, 30, 12, 0, 0, 0, time.UTC)
|
||||||
|
h := systemHandler(now)
|
||||||
|
cases := []struct{ utterance, want string }{
|
||||||
|
{"который час", "сейчас 12 часов ровно"},
|
||||||
|
{"который час в киеве", onlyLocalTimeReply},
|
||||||
|
{"сколько времени в москве", onlyLocalTimeReply},
|
||||||
|
{"который час в лондоне", onlyLocalTimeReply},
|
||||||
|
{"который час в бишкеке", onlyLocalTimeReply},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got := h.replySystem(context.Background(), router.Decision{Utterance: c.utterance})
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("replySystem(%q) = %q, want %q", c.utterance, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+119
-11
@@ -752,7 +752,9 @@ func (h *reactiveHandler) applyAction(ctx context.Context, dec router.Decision)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Calendar questions: "что у меня сегодня?", "планы на завтра?"
|
// Calendar questions: "что у меня сегодня?", "планы на завтра?"
|
||||||
if date, ok := router.ParseCalendarDate(dec.Utterance, time.Now()); ok {
|
// h.now(), not time.Now(): the handler's clock is the injected one, so
|
||||||
|
// this arm can be tested at a fixed time like the rest.
|
||||||
|
if date, ok := router.ParseCalendarDate(dec.Utterance, h.now()); ok {
|
||||||
events, err := h.api.CalendarEvents(ctx, date, date.Add(24*time.Hour))
|
events, err := h.api.CalendarEvents(ctx, date, date.Add(24*time.Hour))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("voice: calendar events: %v", err)
|
log.Printf("voice: calendar events: %v", err)
|
||||||
@@ -936,6 +938,101 @@ var ruMonths = []string{
|
|||||||
"июля", "августа", "сентября", "октября", "ноября", "декабря",
|
"июля", "августа", "сентября", "октября", "ноября", "декабря",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// onlyLocalTimeReply — the honest answer when the user asks the time somewhere
|
||||||
|
// other than here. She only keeps one clock, and saying so is better than
|
||||||
|
// naming the wrong city's time.
|
||||||
|
//
|
||||||
|
// There used to be a city→time-zone table here. It was removed on purpose: the
|
||||||
|
// user only ever asks for local time, so the table was a second list of cities
|
||||||
|
// to keep in step with the weather one for no gain.
|
||||||
|
const onlyLocalTimeReply = "я знаю только местное время, про другие города пока не скажу."
|
||||||
|
|
||||||
|
// notPlaceAfterV — words that follow "в" without naming a place, so
|
||||||
|
// mentionsUnknownPlace does not mistake them for a city.
|
||||||
|
var notPlaceAfterV = map[string]bool{
|
||||||
|
"данный": true, "данную": true, "этот": true, "эту": true,
|
||||||
|
"котором": true, "какое": true, "какой": true, "который": true,
|
||||||
|
"общем": true, "точности": true, "курсе": true, "сутках": true,
|
||||||
|
"часах": true, "минутах": true, "секундах": true, "неделе": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// mentionsUnknownPlace reports whether the question has a "в <слово>" phrase
|
||||||
|
// that looks like a place we do not know ("который час в киеве"). Used only to
|
||||||
|
// pick the honest "local time only" reply instead of answering local time as
|
||||||
|
// if it were the city's.
|
||||||
|
func mentionsUnknownPlace(u string) bool {
|
||||||
|
toks := strings.Fields(u)
|
||||||
|
for i := 0; i+1 < len(toks); i++ {
|
||||||
|
if toks[i] != "в" && toks[i] != "во" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
next := strings.Trim(toks[i+1], ".,?!")
|
||||||
|
if next == "" || notPlaceAfterV[next] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// A number after "в" is a clock ("в 5 часов"), not a place.
|
||||||
|
if _, err := strconv.Atoi(strings.SplitN(next, ":", 2)[0]); err == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// onlyNearDaysReply — she can work out today, tomorrow, the day after and
|
||||||
|
// yesterday, and nothing further. Said out loud instead of answering today's
|
||||||
|
// date for a day she did not understand.
|
||||||
|
const onlyNearDaysReply = "я считаю только сегодня, завтра, послезавтра и вчера — про другие дни пока не скажу."
|
||||||
|
|
||||||
|
// dayWords — day references the calendar parser cannot resolve. A weekday name
|
||||||
|
// or a "через …" phrase means he asked about a specific other day.
|
||||||
|
var dayWords = []string{
|
||||||
|
"понедельник", "вторник", "сред", "четверг", "пятниц", "суббот", "воскресен",
|
||||||
|
"через", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday",
|
||||||
|
}
|
||||||
|
|
||||||
|
// mentionsUnknownDay reports whether the question names a day the calendar
|
||||||
|
// parser could not resolve. Mirror of mentionsUnknownPlace: it exists only to
|
||||||
|
// pick an honest reply over a confidently wrong one.
|
||||||
|
//
|
||||||
|
// Only called after ParseCalendarDate has already failed, so "завтра" and the
|
||||||
|
// other words it does know never reach here.
|
||||||
|
func mentionsUnknownDay(u string) bool {
|
||||||
|
for _, w := range dayWords {
|
||||||
|
if strings.Contains(u, w) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ruClock renders the clock part of the time reply: "15 часов 4 минуты".
|
||||||
|
func ruClock(t time.Time) string {
|
||||||
|
h, m := t.Hour(), t.Minute()
|
||||||
|
hourWord := ruPlural(h, "час", "часа", "часов")
|
||||||
|
if m == 0 {
|
||||||
|
return fmt.Sprintf("%d %s ровно", h, hourWord)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d %s %d %s", h, hourWord, m, ruPlural(m, "минута", "минуты", "минут"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// dayPrefix names the day relative to now ("завтра", "вчера", …) so the date
|
||||||
|
// reply opens the way a person would say it.
|
||||||
|
func dayPrefix(now, day time.Time) string {
|
||||||
|
base := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||||
|
switch int(day.Sub(base).Hours() / 24) {
|
||||||
|
case -1:
|
||||||
|
return "вчера"
|
||||||
|
case 0:
|
||||||
|
return "сегодня"
|
||||||
|
case 1:
|
||||||
|
return "завтра"
|
||||||
|
case 2:
|
||||||
|
return "послезавтра"
|
||||||
|
}
|
||||||
|
return "это"
|
||||||
|
}
|
||||||
|
|
||||||
func ruPlural(n int, one, two, many string) string {
|
func ruPlural(n int, one, two, many string) string {
|
||||||
n = n % 100
|
n = n % 100
|
||||||
if n > 10 && n < 20 {
|
if n > 10 && n < 20 {
|
||||||
@@ -1014,18 +1111,29 @@ func (h *reactiveHandler) replySystem(ctx context.Context, dec router.Decision)
|
|||||||
|
|
||||||
switch {
|
switch {
|
||||||
case strings.Contains(u, "час") || strings.Contains(u, "врем"):
|
case strings.Contains(u, "час") || strings.Contains(u, "врем"):
|
||||||
h := now.Hour()
|
// "который час в киеве" — she keeps one clock, so any named place gets
|
||||||
m := now.Minute()
|
// the honest answer. Never local time dressed up as the city's.
|
||||||
hourWord := ruPlural(h, "час", "часа", "часов")
|
if mentionsUnknownPlace(u) {
|
||||||
if m == 0 {
|
return onlyLocalTimeReply
|
||||||
return fmt.Sprintf("сейчас %d %s ровно", h, hourWord)
|
|
||||||
}
|
}
|
||||||
minWord := ruPlural(m, "минута", "минуты", "минут")
|
return "сейчас " + ruClock(now)
|
||||||
return fmt.Sprintf("сейчас %d %s %d %s", h, hourWord, m, minWord)
|
|
||||||
case strings.Contains(u, "день") || strings.Contains(u, "числ"):
|
case strings.Contains(u, "день") || strings.Contains(u, "числ"):
|
||||||
dow := ruWeekdays[now.Weekday()]
|
// "какое число завтра" — answer for the day the user asked about,
|
||||||
month := ruMonths[now.Month()-1]
|
// not today. Reuses the router's calendar day-word parser.
|
||||||
return fmt.Sprintf("сегодня %s, %d %s %d года", dow, now.Day(), month, now.Year())
|
day := now
|
||||||
|
prefix := "сегодня"
|
||||||
|
if d, ok := router.ParseCalendarDate(u, now); ok {
|
||||||
|
day = d
|
||||||
|
prefix = dayPrefix(now, d)
|
||||||
|
} else if mentionsUnknownDay(u) {
|
||||||
|
// He named a day she cannot work out ("в пятницу", "через неделю").
|
||||||
|
// Answering today's date here would be the same silent wrong answer
|
||||||
|
// this arm was fixed for, so say what she can do instead.
|
||||||
|
return onlyNearDaysReply
|
||||||
|
}
|
||||||
|
dow := ruWeekdays[day.Weekday()]
|
||||||
|
month := ruMonths[day.Month()-1]
|
||||||
|
return fmt.Sprintf("%s %s, %d %s %d года", prefix, dow, day.Day(), month, day.Year())
|
||||||
case strings.Contains(u, "кто дома") || strings.Contains(u, "человек дома"):
|
case strings.Contains(u, "кто дома") || strings.Contains(u, "человек дома"):
|
||||||
return "присутствие пока не подключено к голосовому запросу."
|
return "присутствие пока не подключено к голосовому запросу."
|
||||||
case strings.Contains(u, "памят") || strings.Contains(u, "процессор") || strings.Contains(u, "загрузк") || strings.Contains(u, "статус") || strings.Contains(u, "работа") || strings.Contains(u, "сервис") || strings.Contains(u, "диск") || strings.Contains(u, "ip") || strings.Contains(u, "аптайм") || strings.Contains(u, "трафик") || strings.Contains(u, "интернет"):
|
case strings.Contains(u, "памят") || strings.Contains(u, "процессор") || strings.Contains(u, "загрузк") || strings.Contains(u, "статус") || strings.Contains(u, "работа") || strings.Contains(u, "сервис") || strings.Contains(u, "диск") || strings.Contains(u, "ip") || strings.Contains(u, "аптайм") || strings.Contains(u, "трафик") || strings.Contains(u, "интернет"):
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
{ "id": "ru-query-011", "utterance": "почему сервер тормозит", "lang": "ru", "intent": "query", "tags": ["homelab", "hard"], "note": "diagnostic question, not a chat opener" },
|
{ "id": "ru-query-011", "utterance": "почему сервер тормозит", "lang": "ru", "intent": "query", "tags": ["homelab", "hard"], "note": "diagnostic question, not a chat opener" },
|
||||||
{ "id": "ru-query-012", "utterance": "какие заметки я оставил про полив", "lang": "ru", "intent": "query", "tags": ["recall"] },
|
{ "id": "ru-query-012", "utterance": "какие заметки я оставил про полив", "lang": "ru", "intent": "query", "tags": ["recall"] },
|
||||||
{ "id": "ru-query-013", "utterance": "во сколько у меня встреча", "lang": "ru", "intent": "query", "tags": ["calendar"] },
|
{ "id": "ru-query-013", "utterance": "во сколько у меня встреча", "lang": "ru", "intent": "query", "tags": ["calendar"] },
|
||||||
|
{ "id": "ru-query-019", "utterance": "что у меня стоит в календаре на послезавтра", "lang": "ru", "intent": "query", "tags": ["calendar", "hard"], "note": "agenda, not the clock: the daemon answers this from CalendarEvents inside the query branch, so the clock/date system rule must not swallow it" },
|
||||||
{ "id": "ru-query-014", "utterance": "я успеваю до дедлайна", "lang": "ru", "intent": "query", "tags": ["hard", "no-question-word"] },
|
{ "id": "ru-query-014", "utterance": "я успеваю до дедлайна", "lang": "ru", "intent": "query", "tags": ["hard", "no-question-word"] },
|
||||||
{ "id": "ru-query-015", "utterance": "сколько я прошёл шагов", "lang": "ru", "intent": "query", "tags": ["aggregate"] },
|
{ "id": "ru-query-015", "utterance": "сколько я прошёл шагов", "lang": "ru", "intent": "query", "tags": ["aggregate"] },
|
||||||
{ "id": "ru-query-016", "utterance": "покажи давление за неделю", "lang": "ru", "intent": "query", "tags": ["hard", "imperative"], "note": "imperative form but a read — must not route to act" },
|
{ "id": "ru-query-016", "utterance": "покажи давление за неделю", "lang": "ru", "intent": "query", "tags": ["hard", "imperative"], "note": "imperative form but a read — must not route to act" },
|
||||||
|
|||||||
@@ -44,10 +44,21 @@ ws ::= [ \t\n]*
|
|||||||
// Changed again 31-07-2026: added the "unknown" escape hatch so the model can
|
// Changed again 31-07-2026: added the "unknown" escape hatch so the model can
|
||||||
// admit it cannot route (Vikunja #359).
|
// admit it cannot route (Vikunja #359).
|
||||||
//
|
//
|
||||||
|
// Changed again 31-07-2026: added the clock/calendar rule (Vikunja #374). The
|
||||||
|
// prompt never said which side "который час" or "какое число завтра" belong on,
|
||||||
|
// so the model guessed — `system→query ×4` in every eval run. The rule sits
|
||||||
|
// above the question test on purpose: these utterances all carry a question
|
||||||
|
// word, so a later rule would never be reached. The boundary is what the
|
||||||
|
// daemon can actually answer: only replySystem in cmd/mavend/voice.go owns the
|
||||||
|
// clock and the calendar formatter, while the agenda ("что у меня завтра") is
|
||||||
|
// answered inside the query branch, so that side stays query.
|
||||||
|
//
|
||||||
// The training workspace keeps its own copy of this prompt for relabelling, and
|
// The training workspace keeps its own copy of this prompt for relabelling, and
|
||||||
// `llm/check_prompt_parity.py` there compares the two. That copy is in another
|
// `llm/check_prompt_parity.py` there compares the two. That copy is in another
|
||||||
// repo and was not touched, so parity will fail until it gets the same edits —
|
// repo and was not touched, so parity will fail until it gets the same edits —
|
||||||
// both the rule reorder and the "unknown" wording (Vikunja #362).
|
// both the rule reorder and the "unknown" wording (Vikunja #362) — and now the
|
||||||
|
// clock/calendar rule too. The training workspace is not checked out on this
|
||||||
|
// box at all, so it could not be updated here; #362 still covers the catch-up.
|
||||||
const routeSystem = `Классифицируй ровно одно сообщение пользователя. Верни ОДИН JSON-массив действий.
|
const routeSystem = `Классифицируй ровно одно сообщение пользователя. Верни ОДИН JSON-массив действий.
|
||||||
|
|
||||||
Ровно одно намерение: fact, reminder, note, query, act, chat, system.
|
Ровно одно намерение: fact, reminder, note, query, act, chat, system.
|
||||||
@@ -56,19 +67,21 @@ const routeSystem = `Классифицируй ровно одно сообще
|
|||||||
Классифицируй по цели пользователя. Порядок решения:
|
Классифицируй по цели пользователя. Порядок решения:
|
||||||
1. Хочет напоминание в будущем → reminder
|
1. Хочет напоминание в будущем → reminder
|
||||||
2. Явно просит сохранить информацию → note
|
2. Явно просит сохранить информацию → note
|
||||||
3. Задаёт вопрос: есть вопросительное слово (сколько, что, какой, когда, где, кто, почему, как) или знак «?» → query
|
3. Спрашивает только «который час» / «какое число» / «какой день недели» — сами часы или календарная дата, без своих данных → system
|
||||||
4. Хочет получить информацию, в том числе о своих же данных → query
|
4. Задаёт вопрос: есть вопросительное слово (сколько, что, какой, когда, где, кто, почему, как) или знак «?» → query
|
||||||
5. Утверждает: сообщает или обновляет текущее состояние/событие → fact
|
5. Хочет получить информацию, в том числе о своих же данных → query
|
||||||
6. Просит выполнить работу → act
|
6. Утверждает: сообщает или обновляет текущее состояние/событие → fact
|
||||||
7. Про ассистента, настройки или память → system
|
7. Просит выполнить работу → act
|
||||||
8. Реплика — обрывок или указание на неназванное («это», «то», «потом»), и без него непонятно, что именно нужно сделать → unknown
|
8. Про ассистента, настройки или память → system
|
||||||
9. Иначе → chat
|
9. Реплика — обрывок или указание на неназванное («это», «то», «потом»), и без него непонятно, что именно нужно сделать → unknown
|
||||||
|
10. Иначе → chat
|
||||||
|
|
||||||
Различия:
|
Различия:
|
||||||
- note — сохранить информацию, без напоминания. text = суть.
|
- note — сохранить информацию, без напоминания. text = суть.
|
||||||
- reminder — уведомить позже. text = что напомнить.
|
- reminder — уведомить позже. text = что напомнить.
|
||||||
- fact — неявное обновление: пользователь сообщает, что что-то в мире изменилось (текущее/изменённое состояние, случившееся событие). key/value.
|
- fact — неявное обновление: пользователь сообщает, что что-то в мире изменилось (текущее/изменённое состояние, случившееся событие). key/value.
|
||||||
- unknown — редкий случай. Ставь его, только если в самой реплике нет ни предмета, ни действия. Короткая, простая или незнакомая тема — это не причина для unknown: приветствие и болтовня — это chat, вопрос на любую тему — это query, просьба сделать что-то названное — это act.
|
- unknown — редкий случай. Ставь его, только если в самой реплике нет ни предмета, ни действия. Короткая, простая или незнакомая тема — это не причина для unknown: приветствие и болтовня — это chat, вопрос на любую тему — это query, просьба сделать что-то названное — это act.
|
||||||
|
- system против query — часы и календарная дата сами по себе (сколько времени, какое число, какой день недели — можно и про завтра, и про другой город) — это system. А что записано в календаре или в памяти («что у меня завтра», «какие есть напоминания») — это query. Если в реплике есть просьба (напомни, запиши, сделай), то названное время — просто деталь просьбы, и это не system.
|
||||||
- query против fact — решает форма реплики, а не тема. Вопрос о состоянии — это query, даже если названо то же самое, что бывает в fact. Только утверждение — это fact.
|
- query против fact — решает форма реплики, а не тема. Вопрос о состоянии — это query, даже если названо то же самое, что бывает в fact. Только утверждение — это fact.
|
||||||
|
|
||||||
Примеры:
|
Примеры:
|
||||||
@@ -82,6 +95,8 @@ const routeSystem = `Классифицируй ровно одно сообще
|
|||||||
"что такое docker?" → {"intent":"query","text":"что такое docker"}
|
"что такое docker?" → {"intent":"query","text":"что такое docker"}
|
||||||
"напиши письмо" → {"intent":"act","verb":"написать письмо"}
|
"напиши письмо" → {"intent":"act","verb":"написать письмо"}
|
||||||
"очисти память" → {"intent":"system"}
|
"очисти память" → {"intent":"system"}
|
||||||
|
"который час?" → {"intent":"system"}
|
||||||
|
"какое число завтра?" → {"intent":"system"}
|
||||||
"привет" → {"intent":"chat","text":"привет"}
|
"привет" → {"intent":"chat","text":"привет"}
|
||||||
"сделай это" → {"intent":"unknown"}
|
"сделай это" → {"intent":"unknown"}
|
||||||
"ну это" → {"intent":"unknown"}
|
"ну это" → {"intent":"unknown"}
|
||||||
|
|||||||
@@ -449,16 +449,30 @@ func (AnaphoraResolver) Resolve(text string) (ref string, ok bool) {
|
|||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseCalendarDate detects RU calendar date words in text and returns the
|
// ParseCalendarDate detects RU/EN calendar day words in text and returns
|
||||||
// resolved time (midnight UTC+0 for "сегодня"/"today", next day for "завтра"/"tomorrow").
|
// midnight of that day in now's own time zone. Handles "сегодня", "завтра",
|
||||||
// Returns zero time + false if no match.
|
// "послезавтра", "вчера" (and the English words). Returns zero time + false
|
||||||
|
// if no match.
|
||||||
|
//
|
||||||
|
// "послезавтра" is checked before "завтра" because it contains it.
|
||||||
func ParseCalendarDate(text string, now time.Time) (time.Time, bool) {
|
func ParseCalendarDate(text string, now time.Time) (time.Time, bool) {
|
||||||
lower := strings.ToLower(text)
|
lower := strings.ToLower(text)
|
||||||
if strings.Contains(lower, "сегодня") || strings.Contains(lower, "today") {
|
switch {
|
||||||
return now.Truncate(24 * time.Hour), true
|
case strings.Contains(lower, "сегодня") || strings.Contains(lower, "today"):
|
||||||
}
|
return midnight(now, 0), true
|
||||||
if strings.Contains(lower, "завтра") || strings.Contains(lower, "tomorrow") {
|
case strings.Contains(lower, "послезавтра") || strings.Contains(lower, "day after tomorrow"):
|
||||||
return now.Truncate(24 * time.Hour).Add(24 * time.Hour), true
|
return midnight(now, 2), true
|
||||||
|
case strings.Contains(lower, "завтра") || strings.Contains(lower, "tomorrow"):
|
||||||
|
return midnight(now, 1), true
|
||||||
|
case strings.Contains(lower, "вчера") || strings.Contains(lower, "yesterday"):
|
||||||
|
return midnight(now, -1), true
|
||||||
}
|
}
|
||||||
return time.Time{}, false
|
return time.Time{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// midnight returns the start of the day that is `days` away from now, in
|
||||||
|
// now's time zone (now.Truncate(24h) would cut on a UTC boundary instead).
|
||||||
|
func midnight(now time.Time, days int) time.Time {
|
||||||
|
y, m, d := now.AddDate(0, 0, days).Date()
|
||||||
|
return time.Date(y, m, d, 0, 0, 0, 0, now.Location())
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ func TestParseCalendarDate(t *testing.T) {
|
|||||||
{"расписание на завтра", time.Date(2026, 7, 7, 0, 0, 0, 0, time.UTC), true},
|
{"расписание на завтра", time.Date(2026, 7, 7, 0, 0, 0, 0, time.UTC), true},
|
||||||
{"what's today", time.Date(2026, 7, 6, 0, 0, 0, 0, time.UTC), true},
|
{"what's today", time.Date(2026, 7, 6, 0, 0, 0, 0, time.UTC), true},
|
||||||
{"tomorrow plans", time.Date(2026, 7, 7, 0, 0, 0, 0, time.UTC), true},
|
{"tomorrow plans", time.Date(2026, 7, 7, 0, 0, 0, 0, time.UTC), true},
|
||||||
|
{"какое число послезавтра", time.Date(2026, 7, 8, 0, 0, 0, 0, time.UTC), true},
|
||||||
|
{"что было вчера", time.Date(2026, 7, 5, 0, 0, 0, 0, time.UTC), true},
|
||||||
|
{"yesterday plans", time.Date(2026, 7, 5, 0, 0, 0, 0, time.UTC), true},
|
||||||
{"какая погода", time.Time{}, false},
|
{"какая погода", time.Time{}, false},
|
||||||
{"сколько времени", time.Time{}, false},
|
{"сколько времени", time.Time{}, false},
|
||||||
{"", time.Time{}, false},
|
{"", time.Time{}, false},
|
||||||
|
|||||||
Reference in New Issue
Block a user