Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b994ff1c3 | |||
| 0b1efe4911 | |||
| 580959f856 | |||
| bf2587c7fa | |||
| 26ff646ace | |||
| 9e1958e7b0 | |||
| e6923490fd | |||
| d7a43afd90 | |||
| fabc3bc274 | |||
| b6eed20af2 | |||
| 936c6d71db | |||
| 72aa97dae8 | |||
| 1c0a1d0db0 | |||
| 37feee1eb3 | |||
| 94c273780a | |||
| 93c08f9de1 | |||
| 4f96bbd6ec | |||
| 7dba1b7935 | |||
| 8102c73f83 | |||
| 8c36e7ef84 | |||
| 95cbf82e38 | |||
| 5bca435146 | |||
| 69270f4cfb | |||
| 01230bf16b | |||
| ebce90b984 | |||
| 04584fb2da | |||
| 5447f08c06 | |||
| 650363ce67 | |||
| 85456d3833 | |||
| 901354002e | |||
| f4a021d3da | |||
| 316fb197a8 | |||
| 67decc42f0 | |||
| 201fe03d20 | |||
| fec572c997 | |||
| 5187f3bd14 | |||
| 1b5d093148 | |||
| 502327678f | |||
| 5d2fd91c06 | |||
| 33c2d782a9 | |||
| e8ece874b1 | |||
| 1fa14e95a4 | |||
| dd6da78aeb |
@@ -31,15 +31,11 @@ import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/audio"
|
||||
"github.com/kami/maven/internal/voice"
|
||||
@@ -155,9 +151,3 @@ func writeWAV(path string, a audio.Audio) error {
|
||||
// jsonUnmarshal — kept local rather than pulling encoding/json into main.go
|
||||
// top-level space.
|
||||
func jsonUnmarshal(b []byte, v any) error { return json.Unmarshal(b, v) }
|
||||
|
||||
// keep strconv + io + net + time alive for future duration/size helpers.
|
||||
var _ = strconv.Atoi
|
||||
var _ io.Reader = (io.Reader)(nil)
|
||||
var _ = net.IPv4
|
||||
var _ = time.Second
|
||||
|
||||
+29
-7
@@ -214,7 +214,7 @@ func (h *reactiveHandler) clarifyQuestion(dec router.Decision) (dialogue.Slot, s
|
||||
if len(missing) == 0 {
|
||||
return "", "", false
|
||||
}
|
||||
q, ok := h.questionFor(missing[0], 1, dec.Utterance, toDialogueSlots(dec.Slots))
|
||||
q, ok := h.questionFor(missing[0], 1, dec.Utterance, toDialogueSlots(dec.Slots), "")
|
||||
if !ok {
|
||||
return "", "", false
|
||||
}
|
||||
@@ -225,11 +225,14 @@ func (h *reactiveHandler) clarifyQuestion(dec router.Decision) (dialogue.Slot, s
|
||||
// reads its deck by attempt; the time asks about whichever of the hour, the half
|
||||
// of the day and the day he has not said, and states the clock while it does
|
||||
// (V-579).
|
||||
func (h *reactiveHandler) questionFor(slot dialogue.Slot, attempt int, utterance string, s dialogue.Slots) (string, bool) {
|
||||
//
|
||||
// taken is the acknowledgement of what his last turn added, empty when it added
|
||||
// nothing and empty for a first ask, which has no turn behind it (V-593).
|
||||
func (h *reactiveHandler) questionFor(slot dialogue.Slot, attempt int, utterance string, s dialogue.Slots, taken string) (string, bool) {
|
||||
if slot != dialogue.SlotTime {
|
||||
return clarifyQuestionFor(slot, attempt)
|
||||
}
|
||||
return whenQuestion(whenGapOf(utterance, s.HasTime), attempt, h.now())
|
||||
return whenQuestion(whenGapOf(utterance, s.HasTime), attempt, h.now(), taken)
|
||||
}
|
||||
|
||||
// askClarify parks the request and returns the question to ask instead of the
|
||||
@@ -384,18 +387,35 @@ func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string)
|
||||
// as the reminder payload — so a reminder clarified out of a bare "напомни"
|
||||
// would fire at 11:00 saying "напомни" and nothing else.
|
||||
q.Utterance = foldAnswerIntoUtterance(q.Utterance, merged.Text)
|
||||
// A fact answers with a key and a value and fills no Text slot at all, so
|
||||
// the fold above leaves the utterance at the bare "запиши" — and that is
|
||||
// what the confirmation now reads back to him (V-592). His raw words are the
|
||||
// only record of what he said, so they are what is folded. Never for a time
|
||||
// question: what he says about when is kept apart in WhenText on purpose,
|
||||
// or the reminder would read the day back at him when it fires.
|
||||
if merged.Text == "" && !asksAboutTime(q.Missing) {
|
||||
q.Utterance = foldAnswerIntoUtterance(q.Utterance, text)
|
||||
}
|
||||
// An answer about the time joins everything else he has said about the time,
|
||||
// and the whole of it is re-read as one request (V-579). "завтра" names the
|
||||
// day of an hour she is already holding, and read alone it names no hour at
|
||||
// all, so the parser would have nothing and she would ask for ever.
|
||||
// What he had already said about the time, read BEFORE this answer joins it.
|
||||
// A re-ask that cannot tell the two apart is the one that repeats itself
|
||||
// byte for byte (V-593).
|
||||
var taken string
|
||||
if asksAboutTime(q.Missing) {
|
||||
before := whenKnownOf(whenTextOf(q), q.Slots.HasTime)
|
||||
q.WhenText = strings.TrimSpace(q.WhenText + " " + text)
|
||||
if t, ok := h.readWhen(ctx, intent, q, text); ok {
|
||||
merged.Time, merged.HasTime = t, true
|
||||
}
|
||||
if before.movedForward(whenKnownOf(whenTextOf(q), merged.HasTime)) {
|
||||
taken = whenTakenLine(text)
|
||||
}
|
||||
}
|
||||
if stillOpen(q.Missing, whenTextOf(q), merged) {
|
||||
return h.reaskOrGiveUp(ctx, q, merged, text), true
|
||||
return h.reaskOrGiveUp(ctx, q, merged, text, taken), true
|
||||
}
|
||||
h.clarifyStore.Delete(dialogueIDOf(ctx))
|
||||
|
||||
@@ -496,7 +516,7 @@ func (h *reactiveHandler) askRemainingGap(ctx context.Context, q *dialogue.Pendi
|
||||
}
|
||||
// Attempts+1 is the question she is about to ask, and the budget is shared
|
||||
// with the re-ask path, so the second gap is worded like a second try.
|
||||
question, ok := h.questionFor(remaining[0], q.Attempts+1, whenTextOf(q), merged)
|
||||
question, ok := h.questionFor(remaining[0], q.Attempts+1, whenTextOf(q), merged, "")
|
||||
if !ok || !q.CanAsk() {
|
||||
return "", false
|
||||
}
|
||||
@@ -518,10 +538,12 @@ func (h *reactiveHandler) askRemainingGap(ctx context.Context, q *dialogue.Pendi
|
||||
// reaskOrGiveUp handles an answer that left the gap open: ask the same question
|
||||
// again while she has attempts left, otherwise say she did not understand and
|
||||
// let the request go. Never returns "" — a mute give-up reads as "done".
|
||||
func (h *reactiveHandler) reaskOrGiveUp(ctx context.Context, q *dialogue.PendingQuestion, merged dialogue.Slots, text string) string {
|
||||
// taken is the acknowledgement of what this answer DID give, empty when it gave
|
||||
// nothing (V-593). The give-up line never carries it: it is not another ask.
|
||||
func (h *reactiveHandler) reaskOrGiveUp(ctx context.Context, q *dialogue.PendingQuestion, merged dialogue.Slots, text, taken string) string {
|
||||
question := ""
|
||||
if len(q.Missing) > 0 {
|
||||
question, _ = h.questionFor(q.Missing[0], q.Attempts+1, whenTextOf(q), merged)
|
||||
question, _ = h.questionFor(q.Missing[0], q.Attempts+1, whenTextOf(q), merged, taken)
|
||||
}
|
||||
if question == "" || !q.CanAsk() {
|
||||
h.clarifyStore.Delete(dialogueIDOf(ctx))
|
||||
|
||||
@@ -169,11 +169,11 @@ func TestClarifyAsksThreeTimesThenSaysSo(t *testing.T) {
|
||||
}
|
||||
// The wording changes with the attempt (Vikunja #457): repeating a
|
||||
// question he already failed to answer is the worst way to ask it.
|
||||
want, _ := whenQuestion(whenNoHour, i, h.now())
|
||||
want, _ := whenQuestion(whenNoHour, i, h.now(), "")
|
||||
if reply != want {
|
||||
t.Fatalf("attempt %d should ask again as %q, got %q", i, want, reply)
|
||||
}
|
||||
if first, _ := whenQuestion(whenNoHour, 1, h.now()); reply == first {
|
||||
if first, _ := whenQuestion(whenNoHour, 1, h.now(), ""); reply == first {
|
||||
t.Fatalf("attempt %d repeated the first wording: %q", i, reply)
|
||||
}
|
||||
if h.clarifyStore.Get(voiceDialogueID, h.now()) == nil {
|
||||
@@ -368,7 +368,7 @@ func TestClarifyAsksAboutTheSecondGapToo(t *testing.T) {
|
||||
}
|
||||
// Second gap, second attempt, so it is the second wording of the time
|
||||
// question — the attempt budget is shared between the two paths.
|
||||
want, _ := whenQuestion(whenNoHour, 2, h.now())
|
||||
want, _ := whenQuestion(whenNoHour, 2, h.now(), "")
|
||||
if reply != want {
|
||||
t.Fatalf("a filled subject with no time must ask about the time as %q, got %q", want, reply)
|
||||
}
|
||||
@@ -685,7 +685,7 @@ func TestIncompleteReminderAsksInsteadOfFailing(t *testing.T) {
|
||||
h, st := newRoutingClarifyHandler(t)
|
||||
|
||||
reply := h.handleText(ctx, "web", "напомни позвонить маме")
|
||||
want, _ := whenQuestion(whenNoHour, 1, h.now())
|
||||
want, _ := whenQuestion(whenNoHour, 1, h.now(), "")
|
||||
if reply != want {
|
||||
t.Fatalf("reply = %q, want the time question %q", reply, want)
|
||||
}
|
||||
|
||||
@@ -173,6 +173,13 @@ func (a *crawlFetcher) Get(ctx context.Context, u string) (*crawl.Response, erro
|
||||
case errors.Is(err, webfetch.ErrBlocked), errors.Is(err, webfetch.ErrPrivate), errors.Is(err, webfetch.ErrScheme):
|
||||
return nil, fmt.Errorf("%w: %v", crawl.ErrFetchRefused, err)
|
||||
case errors.Is(err, webfetch.ErrStatus):
|
||||
// Carry the code across the seam. The crawler needs to tell a 5xx
|
||||
// from a 404 to decide what a failed robots.txt means, and it must
|
||||
// not learn that by reading this sentence.
|
||||
var se *webfetch.StatusError
|
||||
if errors.As(err, &se) {
|
||||
return nil, &crawl.StatusError{Code: se.Code}
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %v", crawl.ErrFetchStatus, err)
|
||||
}
|
||||
return nil, err
|
||||
|
||||
@@ -118,7 +118,14 @@ type turn struct {
|
||||
attempt int
|
||||
// gap — which part of the time she is asking about, for a SlotTime question
|
||||
// (V-579). Zero value is the missing hour, which is what she asks first.
|
||||
gap whenGap
|
||||
gap whenGap
|
||||
// took — the words of the PREVIOUS turn that this ask must acknowledge
|
||||
// before asking again (V-593). Empty ⇒ the ask carries no acknowledgement,
|
||||
// which is right for a first ask and for an answer that moved nothing.
|
||||
took string
|
||||
// differs — this reply must not be byte-identical to the one before it. Set
|
||||
// on a re-ask whose turn moved the request forward (V-593).
|
||||
differs bool
|
||||
contains []string
|
||||
notContain []string
|
||||
// noQuestion — the reply must not be any clarify question. Used where the
|
||||
@@ -178,7 +185,7 @@ func wantedQuestion(tn turn, now time.Time) (string, bool) {
|
||||
if gap == whenComplete {
|
||||
gap = whenNoHour
|
||||
}
|
||||
return whenQuestion(gap, tn.attempt, now)
|
||||
return whenQuestion(gap, tn.attempt, now, whenTakenLine(tn.took))
|
||||
}
|
||||
return clarifyQuestionFor(tn.question, tn.attempt)
|
||||
}
|
||||
@@ -201,6 +208,7 @@ func runTrace(t *testing.T, tr trace) {
|
||||
id := dialogueIDFor(sourceText, conversation)
|
||||
|
||||
var claims []claim
|
||||
var previous string
|
||||
fail := func(turnIdx int, format string, args ...any) {
|
||||
t.Helper()
|
||||
lines := make([]string, 0, len(claims))
|
||||
@@ -259,6 +267,10 @@ func runTrace(t *testing.T, tr trace) {
|
||||
fail(i, "reply %q carries %q and must not", body, unwanted)
|
||||
}
|
||||
}
|
||||
if tn.differs && reply == previous {
|
||||
fail(i, "reply %q is byte-identical to the one before it, and his turn between them answered part of the gap", reply)
|
||||
}
|
||||
previous = reply
|
||||
checkParked(t, fail, i, h.clarifyStore.Get(id, h.now()), tn.parked)
|
||||
}
|
||||
checkEnd(t, ctx, st, h, tr.end, claims)
|
||||
@@ -400,7 +412,7 @@ func dialogueTraces() []trace {
|
||||
turns: []turn{
|
||||
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "маме"}},
|
||||
{say: "в 11:00", question: dialogue.SlotTime, attempt: 2, gap: whenNoDay,
|
||||
{say: "в 11:00", question: dialogue.SlotTime, attempt: 2, gap: whenNoDay, took: "в 11:00",
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 2, carries: "маме"}},
|
||||
{say: "сегодня", contains: []string{"11:00"}, notContain: []string{"?"}},
|
||||
},
|
||||
@@ -413,7 +425,9 @@ func dialogueTraces() []trace {
|
||||
turns: []turn{
|
||||
{say: "запиши", question: dialogue.SlotKey, attempt: 1,
|
||||
parked: &parkedWant{slot: dialogue.SlotKey, attempt: 1}},
|
||||
{say: "пил воду", contains: []string{"water"}},
|
||||
// His words back, not the key the parser filed them under
|
||||
// (V-592). "water" is machine vocabulary and he never said it.
|
||||
{say: "пил воду", contains: []string{"пил воду"}},
|
||||
},
|
||||
end: endState{factKeys: []string{"water"}},
|
||||
},
|
||||
@@ -522,8 +536,42 @@ func dialogueTraces() []trace {
|
||||
turns: []turn{
|
||||
{say: "напомни позвонить врачу", question: dialogue.SlotTime, attempt: 1,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "врачу"}},
|
||||
// Nothing she says about it may be a word he did not say
|
||||
// (V-592). On the box this sentence came back as "Проверила, что
|
||||
// ты выпел стакан воды": a non-word for the verb, a glass copied
|
||||
// out of the example in ReplySystemPrompt, and a claim to have
|
||||
// checked something. The store held key=water value="drank"
|
||||
// throughout, so all of it was generated from two tokens.
|
||||
//
|
||||
// The positive half of the contract — the confirmation IS his
|
||||
// sentence — is asserted by "fact completed over two turns"
|
||||
// above. It cannot be asserted here: the hash embedder marks
|
||||
// this route Clarify, and an unsure fact is answered with the
|
||||
// canned line rather than a confirmation of anything.
|
||||
{say: "я выпил воды", contains: []string{"напоминание?"},
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "врачу"}},
|
||||
notContain: []string{"стакан", "выпел", "Проверила", "water"},
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "врачу"}},
|
||||
},
|
||||
end: endState{},
|
||||
},
|
||||
// V-593: two asks about the same half of the day, with a turn between
|
||||
// them that answered the DAY. Asking again is right and asking in the
|
||||
// same bytes is not — from his side it is indistinguishable from not
|
||||
// having been heard, which is what the whole V-558 family is about.
|
||||
//
|
||||
// The clock still opens every ask (the owner's rule, V-579); the
|
||||
// acknowledgement goes after it and before the question.
|
||||
{
|
||||
name: "a re-ask names what the answer before it gave her",
|
||||
turns: []turn{
|
||||
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
|
||||
{say: "на 9", question: dialogue.SlotTime, attempt: 2, gap: whenAmbiguousHour, took: "на 9",
|
||||
contains: []string{"Сейчас "},
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 2}},
|
||||
{say: "на завтра", question: dialogue.SlotTime, attempt: 3, gap: whenAmbiguousHour, took: "на завтра",
|
||||
contains: []string{"Сейчас ", "завтра"}, differs: true,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 3}},
|
||||
},
|
||||
end: endState{},
|
||||
},
|
||||
@@ -540,9 +588,9 @@ func dialogueTraces() []trace {
|
||||
turns: []turn{
|
||||
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
|
||||
{say: "на 9", question: dialogue.SlotTime, attempt: 2, gap: whenAmbiguousHour,
|
||||
{say: "на 9", question: dialogue.SlotTime, attempt: 2, gap: whenAmbiguousHour, took: "на 9",
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 2}},
|
||||
{say: "утра", question: dialogue.SlotTime, attempt: 3, gap: whenNoDay,
|
||||
{say: "утра", question: dialogue.SlotTime, attempt: 3, gap: whenNoDay, took: "утра",
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 3}},
|
||||
{say: "завтра", contains: []string{"09:00"}},
|
||||
},
|
||||
@@ -657,9 +705,13 @@ func dialogueTraces() []trace {
|
||||
// the day, so she says the clock she is reading from and asks.
|
||||
// "на завтра." then answers the day and leaves the half open, so
|
||||
// she asks that one again.
|
||||
{say: "а, да, прости - на 9.", question: dialogue.SlotTime, attempt: 2, gap: whenAmbiguousHour,
|
||||
// Each ask names what the turn before it gave her (V-593). The
|
||||
// two asks about the half of the day are the same question and
|
||||
// must not be the same sentence: he answered between them, and a
|
||||
// reply with no trace of that reads as not having been heard.
|
||||
{say: "а, да, прости - на 9.", question: dialogue.SlotTime, attempt: 2, gap: whenAmbiguousHour, took: "а, да, прости - на 9.",
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 2}},
|
||||
{say: "на завтра.", question: dialogue.SlotTime, attempt: 3, gap: whenAmbiguousHour,
|
||||
{say: "на завтра.", question: dialogue.SlotTime, attempt: 3, gap: whenAmbiguousHour, took: "на завтра.",
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 3}},
|
||||
},
|
||||
end: endState{},
|
||||
@@ -679,7 +731,7 @@ func dialogueTraces() []trace {
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
|
||||
{say: "какая сейчас погода в Риме?",
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "маме"}},
|
||||
{say: "в 11:00", question: dialogue.SlotTime, attempt: 2, gap: whenNoDay,
|
||||
{say: "в 11:00", question: dialogue.SlotTime, attempt: 2, gap: whenNoDay, took: "в 11:00",
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 2, carries: "маме"}},
|
||||
{say: "сегодня", contains: []string{"11:00"}},
|
||||
},
|
||||
|
||||
+54
-1
@@ -12,6 +12,7 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
hexisclient "github.com/kami/hexis/pkg/client"
|
||||
@@ -179,6 +180,54 @@ func httpError(service, op string, status int) *ecosystemError {
|
||||
}
|
||||
}
|
||||
|
||||
// hexisStatusTexts maps the http.StatusText spelling back to its code, for the
|
||||
// failure statuses a Hexis call can plausibly answer with. It is the inverse of
|
||||
// what the vendored client threw away.
|
||||
var hexisStatusTexts = func() map[string]int {
|
||||
codes := []int{
|
||||
http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden,
|
||||
http.StatusNotFound, http.StatusMethodNotAllowed, http.StatusNotAcceptable,
|
||||
http.StatusRequestTimeout, http.StatusConflict, http.StatusGone,
|
||||
http.StatusUnprocessableEntity, http.StatusUpgradeRequired,
|
||||
http.StatusTooManyRequests, http.StatusInternalServerError,
|
||||
http.StatusNotImplemented, http.StatusBadGateway,
|
||||
http.StatusServiceUnavailable, http.StatusGatewayTimeout,
|
||||
}
|
||||
m := make(map[string]int, len(codes))
|
||||
for _, c := range codes {
|
||||
m[http.StatusText(c)] = c
|
||||
}
|
||||
return m
|
||||
}()
|
||||
|
||||
// hexisError re-wraps an error from the vendored Hexis client as an
|
||||
// *ecosystemError, so a Hexis failure classifies the same way a Nexus or Praxis
|
||||
// one does and ecosystemGap can tell a refused credential from an outage.
|
||||
//
|
||||
// This is a boundary adapter and it is not the fix anyone would choose. The
|
||||
// Hexis client lives in another repository and returns
|
||||
// fmt.Errorf("%s: %s", http.StatusText(status), body) for every status at or
|
||||
// above 400, so the status text is the only signal that survives — the correct
|
||||
// fix is a typed error carrying the code, and Maven cannot land it unilaterally
|
||||
// (Vikunja #587, docs/plans/20-two-artifacts-and-neither-is-spring.md). Parsing
|
||||
// here is bounded: the message's first colon-delimited segment is the status
|
||||
// text verbatim, no status text contains a colon, and anything unrecognised —
|
||||
// "do request: ...", "create request: ..." — is a transport failure and is left
|
||||
// at status 0, which is exactly what Unreachable() means.
|
||||
func hexisError(op string, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var ee *ecosystemError
|
||||
if errors.As(err, &ee) {
|
||||
return err
|
||||
}
|
||||
head, _, _ := strings.Cut(err.Error(), ": ")
|
||||
return &ecosystemError{
|
||||
Service: "hexis", Op: op, Status: hexisStatusTexts[head], Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
type nexusClient struct {
|
||||
ecosystemHTTP
|
||||
}
|
||||
@@ -530,6 +579,7 @@ func (w *ecosystemWiring) discoverCapabilities(ctx context.Context, entityID str
|
||||
}
|
||||
caps, err := w.hexis.Capabilities(ctx, entityID)
|
||||
if err != nil {
|
||||
err = hexisError("capabilities", err)
|
||||
log.Printf("ecosystem: hexis capabilities error: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
@@ -557,7 +607,10 @@ func (w *ecosystemWiring) executeCapability(ctx context.Context, capabilityID, t
|
||||
|
||||
exec, err := w.hexis.Execute(ctx, req)
|
||||
if err != nil {
|
||||
return correlationID, fmt.Errorf("execute: %w", err)
|
||||
// A classified dependency failure. The two returns below are NOT: an
|
||||
// execution that ran and failed is the command failing, not Hexis
|
||||
// degrading, and it keeps its plain error so the caller says so.
|
||||
return correlationID, hexisError("execute", err)
|
||||
}
|
||||
if exec.Status == "succeeded" {
|
||||
return correlationID, nil
|
||||
|
||||
@@ -22,8 +22,9 @@ import (
|
||||
// already knows which one it was talking to — it records the same name in the
|
||||
// trace (Vikunja #521).
|
||||
const (
|
||||
serviceNexus = "Nexus"
|
||||
serviceHexis = "Hexis"
|
||||
serviceNexus = "Nexus"
|
||||
servicePraxis = "Praxis"
|
||||
serviceHexis = "Hexis"
|
||||
)
|
||||
|
||||
// serviceVars — the one-key map the eco_down and eco_denied lines take.
|
||||
@@ -141,7 +142,13 @@ type praxisItemAction struct {
|
||||
verbs []string
|
||||
ask string // reply when no item id was given
|
||||
op string // trace + log name of the operation
|
||||
failure string // reply when the Praxis call errors
|
||||
// failure is the first half of the reply when the Praxis call errors: which
|
||||
// operation did not happen. ecosystemGap supplies the second half, which
|
||||
// names Praxis and splits a refused token from an outage — those two used to
|
||||
// produce the identical sentence and neither said "Praxis" (Vikunja #588).
|
||||
// The verb is kept alongside the service name because the trace is the only
|
||||
// other place it exists, and he is not reading the trace.
|
||||
failure string
|
||||
success string
|
||||
call func(ctx context.Context, px *praxisClient, id string) error
|
||||
}
|
||||
@@ -158,7 +165,7 @@ func (a praxisItemAction) handle(ctx context.Context, h *reactiveHandler, px *pr
|
||||
log.Printf("ecosystem: praxis %s %s: %v", a.op, id, err)
|
||||
h.recordEcosystemTrace(ctx, "praxis", a.op, traceStatusForError(err), started,
|
||||
mergeFields(traceErrorFields(err), map[string]any{"item_id": id}))
|
||||
return a.failure
|
||||
return a.failure + " " + ecosystemGap(servicePraxis, err)
|
||||
}
|
||||
h.recordPraxisTrace(ctx, a.op, started, map[string]any{"item_id": id})
|
||||
return a.success
|
||||
@@ -551,6 +558,14 @@ func unauthorizedEcosystemError(err error) bool {
|
||||
return errors.As(err, &ee) && ee.Unauthorized()
|
||||
}
|
||||
|
||||
// isEcosystemError reports a failure that belongs to the service rather than to
|
||||
// what was asked of it: a call that never landed, or one the far side refused.
|
||||
// It separates "Hexis is down" from "the restart failed".
|
||||
func isEcosystemError(err error) bool {
|
||||
var ee *ecosystemError
|
||||
return errors.As(err, &ee)
|
||||
}
|
||||
|
||||
// traceErrorFields describes an ecosystemError for a trace without leaking the
|
||||
// payload: the HTTP status and the failure class, nothing else.
|
||||
func traceErrorFields(err error) map[string]any {
|
||||
@@ -777,6 +792,14 @@ func (h *reactiveHandler) execHexis(ctx context.Context, capID, capName, entityI
|
||||
mergeFields(traceErrorFields(err), map[string]any{
|
||||
"entity_id": entityID, "capability": capName, "causation_id": causationID,
|
||||
}))
|
||||
// Hexis never answering, or answering "no", is a gap in Hexis and is
|
||||
// named as one — a refused token said "не получилось выполнить команду"
|
||||
// here and sent him to debug a capability that was never reached
|
||||
// (Vikunja #587). An execution that genuinely ran and failed is not an
|
||||
// ecosystemError and keeps the command-level line.
|
||||
if isEcosystemError(err) {
|
||||
return ecosystemGap(serviceHexis, err)
|
||||
}
|
||||
return phraser.A(phraser.ActFailEntity, map[string]string{"name": displayName})
|
||||
}
|
||||
// One record per hop: the second write this used to make said the same
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
)
|
||||
|
||||
// A refused credential and an outage are different answers, and on the Hexis
|
||||
// path only one of them used to be said. These tests pin the difference at both
|
||||
// Hexis sites: the discovery hop and the execute hop (Vikunja #587). The Praxis
|
||||
// half of the same defect is in praxis_gap_test.go.
|
||||
//
|
||||
// unreachableURL is a port nothing listens on, which is what "the service is
|
||||
// down" looks like from inside a call: the connection is refused, no HTTP
|
||||
// answer is ever produced, and ecosystemError.Unreachable() is true.
|
||||
const unreachableURL = "http://127.0.0.1:1"
|
||||
|
||||
func denied(service, reply string) bool {
|
||||
return phraser.IsA(phraser.EcoDenied, serviceVars(service), reply)
|
||||
}
|
||||
|
||||
func down(service, reply string) bool {
|
||||
return phraser.IsA(phraser.EcoDown, serviceVars(service), reply)
|
||||
}
|
||||
|
||||
// hexisGapHandler wires a handler whose Nexus resolves cleanly and whose Hexis
|
||||
// is the caller's to break. hexisURL is taken separately so a test can point it
|
||||
// at a dead port.
|
||||
func hexisGapHandler(t *testing.T, nexusURL, hexisURL string) *reactiveHandler {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
now := time.Now()
|
||||
return &reactiveHandler{
|
||||
api: ipc.NewStoreAPI(st),
|
||||
dataStore: st,
|
||||
now: func() time.Time { return now },
|
||||
ecosystem: stubEcosystem(nexusURL, hexisURL),
|
||||
}
|
||||
}
|
||||
|
||||
// TestHexisDiscovery401IsDeniedNotDown — the discovery hop.
|
||||
//
|
||||
// The vendored Hexis client returns a plain fmt.Errorf for every status at or
|
||||
// above 400, so errors.As for *ecosystemError never matched and every failure
|
||||
// fell through to the outage line. "Hexis is down" for a rejected token sends
|
||||
// him to inspect a service that is running fine.
|
||||
func TestHexisDiscovery401IsDeniedNotDown(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", muzickIndexer, "service"))
|
||||
caps := fixtureHexisCapabilities(map[string]any{"id": "cap_restart", "name": "restart", "read_only": true})
|
||||
hexis := newFakeHexis(t, caps, fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := hexisGapHandler(t, nexus.URL, hexis.URL)
|
||||
|
||||
hexis.SetFault(401)
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
if !denied(serviceHexis, reply) {
|
||||
t.Fatalf("401 from hexis discovery: got %q, want the denied line naming Hexis", reply)
|
||||
}
|
||||
if !strings.Contains(reply, serviceHexis) {
|
||||
t.Errorf("reply does not name Hexis: %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHexisDiscoveryOutageIsDownNotDenied — the other half of the same fork.
|
||||
// Without this the fix could pass by calling everything a refused credential.
|
||||
func TestHexisDiscoveryOutageIsDownNotDenied(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", muzickIndexer, "service"))
|
||||
h := hexisGapHandler(t, nexus.URL, unreachableURL)
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
if !down(serviceHexis, reply) {
|
||||
t.Fatalf("connection refused from hexis: got %q, want the outage line naming Hexis", reply)
|
||||
}
|
||||
if denied(serviceHexis, reply) {
|
||||
t.Error("an outage must not be reported as a refused credential")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHexisExecute401IsDeniedNotCommandFailure — the execute hop, which did not
|
||||
// consult ecosystemGap at all and named neither the service nor the cause.
|
||||
func TestHexisExecute401IsDeniedNotCommandFailure(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", muzickIndexer, "service"))
|
||||
caps := fixtureHexisCapabilities(map[string]any{"id": "cap_restart", "name": "restart", "read_only": true})
|
||||
hexis := newFakeHexis(t, caps, fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := hexisGapHandler(t, nexus.URL, hexis.URL)
|
||||
|
||||
// Discovery stays healthy; only the execute endpoint refuses. A blanket
|
||||
// fault would never reach the site under test.
|
||||
hexis.SetRouteFault("/api/v1/execute", 401)
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
if !denied(serviceHexis, reply) {
|
||||
t.Fatalf("401 from hexis execute: got %q, want the denied line naming Hexis", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHexisExecuteOutageIsDown — same site, the other classification.
|
||||
//
|
||||
// Discovery and execution share one base URL, so the outage has to be scoped to
|
||||
// the execute endpoint rather than to the server: it answers capabilities
|
||||
// normally and drops the connection on execute, which is what the client sees
|
||||
// when the far side dies mid-call. That produces no HTTP status at all, which is
|
||||
// what Unreachable() means.
|
||||
func TestHexisExecuteOutageIsDown(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", muzickIndexer, "service"))
|
||||
caps := fixtureHexisCapabilities(map[string]any{"id": "cap_restart", "name": "restart", "read_only": true})
|
||||
hexis := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/v1/execute" {
|
||||
conn, _, err := w.(http.Hijacker).Hijack()
|
||||
if err != nil {
|
||||
t.Errorf("hijack: %v", err)
|
||||
return
|
||||
}
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(caps))
|
||||
}))
|
||||
t.Cleanup(hexis.Close)
|
||||
h := hexisGapHandler(t, nexus.URL, hexis.URL)
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
if !down(serviceHexis, reply) {
|
||||
t.Fatalf("dropped connection on hexis execute: got %q, want the outage line", reply)
|
||||
}
|
||||
if denied(serviceHexis, reply) {
|
||||
t.Error("an outage must not be reported as a refused credential")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHexisExecutionFailedStaysCommandFailure — the boundary of the fix. Hexis
|
||||
// answering 200 with a failed execution is the command failing, not Hexis
|
||||
// degrading, and it must keep the command-level line rather than accusing a
|
||||
// healthy service of being down.
|
||||
func TestHexisExecutionFailedStaysCommandFailure(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", muzickIndexer, "service"))
|
||||
caps := fixtureHexisCapabilities(map[string]any{"id": "cap_restart", "name": "restart", "read_only": true})
|
||||
hexis := newFakeHexis(t, caps, fixtureHexisExecutionFailed("exec_1", "unit refused to start"))
|
||||
h := hexisGapHandler(t, nexus.URL, hexis.URL)
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
if down(serviceHexis, reply) || denied(serviceHexis, reply) {
|
||||
t.Fatalf("a failed execution must not be reported as an ecosystem gap, got %q", reply)
|
||||
}
|
||||
if !phraser.IsA(phraser.ActFailEntity, map[string]string{"name": muzickIndexer}, reply) {
|
||||
t.Fatalf("want the command-failure line, got %q", reply)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
)
|
||||
|
||||
// A Praxis lifecycle failure used to return a hardcoded constant that named the
|
||||
// verb and never the service, so an outage, a refused token and a contract
|
||||
// mismatch all produced the identical sentence (Vikunja #588). The helpers and
|
||||
// the Hexis half of the same defect are in ecosystem_gap_test.go.
|
||||
|
||||
func TestPraxisLifecycle401NamesPraxis(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionItems())
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
|
||||
praxis.SetFault(401)
|
||||
reply := h.handlePraxisAct(ctx, praxisItemDec("resolve_item", "item_1"))
|
||||
if !strings.Contains(reply, servicePraxis) {
|
||||
t.Fatalf("praxis failure does not name Praxis: %q", reply)
|
||||
}
|
||||
if !strings.Contains(reply, phraser.A(phraser.EcoDenied, serviceVars(servicePraxis))) {
|
||||
t.Fatalf("401 from praxis: got %q, want the denied line", reply)
|
||||
}
|
||||
// The verb that did not happen is still said: the trace is the only other
|
||||
// place it exists and he is not reading the trace.
|
||||
if !strings.Contains(reply, "не получилось отметить сделанным.") {
|
||||
t.Errorf("reply dropped the operation that failed: %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPraxisLifecycleOutageDiffersFrom401 — the identity that was the bug.
|
||||
func TestPraxisLifecycleOutageDiffersFrom401(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionItems())
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
|
||||
praxis.SetFault(401)
|
||||
refused := h.handlePraxisAct(ctx, praxisItemDec("acknowledge_item", "item_1"))
|
||||
|
||||
h.ecosystem = &ecosystemWiring{praxis: newPraxisClient(unreachableURL)}
|
||||
outage := h.handlePraxisAct(ctx, praxisItemDec("acknowledge_item", "item_1"))
|
||||
|
||||
if refused == outage {
|
||||
t.Fatalf("a refused token and an outage still say the same thing: %q", refused)
|
||||
}
|
||||
if !strings.Contains(outage, phraser.A(phraser.EcoDown, serviceVars(servicePraxis))) {
|
||||
t.Fatalf("praxis outage: got %q, want the outage line naming Praxis", outage)
|
||||
}
|
||||
}
|
||||
@@ -59,8 +59,15 @@ func whenGapOf(text string, hasTime bool) whenGap {
|
||||
// reasoning unless he hears it. The hour deck varies with the attempt, like
|
||||
// every other slot; the other two say one thing and there is only one way to
|
||||
// say it.
|
||||
func whenQuestion(gap whenGap, attempt int, now time.Time) (string, bool) {
|
||||
//
|
||||
// taken is what his last turn added, in his own words, and it goes between the
|
||||
// clock and the question (V-593). It is empty whenever his turn moved nothing
|
||||
// forward, which is the case where repeating the question verbatim is honest.
|
||||
func whenQuestion(gap whenGap, attempt int, now time.Time, taken string) (string, bool) {
|
||||
clock := fmt.Sprintf("Сейчас %s.", now.Format("15:04"))
|
||||
if taken != "" {
|
||||
clock += " " + taken
|
||||
}
|
||||
switch gap {
|
||||
case whenNoHour:
|
||||
q, ok := clarifyQuestionFor(dialogue.SlotTime, attempt)
|
||||
@@ -76,6 +83,50 @@ func whenQuestion(gap whenGap, attempt int, now time.Time) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// whenKnown — the three things he has to say about the time, and whether the
|
||||
// words so far say them. Read off the same predicates whenGapOf reads, so the
|
||||
// two cannot disagree about what is still open.
|
||||
type whenKnown struct{ hour, part, day bool }
|
||||
|
||||
func whenKnownOf(text string, hasTime bool) whenKnown {
|
||||
if !router.NamesAnHour(text) {
|
||||
return whenKnown{}
|
||||
}
|
||||
if router.NamesAnInterval(text) {
|
||||
return whenKnown{hour: true, part: true, day: true}
|
||||
}
|
||||
if !hasTime {
|
||||
return whenKnown{}
|
||||
}
|
||||
return whenKnown{
|
||||
hour: true,
|
||||
part: !router.HourIsAmbiguous(text),
|
||||
day: router.NamesADay(text),
|
||||
}
|
||||
}
|
||||
|
||||
// movedForward reports whether b says something a did not.
|
||||
func (a whenKnown) movedForward(b whenKnown) bool {
|
||||
return (!a.hour && b.hour) || (!a.part && b.part) || (!a.day && b.day)
|
||||
}
|
||||
|
||||
// whenTakenLine — the acknowledgement in front of a re-ask, in the words he
|
||||
// just used (V-593).
|
||||
//
|
||||
// It is an echo and never a restatement, for the same reason the fact
|
||||
// confirmation is (V-592): a 1.7B asked to say a Russian sentence back invents.
|
||||
// Its only job is evidence that the turn between two asks was heard, so after
|
||||
// "на 9" and then "на завтра" she does not ask "утра или вечера?" twice
|
||||
// byte-identically while he wonders whether the microphone is on.
|
||||
func whenTakenLine(text string) string {
|
||||
text = strings.TrimSpace(text)
|
||||
text = strings.TrimRight(text, " \t.,!?;:")
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
return "Поняла: " + text + "."
|
||||
}
|
||||
|
||||
// whenTextOf is everything he has said about when, the original request plus
|
||||
// every answer he has given to a question about it.
|
||||
//
|
||||
|
||||
@@ -31,6 +31,14 @@ func (r *llmReplier) Reply(d router.Decision) string {
|
||||
// a generation to say something this small.
|
||||
return clarifyMissedLine(d)
|
||||
}
|
||||
if d.Intent == router.IntentFact {
|
||||
// A captured fact is confirmed by echoing him, and the model is not
|
||||
// asked (V-592). It has nothing to phrase FROM: replyContext hands it
|
||||
// "записала факт: water \"drank\"", so every Russian word in the reply
|
||||
// was the model's own invention, and on 2026-08-06 that was "Проверила,
|
||||
// что ты выпел стакан воды" for "я выпил воды".
|
||||
return phraser.FactAck(d.Utterance)
|
||||
}
|
||||
out, err := r.p.PhraseReply(context.Background(), d)
|
||||
if err != nil || out == "" {
|
||||
return r.stub.Reply(d)
|
||||
|
||||
+14
-9
@@ -17,6 +17,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/lexicon"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/say"
|
||||
)
|
||||
|
||||
@@ -73,22 +74,26 @@ func mentionsUnknownPlace(u string) bool {
|
||||
// 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.
|
||||
//
|
||||
// The weekday half was a list of STEMS matched with strings.Contains until
|
||||
// V-581 — "сред", "пятниц", "суббот". That is the hand-written Russian pattern
|
||||
// the sweep of 2026-08-04 took out, and it was wrong in the way such a pattern
|
||||
// always is: "среди", "средство" and "средний" all contain "сред", so a question
|
||||
// carrying any of them was answered with onlyNearDaysReply instead of the date.
|
||||
// Whole tokens now, and the weekday itself is router.WeekdayIndex, which reads
|
||||
// the lexicon and asks the dictionary about the case.
|
||||
func mentionsUnknownDay(u string) bool {
|
||||
for _, w := range dayWords {
|
||||
if strings.Contains(u, w) {
|
||||
for _, tok := range quietTokens(u) {
|
||||
if tok == "через" {
|
||||
return true
|
||||
}
|
||||
if _, ok := router.WeekdayIndex(tok); ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestMentionsUnknownDayReadsWordsNotStems — the defect V-581 found. The
|
||||
// weekday half of this guard was a list of stems matched with strings.Contains,
|
||||
// so "среди", "средство" and "средний" all read as Wednesday and the question
|
||||
// was answered with onlyNearDaysReply instead of a date.
|
||||
//
|
||||
// The other half of the fix is coverage: a stem list stops at the forms whoever
|
||||
// wrote it thought of, and "воскресеньях" was not one of them.
|
||||
func TestMentionsUnknownDayReadsWordsNotStems(t *testing.T) {
|
||||
for _, u := range []string{
|
||||
"какое число в понедельник",
|
||||
"какое число в среду",
|
||||
"какое число в среде",
|
||||
"что там по воскресеньям",
|
||||
"what is the date on friday",
|
||||
"какое число через неделю",
|
||||
} {
|
||||
if !mentionsUnknownDay(u) {
|
||||
t.Errorf("mentionsUnknownDay(%q) = false, want true", u)
|
||||
}
|
||||
}
|
||||
for _, u := range []string{
|
||||
"какое число в среднем",
|
||||
"сколько это в среднем",
|
||||
"какое сегодня средство",
|
||||
"какое число",
|
||||
} {
|
||||
if mentionsUnknownDay(u) {
|
||||
t.Errorf("mentionsUnknownDay(%q) = true; it names no day", u)
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -24,8 +24,8 @@
|
||||
"at": "21:00",
|
||||
"note": "he speaks. The whole voice path runs: push-to-talk, the STT seam parked with the golden transcript, the real router, the real store write, the phrasing contract.",
|
||||
"audio": "ru_fact",
|
||||
"expect_reply_contains": ["записала"],
|
||||
"expect_reply_lacks": ["записал ", "записал,", "записал.", "милый", "ваш"],
|
||||
"expect_reply_contains": ["записала", "выпил воды"],
|
||||
"expect_reply_lacks": ["записал ", "записал,", "записал.", "милый", "ваш", "стакан"],
|
||||
"expect_events": ["water"]
|
||||
},
|
||||
{
|
||||
|
||||
+3
-3
@@ -81,10 +81,10 @@
|
||||
},
|
||||
{
|
||||
"at": "08:55",
|
||||
"note": "stating a fact writes it and says so, in the feminine. This reply comes back through the replier from the scripted model, so the persona check is against generated text rather than a constant. The masculine forms are listed with their following character — \"записал \" and \"записал,\" — because \"записала\" contains \"записал\", and the earlier check on the comma alone passed on \"записал что ты выпил воды\".",
|
||||
"note": "stating a fact writes it and says so, in the feminine, and in his own words. The reply no longer comes from the model at all (V-592): a 1.7B asked to restate «я выпил воды» answered «Проверила, что ты выпел стакан воды», so the confirmation is now a deck frame with his sentence in it. The masculine forms are listed with their following character — \"записал \" and \"записал,\" — because \"записала\" contains \"записал\".",
|
||||
"say": "я выпил воды",
|
||||
"expect_reply_contains": ["записала"],
|
||||
"expect_reply_lacks": ["записал ", "записал,", "записал.", "милый"],
|
||||
"expect_reply_contains": ["записала", "я выпил воды"],
|
||||
"expect_reply_lacks": ["записал ", "записал,", "записал.", "милый", "стакан"],
|
||||
"expect_events": ["water"]
|
||||
},
|
||||
{
|
||||
|
||||
+39
-9
@@ -7,6 +7,10 @@ package main
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/kami/maven/internal/lexicon"
|
||||
"github.com/kami/maven/internal/morph"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// isWeatherQuery returns true if the utterance is about weather.
|
||||
@@ -27,16 +31,42 @@ func isWeatherQuery(u string) bool {
|
||||
// come through whole and "в 5 утра" does not.
|
||||
var weatherPlace = regexp.MustCompile(`(?i)(?:^|\s)(?:в|во|in)\s+([\p{L}-]+(?:\s+[\p{L}-]+)?)`)
|
||||
|
||||
// weatherNonPlaces — words that follow "в" in a weather question and are not
|
||||
// cities. "какая погода в доме" is the smart-home sensor, not Open-Meteo, and
|
||||
// "тепло в комнате" is the same question about the same room.
|
||||
var weatherNonPlaces = map[string]bool{
|
||||
// weatherRooms — the rooms of the house, which are the only words in this
|
||||
// guard that belong to it. "какая погода в доме" is the smart-home sensor, not
|
||||
// Open-Meteo, and "тепло в комнате" is the same question about the same room.
|
||||
//
|
||||
// The rest of the guard used to be a third copy of three closed sets that
|
||||
// already exist in the lexicon: the weekdays, the parts of the day, and the
|
||||
// words that follow "в" without naming a place (V-581). Each copy was short in
|
||||
// its own direction — "среду" but not "среде", "утром" but not "утра", "целом"
|
||||
// but not "общем" — so the same question phrased one word differently reached
|
||||
// the geocoder as a city.
|
||||
var weatherRooms = map[string]bool{
|
||||
"доме": true, "квартире": true, "комнате": true, "спальне": true,
|
||||
"гостиной": true, "кухне": true, "гараже": true, "офисе": true,
|
||||
"выходные": true, "субботу": true, "воскресенье": true, "понедельник": true,
|
||||
"вторник": true, "среду": true, "четверг": true, "пятницу": true,
|
||||
"обед": true, "обеде": true, "утро": true, "утром": true, "вечер": true,
|
||||
"вечером": true, "ночь": true, "ночью": true, "целом": true, "принципе": true,
|
||||
"обед": true, "обеде": true, "выходные": true, "выходных": true,
|
||||
}
|
||||
|
||||
// isWeatherNonPlace reports whether the word after "в" names something other
|
||||
// than a place he could ask the weather for.
|
||||
func isWeatherNonPlace(word string) bool {
|
||||
if weatherRooms[word] {
|
||||
return true
|
||||
}
|
||||
if _, ok := router.WeekdayIndex(word); ok {
|
||||
return true
|
||||
}
|
||||
for _, w := range lexicon.PartsOfDay() {
|
||||
if word == w || morph.SameWord(word, w) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, w := range lexicon.NotPlaceAfterV() {
|
||||
if word == w {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// extractWeatherLocation returns the place he named, or the configured default
|
||||
@@ -63,7 +93,7 @@ func extractWeatherLocation(u, defaultLoc string) string {
|
||||
}
|
||||
place := strings.TrimSpace(m[1])
|
||||
first := strings.ToLower(strings.Fields(place)[0])
|
||||
if weatherNonPlaces[first] {
|
||||
if isWeatherNonPlace(first) {
|
||||
return defaultLoc
|
||||
}
|
||||
return place
|
||||
|
||||
@@ -29,6 +29,13 @@ func TestExtractWeatherLocation(t *testing.T) {
|
||||
// the house sensors and the day words answer elsewhere.
|
||||
{"тепло в комнате?", "Berlin", "Berlin"},
|
||||
{"какая погода в выходные", "Berlin", "Berlin"},
|
||||
// The cases the three private copies of the lexicon were short by
|
||||
// (V-581): a weekday in a case the old map did not list, a part of the
|
||||
// day in one it did not list, and "в общем".
|
||||
{"какая погода в среде", "Berlin", "Berlin"},
|
||||
{"какая погода в воскресеньях", "Berlin", "Berlin"},
|
||||
{"какая погода в понедельникам", "Berlin", "Berlin"},
|
||||
{"какая погода в общем", "Berlin", "Berlin"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := extractWeatherLocation(c.utterance, c.def); got != c.want {
|
||||
|
||||
@@ -22,6 +22,18 @@ import (
|
||||
// to it before sending, so it's also the rate the silence gate assumes.
|
||||
const whisperSampleRate = 16000
|
||||
|
||||
// whisperThreads — greedy decode is single-pass and this is a laptop CPU
|
||||
// (homesrv), not a server box; 4 was picked to leave headroom for the rest
|
||||
// of the daemons sharing the machine, not measured against a latency target.
|
||||
const whisperThreads = 4
|
||||
|
||||
// noSpeechFloor — whisper's own no_speech_prob past this point means the
|
||||
// segment it transcribed is not speech (the model still emits token
|
||||
// probabilities for silence/noise, so a high avgLogProb-derived confidence
|
||||
// can coexist with a segment that should be zero). Read as "at least 90%
|
||||
// sure this was not speech."
|
||||
const noSpeechFloor = 0.9
|
||||
|
||||
type whisperHandler struct {
|
||||
ctx *C.struct_whisper_context
|
||||
minMs int // clips shorter than this are dropped (hallucination bait)
|
||||
@@ -101,7 +113,7 @@ func (h *whisperHandler) Transcribe(ctx context.Context, req worker.TranscribeRe
|
||||
params.print_realtime = false
|
||||
params.print_timestamps = false
|
||||
params.print_special = false
|
||||
params.n_threads = C.int(4)
|
||||
params.n_threads = C.int(whisperThreads)
|
||||
params.single_segment = true
|
||||
|
||||
lang := C.CString(req.Lang)
|
||||
@@ -162,7 +174,7 @@ func (h *whisperHandler) Transcribe(ctx context.Context, req worker.TranscribeRe
|
||||
}
|
||||
|
||||
noSpeechProb := float64(C.whisper_full_get_segment_no_speech_prob(h.ctx, 0))
|
||||
if noSpeechProb > 0.9 {
|
||||
if noSpeechProb > noSpeechFloor {
|
||||
confidence = 0
|
||||
}
|
||||
|
||||
|
||||
@@ -116,18 +116,27 @@ func (h *piperHandler) Synthesize(ctx context.Context, req worker.SynthesizeReq)
|
||||
}, nil
|
||||
}
|
||||
|
||||
// resample22050To16000 converts raw 16-bit PCM from 22050 Hz to 16000 Hz
|
||||
// using linear interpolation.
|
||||
// piperSampleRate is the rate piper's onnx voices render at (ru_RU-irina and
|
||||
// the other models this daemon has been pointed at). targetSampleRate is the
|
||||
// canonical maven wire rate (audio.PCM16kMono) that every downstream
|
||||
// consumer — playback, the voice wire, whisper on the way back in — expects.
|
||||
const (
|
||||
piperSampleRate = 22050
|
||||
targetSampleRate = 16000
|
||||
)
|
||||
|
||||
// resample22050To16000 converts raw 16-bit PCM from piperSampleRate to
|
||||
// targetSampleRate using linear interpolation.
|
||||
func resample22050To16000(input []byte) []byte {
|
||||
if len(input) < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
nSamples := len(input) / 2
|
||||
outSamples := int(float64(nSamples) * 16000.0 / 22050.0)
|
||||
outSamples := int(float64(nSamples) * float64(targetSampleRate) / float64(piperSampleRate))
|
||||
output := make([]byte, outSamples*2)
|
||||
|
||||
ratio := 22050.0 / 16000.0
|
||||
ratio := float64(piperSampleRate) / float64(targetSampleRate)
|
||||
|
||||
for i := 0; i < outSamples; i++ {
|
||||
srcPos := float64(i) * ratio
|
||||
|
||||
@@ -82,6 +82,38 @@ func TestWAVRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A LIST chunk sitting between fmt and data is common (arecord and ffmpeg both
|
||||
// write one), and its payload is free text that can spell "data". The parser
|
||||
// walks chunk headers, so the text is skipped and the real samples are read.
|
||||
func TestPCMFromWAVSkipsLISTChunk(t *testing.T) {
|
||||
t.Parallel()
|
||||
pcm := []byte{1, 0, 2, 0, 3, 0, 4, 0}
|
||||
list := []byte("LIST")
|
||||
payload := []byte("INFOICMTdata is not here")
|
||||
list = binary.LittleEndian.AppendUint32(list, uint32(len(payload)))
|
||||
list = append(list, payload...)
|
||||
|
||||
plain, err := WAVFromPCM(PCM16kMono, pcm)
|
||||
if err != nil {
|
||||
t.Fatalf("WAVFromPCM: %v", err)
|
||||
}
|
||||
wav := append([]byte{}, plain[:36]...)
|
||||
wav = append(wav, list...)
|
||||
wav = append(wav, plain[36:]...)
|
||||
binary.LittleEndian.PutUint32(wav[4:8], uint32(len(wav)-8))
|
||||
|
||||
f, got, err := PCMFromWAV(wav)
|
||||
if err != nil {
|
||||
t.Fatalf("PCMFromWAV: %v", err)
|
||||
}
|
||||
if !f.IsValid() {
|
||||
t.Fatalf("parsed format invalid: %+v", f)
|
||||
}
|
||||
if !bytes.Equal(got, pcm) {
|
||||
t.Fatalf("PCM mismatch: got %v, want %v", got, pcm)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPCMFromWAVRejectsNonCanonical(t *testing.T) {
|
||||
t.Parallel()
|
||||
// too short
|
||||
|
||||
+30
-12
@@ -36,6 +36,10 @@ const wavHeaderSize = 44
|
||||
// raw PCM samples (little-endian int16 as bytes). A non-canonical blob is
|
||||
// rejected with ErrNotCanonicalPCM; the format mismatch is logged at the seam
|
||||
// so the caller surfaces it, not a hidden silent downmix.
|
||||
//
|
||||
// The returned PCM aliases wav rather than copying it, because a recording is
|
||||
// large and the caller already owns the bytes. A caller that keeps the PCM past
|
||||
// the life of wav, or that reuses wav as a read buffer, must copy first.
|
||||
func PCMFromWAV(wav []byte) (Format, []byte, error) {
|
||||
if len(wav) < wavHeaderSize {
|
||||
return Format{}, nil, fmt.Errorf("audio: wav too short: %d bytes", len(wav))
|
||||
@@ -61,17 +65,13 @@ func PCMFromWAV(wav []byte) (Format, []byte, error) {
|
||||
return Format{}, nil, fmt.Errorf("%w: channels=%d bits=%d (want 1/16)", ErrNotCanonicalPCM, channels, bitsPerSample)
|
||||
}
|
||||
// data chunk: the spec mandates it appears right after fmt, but real
|
||||
// recorders sometimes append extra chunks (LIST, fact). Find the "data"
|
||||
// chunk by scanning; require it within the region we'd expect.
|
||||
dataIdx := -1
|
||||
for i := wavHeaderSize - 8; i+8 <= len(wav) && i < wavHeaderSize+4096; i++ {
|
||||
if string(wav[i:i+4]) == "data" {
|
||||
dataIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if dataIdx < 0 {
|
||||
return Format{}, nil, fmt.Errorf("%w: no data chunk", ErrNotCanonicalPCM)
|
||||
// recorders sometimes append extra chunks (LIST, fact). Walk the chunk
|
||||
// headers rather than scanning for the four bytes "data", because those
|
||||
// bytes occur inside a LIST/INFO payload as ordinary text and a byte scan
|
||||
// would take the middle of a comment for a chunk header.
|
||||
dataIdx, err := findDataChunk(wav)
|
||||
if err != nil {
|
||||
return Format{}, nil, err
|
||||
}
|
||||
dataSize := binary.LittleEndian.Uint32(wav[dataIdx+4 : dataIdx+8])
|
||||
body := wav[dataIdx+8:]
|
||||
@@ -90,6 +90,24 @@ func PCMFromWAV(wav []byte) (Format, []byte, error) {
|
||||
return f, body, nil
|
||||
}
|
||||
|
||||
// findDataChunk returns the offset of the "data" chunk header, walking the
|
||||
// chunk list that starts after the 16-byte fmt chunk. Chunks are word-aligned,
|
||||
// so an odd size carries one pad byte the next header sits behind.
|
||||
func findDataChunk(wav []byte) (int, error) {
|
||||
for pos := wavHeaderSize - 8; pos+8 <= len(wav); {
|
||||
size := int(binary.LittleEndian.Uint32(wav[pos+4 : pos+8]))
|
||||
if string(wav[pos:pos+4]) == "data" {
|
||||
return pos, nil
|
||||
}
|
||||
next := pos + 8 + size + size%2
|
||||
if next <= pos || next > len(wav) {
|
||||
break
|
||||
}
|
||||
pos = next
|
||||
}
|
||||
return 0, fmt.Errorf("%w: no data chunk", ErrNotCanonicalPCM)
|
||||
}
|
||||
|
||||
// WAVFromPCM wraps raw 16-bit mono PCM bytes in a canonical 44-byte WAV
|
||||
// header so the result can be written to disk and played with `aplay`.
|
||||
// Used by the reference client to write the TTS reply; not on the wire.
|
||||
@@ -115,7 +133,7 @@ const WAVHeaderSize = wavHeaderSize
|
||||
// avoiding.
|
||||
func WAVHeader(format Format, n int) ([]byte, error) {
|
||||
if !format.IsValid() {
|
||||
return nil, fmt.Errorf("audio: WAVFromPCM: %w: %+v", ErrNotCanonicalPCM, format)
|
||||
return nil, fmt.Errorf("audio: WAVHeader: %w: %+v", ErrNotCanonicalPCM, format)
|
||||
}
|
||||
out := make([]byte, wavHeaderSize)
|
||||
// RIFF header
|
||||
|
||||
@@ -140,6 +140,12 @@ const EventKeyPrefix = "calendar_event_"
|
||||
//
|
||||
// An end at or before the start is read as crossing midnight, so a 23:30-00:15
|
||||
// meeting covers the quarter hour it actually covers.
|
||||
//
|
||||
// Both readings are built with time.Date rather than added to midnight as a
|
||||
// duration. A day is 23 or 25 hours wide on the two DST changeovers, so
|
||||
// midnight plus fourteen hours is 13:00 or 15:00 on those days, and the busy
|
||||
// gate would then read a 14:00 meeting an hour off. The same goes for the
|
||||
// midnight crossing, which is AddDate and not a 24-hour add.
|
||||
func FactSpan(key, value string, loc *time.Location) (start, end time.Time, ok bool) {
|
||||
if !strings.HasPrefix(key, EventKeyPrefix) {
|
||||
return time.Time{}, time.Time{}, false
|
||||
@@ -172,10 +178,11 @@ func FactSpan(key, value string, loc *time.Location) (start, end time.Time, ok b
|
||||
if !ok1 || !ok2 {
|
||||
return time.Time{}, time.Time{}, false
|
||||
}
|
||||
start = day.Add(time.Duration(sh)*time.Hour + time.Duration(sm)*time.Minute)
|
||||
end = day.Add(time.Duration(eh)*time.Hour + time.Duration(em)*time.Minute)
|
||||
y, mo, d := day.Date()
|
||||
start = time.Date(y, mo, d, sh, sm, 0, 0, loc)
|
||||
end = time.Date(y, mo, d, eh, em, 0, 0, loc)
|
||||
if !end.After(start) {
|
||||
end = end.Add(24 * time.Hour)
|
||||
end = end.AddDate(0, 0, 1)
|
||||
}
|
||||
return start, end, true
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ func ParseICalDay(body []byte, now time.Time) []Event {
|
||||
// Reports false for all-day events and parse failures.
|
||||
func parseVEVENT(block string, loc *time.Location) (Event, bool) {
|
||||
var e Event
|
||||
for _, line := range strings.Split(block, "\n") {
|
||||
for _, line := range strings.Split(unfold(block), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
switch {
|
||||
case strings.HasPrefix(line, "DTSTART"):
|
||||
@@ -78,9 +78,9 @@ func parseVEVENT(block string, loc *time.Location) (Event, bool) {
|
||||
e.End = t
|
||||
}
|
||||
case strings.HasPrefix(line, "SUMMARY"):
|
||||
e.Summary = afterColon(line)
|
||||
e.Summary = unescapeText(afterColon(line))
|
||||
case strings.HasPrefix(line, "UID"):
|
||||
e.UID = afterColon(line)
|
||||
e.UID = unescapeText(afterColon(line))
|
||||
}
|
||||
}
|
||||
if e.Start.IsZero() || e.End.IsZero() {
|
||||
@@ -89,6 +89,48 @@ func parseVEVENT(block string, loc *time.Location) (Event, bool) {
|
||||
return e, true
|
||||
}
|
||||
|
||||
// unfold undoes RFC 5545 content-line folding, where a long property is split
|
||||
// with a CRLF and the continuation begins with one space or tab.
|
||||
//
|
||||
// It runs before the block is split into lines, because splitting first and
|
||||
// trimming each line destroys the leading space that marks a continuation. A
|
||||
// server folds at 75 octets and a Russian summary is two bytes a letter, so
|
||||
// "Еженедельная планёрка с командой" crosses the limit easily — without this
|
||||
// the tail of the summary was read as an unknown property and dropped, and the
|
||||
// event was filed under a truncated name.
|
||||
func unfold(block string) string {
|
||||
if !strings.Contains(block, "\n ") && !strings.Contains(block, "\n\t") {
|
||||
return block
|
||||
}
|
||||
return strings.NewReplacer("\r\n ", "", "\r\n\t", "", "\n ", "", "\n\t", "").Replace(block)
|
||||
}
|
||||
|
||||
// unescapeText reverses the RFC 5545 TEXT escaping escapeText applies. Without
|
||||
// it a summary a server wrote as "Обед\, потом созвон" reaches the day plan
|
||||
// with the backslash still in it, and FactKey folds that literal into the key.
|
||||
func unescapeText(s string) string {
|
||||
if !strings.Contains(s, `\`) {
|
||||
return s
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] != '\\' || i+1 >= len(s) {
|
||||
b.WriteByte(s[i])
|
||||
continue
|
||||
}
|
||||
i++
|
||||
switch s[i] {
|
||||
case 'n', 'N':
|
||||
b.WriteByte('\n')
|
||||
default:
|
||||
// ";", ",", "\\" and anything else a writer escaped needlessly.
|
||||
b.WriteByte(s[i])
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func afterColon(line string) string {
|
||||
if i := strings.Index(line, ":"); i >= 0 {
|
||||
return strings.TrimSpace(line[i+1:])
|
||||
|
||||
@@ -61,6 +61,44 @@ func TestRenderICalEscapesInjection(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A folded SUMMARY is one property, not a property plus a dropped tail. Servers
|
||||
// fold at 75 octets and a Russian summary is two bytes a letter.
|
||||
func TestParseICalUnfoldsAndUnescapes(t *testing.T) {
|
||||
body := []byte("BEGIN:VEVENT\r\n" +
|
||||
"UID:u1\r\n" +
|
||||
"DTSTART:20260703T130000Z\r\n" +
|
||||
"DTEND:20260703T140000Z\r\n" +
|
||||
"SUMMARY:Еженедельная планёрка\\, потом\r\n созвон\r\n" +
|
||||
"END:VEVENT\r\n")
|
||||
from := time.Date(2026, 7, 3, 0, 0, 0, 0, time.UTC)
|
||||
events := ParseICal(body, from, from.AddDate(0, 0, 1))
|
||||
if len(events) != 1 {
|
||||
t.Fatalf("got %d events, want 1", len(events))
|
||||
}
|
||||
if want := "Еженедельная планёрка, потом созвон"; events[0].Summary != want {
|
||||
t.Errorf("Summary = %q, want %q", events[0].Summary, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A day is 23 hours wide where DST starts, so a wall clock reading has to be
|
||||
// built with time.Date and never as midnight plus a duration.
|
||||
func TestFactSpanAcrossDSTStart(t *testing.T) {
|
||||
loc, err := time.LoadLocation("Europe/Berlin")
|
||||
if err != nil {
|
||||
t.Skipf("no tzdata for Europe/Berlin: %v", err)
|
||||
}
|
||||
start, end, ok := FactSpan("calendar_event_20260329_Planerka", "Planerka @ 14:00-15:00", loc)
|
||||
if !ok {
|
||||
t.Fatal("FactSpan reported not ok")
|
||||
}
|
||||
if start.Hour() != 14 || start.Minute() != 0 {
|
||||
t.Errorf("start = %s, want a 14:00 wall clock", start)
|
||||
}
|
||||
if end.Hour() != 15 {
|
||||
t.Errorf("end = %s, want a 15:00 wall clock", end)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReminderEventEmptyPayload(t *testing.T) {
|
||||
e := ReminderEvent(3, time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC), " ", 0)
|
||||
if e.Summary != "напоминание" {
|
||||
|
||||
@@ -626,6 +626,9 @@ func windowBytes(f audio.Format, window time.Duration) int64 {
|
||||
if bps <= 0 || f.SampleRate <= 0 || window <= 0 {
|
||||
return 0
|
||||
}
|
||||
per := int64(window.Seconds()) * bytesPerSecond(f)
|
||||
// Fractional seconds count. Truncating the window to whole seconds turned
|
||||
// any sub-second window into zero bytes, which the caller reads as "no
|
||||
// window" and answers by handing the transcriber the entire meeting at once.
|
||||
per := int64(window.Seconds() * float64(bytesPerSecond(f)))
|
||||
return per - per%bps
|
||||
}
|
||||
|
||||
@@ -193,9 +193,7 @@ func ChunkText(text string, maxRunes int) []string {
|
||||
// Oversized sentence: emit what is buffered, then cut this one on
|
||||
// word boundaries.
|
||||
flush()
|
||||
for _, piece := range splitWords(sr, maxRunes) {
|
||||
out = append(out, piece)
|
||||
}
|
||||
out = append(out, splitWords(sr, maxRunes)...)
|
||||
continue
|
||||
}
|
||||
if len(cur)+len(sr) > maxRunes {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
// utterance (V-565, umbrella V-558, design in
|
||||
// docs/plans/19-dialogue-arbitration.md).
|
||||
//
|
||||
// Maven's cascade has roughly ten stage-0 grammars, seven router intents,
|
||||
// twenty-two query sources and four stateful pre-emptors, and every one of them
|
||||
// Maven's cascade has twenty-two stage-0 grammars, seven router intents,
|
||||
// twenty-two query sources and seven stateful pre-emptors, and every one of them
|
||||
// answers "is this mine?" alone. None can answer "is this more mine than
|
||||
// yours?", because their scores are not comparable: stage 0 asserts 1.0 by
|
||||
// fiat, the classifier reports a cosine, the LLM router derives one from
|
||||
@@ -56,8 +56,9 @@ const (
|
||||
// BandStructural — the claimant read the whole sentence and produced a
|
||||
// complete route, every slot its intent requires filled. The LLM router at
|
||||
// full confidence, and a stateful claimant holding a pending question.
|
||||
// Below BandAnchored on purpose: the four stateful claimants pre-empt
|
||||
// unconditionally today, and that is the V-558 defect.
|
||||
// Below BandAnchored on purpose: the stateful claimants pre-empt
|
||||
// unconditionally today, and that is the V-558 defect. There are seven of
|
||||
// them and preRouteLadder in cmd/mavend/decisiontrace.go is the roster.
|
||||
BandStructural
|
||||
|
||||
// BandAnchored — a literal pattern anchored in the utterance matched, and
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
@@ -329,7 +330,15 @@ func Load(path string) (*Config, error) {
|
||||
// Expand ${VAR} or $VAR patterns from environment variables. This lets
|
||||
// secrets live in env (docker-compose env_file) rather than the config
|
||||
// file committed to git.
|
||||
expanded := os.ExpandEnv(string(b))
|
||||
expanded, missing := expandEnv(string(b))
|
||||
if len(missing) > 0 {
|
||||
// An unset variable expands to "", which every block reads as "not
|
||||
// configured" and none of them complains about. That is the intended
|
||||
// behaviour and it stays: CI parses this same file with no secrets
|
||||
// present. What was missing is the line telling the operator which
|
||||
// capability he just turned off by forgetting an env file.
|
||||
log.Printf("config: %s references unset environment variables %v — those settings are empty, so whatever they configure is off", path, missing)
|
||||
}
|
||||
var c Config
|
||||
if err := json.Unmarshal([]byte(expanded), &c); err != nil {
|
||||
return nil, fmt.Errorf("config: parse %s: %w", path, err)
|
||||
@@ -341,6 +350,23 @@ func Load(path string) (*Config, error) {
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// expandEnv is os.ExpandEnv plus the names it could not resolve, each reported
|
||||
// once and in the order the file mentions them. A variable set to the empty
|
||||
// string counts as set: the operator wrote it down, so he meant it.
|
||||
func expandEnv(s string) (string, []string) {
|
||||
var missing []string
|
||||
seen := map[string]bool{}
|
||||
out := os.Expand(s, func(name string) string {
|
||||
v, ok := os.LookupEnv(name)
|
||||
if !ok && !seen[name] {
|
||||
seen[name] = true
|
||||
missing = append(missing, name)
|
||||
}
|
||||
return v
|
||||
})
|
||||
return out, missing
|
||||
}
|
||||
|
||||
func (c *Config) applyDefaults() {
|
||||
if c.IntakeJournal == 0 {
|
||||
c.IntakeJournal = DefaultIntakeJournal
|
||||
|
||||
+17
-8
@@ -57,6 +57,16 @@ var (
|
||||
ErrFetchStatus = errors.New("crawl: the server answered with an error status")
|
||||
)
|
||||
|
||||
// StatusError is ErrFetchStatus with the code the server actually sent. The
|
||||
// adapter builds it; isServerError reads Code rather than the message, so a
|
||||
// reworded error can no longer turn a 503 robots.txt into permission to crawl.
|
||||
type StatusError struct{ Code int }
|
||||
|
||||
func (e *StatusError) Error() string {
|
||||
return fmt.Sprintf("crawl: the server answered with status %d", e.Code)
|
||||
}
|
||||
func (e *StatusError) Unwrap() error { return ErrFetchStatus }
|
||||
|
||||
// Fetcher is the guarded HTTP door (internal/webfetch adapted by the daemon). An
|
||||
// interface so this package constructs no http.Client of its own and can be
|
||||
// tested without a network.
|
||||
@@ -233,16 +243,15 @@ func (c *Crawler) markFetched(host string) {
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// isServerError — a 5xx rather than any other non-2xx. The adapter formats the
|
||||
// status into the message, which is the only place it survives.
|
||||
// isServerError — a 5xx rather than any other non-2xx. A status the adapter
|
||||
// could not recover reads as 0 and is not a server error, which keeps the
|
||||
// standard's "404 means allow" as the default for an unknown.
|
||||
func isServerError(err error) bool {
|
||||
s := err.Error()
|
||||
for _, code := range []string{" 50", " 51", " 52", " 53"} {
|
||||
if strings.Contains(s, code) {
|
||||
return true
|
||||
}
|
||||
var se *StatusError
|
||||
if !errors.As(err, &se) {
|
||||
return false
|
||||
}
|
||||
return false
|
||||
return se.Code >= 500 && se.Code <= 599
|
||||
}
|
||||
|
||||
// Hash is the dedup key for a crawl result: the sha256 of the extracted text,
|
||||
|
||||
@@ -79,7 +79,7 @@ func TestPage_ABrokenRobotsServerIsNotPermissionToCrawl(t *testing.T) {
|
||||
// way to resolve an unknown.
|
||||
f := &timedFetcher{
|
||||
pages: map[string]Response{"https://example.org/a": {Body: []byte("<html><body>a</body></html>")}},
|
||||
errs: map[string]error{"https://example.org/robots.txt": fmt.Errorf("%w: 503", ErrFetchStatus)},
|
||||
errs: map[string]error{"https://example.org/robots.txt": &StatusError{Code: 503}},
|
||||
}
|
||||
c := New(f, Config{UserAgent: "Maven/1.0"})
|
||||
if _, err := c.Page(context.Background(), "https://example.org/a"); !errors.Is(err, ErrFetchStatus) {
|
||||
|
||||
@@ -19,6 +19,13 @@ type Ring struct {
|
||||
func NewRing() *Ring { return &Ring{} }
|
||||
|
||||
// Push adds one finished record and drops the oldest past the bound.
|
||||
//
|
||||
// The dropped pointers are cleared before the reslice. Resliceing alone moves
|
||||
// the window forward and leaves the evicted records addressable from the
|
||||
// backing array, so up to ringSize turns he had already aged out stayed in
|
||||
// memory until the next append reallocated. That is a leak anywhere and it is
|
||||
// the wrong one here, because the reason this store is memory-only is that his
|
||||
// words should not outlive the diagnosis.
|
||||
func (r *Ring) Push(rec *Record) {
|
||||
if r == nil || rec == nil {
|
||||
return
|
||||
@@ -26,8 +33,11 @@ func (r *Ring) Push(rec *Record) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.recs = append(r.recs, rec)
|
||||
if len(r.recs) > ringSize {
|
||||
r.recs = r.recs[len(r.recs)-ringSize:]
|
||||
if drop := len(r.recs) - ringSize; drop > 0 {
|
||||
for i := 0; i < drop; i++ {
|
||||
r.recs[i] = nil
|
||||
}
|
||||
r.recs = r.recs[drop:]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,8 +37,8 @@ type PendingQuestion struct {
|
||||
// request rather than alone: "завтра" names a day for an hour said earlier.
|
||||
WhenText string
|
||||
Asked time.Time
|
||||
TTL time.Duration
|
||||
Attempts int // questions already asked
|
||||
TTL time.Duration
|
||||
Attempts int // questions already asked
|
||||
// MaxAttempts caps Attempts. 0 ⇒ DefaultMaxAttempts.
|
||||
MaxAttempts int
|
||||
}
|
||||
@@ -104,11 +104,14 @@ type ClarifyStore struct {
|
||||
// reply can carry.
|
||||
const MaxStackDepth = 2
|
||||
|
||||
// DefaultClarifyTTL — how long a parked question stays his answer to give.
|
||||
// Short, like confirmTTL in voice.go: a clarifying question is a same-breath
|
||||
// gesture, and a stale one should not eat a later utterance.
|
||||
const DefaultClarifyTTL = 90 * time.Second
|
||||
|
||||
func NewClarifyStore(defaultTTL time.Duration) *ClarifyStore {
|
||||
if defaultTTL <= 0 {
|
||||
// Short, like confirmTTL in voice.go: a clarifying question is a
|
||||
// same-breath gesture, a stale one should not eat a later utterance.
|
||||
defaultTTL = 90 * time.Second
|
||||
defaultTTL = DefaultClarifyTTL
|
||||
}
|
||||
return &ClarifyStore{
|
||||
stacks: make(map[string][]*PendingQuestion),
|
||||
@@ -151,9 +154,15 @@ func (s *ClarifyStore) Push(id string, q *PendingQuestion) *PendingQuestion {
|
||||
return dropped
|
||||
}
|
||||
|
||||
// Peek returns the live question on top, or nil when there is none. Expired
|
||||
// entries below it are left alone: TakeExpired is what reports those, and
|
||||
// dropping one here would be the silent death this store is careful about.
|
||||
// Peek returns the live question on top, or nil when there is none. An expired
|
||||
// top takes the whole stack with it, exactly as Pop does: the clock that killed
|
||||
// it has been running for everything underneath too.
|
||||
//
|
||||
// That drop is silent, which is the death this store is otherwise careful
|
||||
// about, so TakeExpired has to run BEFORE Peek on a turn — it is what counts the
|
||||
// dropped questions and tells him they are gone. cmd/mavend/voice.go calls
|
||||
// clarifyExpiredNotice first for that reason, and reordering the two makes the
|
||||
// notice unreachable rather than wrong.
|
||||
func (s *ClarifyStore) Peek(id string, now time.Time) *PendingQuestion {
|
||||
s.mu.RLock()
|
||||
stack := s.stacks[id]
|
||||
|
||||
@@ -87,9 +87,14 @@ type SessionStore struct {
|
||||
persist SessionPersister // may be nil: memory only (tests, no-store paths)
|
||||
}
|
||||
|
||||
// DefaultSessionTTL — how long a turn stays available to inherit from. Longer
|
||||
// than DefaultClarifyTTL because this is not a question waiting on an answer:
|
||||
// it is the last thing said, and a follow-up may land after a real pause.
|
||||
const DefaultSessionTTL = 2 * time.Minute
|
||||
|
||||
func NewSessionStore(defaultTTL time.Duration) *SessionStore {
|
||||
if defaultTTL <= 0 {
|
||||
defaultTTL = 2 * time.Minute
|
||||
defaultTTL = DefaultSessionTTL
|
||||
}
|
||||
return &SessionStore{
|
||||
sessions: make(map[string]*Session),
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package email
|
||||
|
||||
import "strings"
|
||||
|
||||
// windows-1251 (and its ASCII-compatible low half) is decoded here rather than
|
||||
// pulled in from x/text.
|
||||
//
|
||||
@@ -35,14 +37,19 @@ var cp1251High = [128]rune{
|
||||
|
||||
// decodeCP1251 maps each byte through the table. Every byte has a defined
|
||||
// meaning in this charset, so decoding cannot fail.
|
||||
//
|
||||
// It writes into a Builder rather than collecting runes: a []rune of the whole
|
||||
// body is four bytes a character and was then copied again into the string, so
|
||||
// a 1 MiB cp1251 mail allocated about 6 MiB to produce roughly 2.
|
||||
func decodeCP1251(b []byte) string {
|
||||
out := make([]rune, 0, len(b))
|
||||
var out strings.Builder
|
||||
out.Grow(len(b))
|
||||
for _, c := range b {
|
||||
if c < 0x80 {
|
||||
out = append(out, rune(c))
|
||||
out.WriteByte(c)
|
||||
continue
|
||||
}
|
||||
out = append(out, cp1251High[c-0x80])
|
||||
out.WriteRune(cp1251High[c-0x80])
|
||||
}
|
||||
return string(out)
|
||||
return out.String()
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -57,7 +58,10 @@ type Message struct {
|
||||
// through, because a subject line alone is often the whole task ("Счёт за
|
||||
// интернет"). Only a message whose headers cannot be read at all is an error.
|
||||
func ParseMessage(uid uint32, raw []byte) (Message, error) {
|
||||
m, err := mail.ReadMessage(strings.NewReader(string(raw)))
|
||||
// bytes.NewReader, not strings.NewReader(string(raw)): the conversion copied
|
||||
// the whole message, and MaxMessageBytes lets that be 2 MiB per mail on a box
|
||||
// already holding the resident model.
|
||||
m, err := mail.ReadMessage(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return Message{}, fmt.Errorf("email: parse message: %w", err)
|
||||
}
|
||||
@@ -82,6 +86,23 @@ func ParseMessage(uid uint32, raw []byte) (Message, error) {
|
||||
// wholesale — an attachment is a file, not a sentence, and reading one would
|
||||
// mean parsing arbitrary formats from the network.
|
||||
func plaintextBody(contentType, encoding string, body io.Reader) (string, error) {
|
||||
return plaintextBodyAt(contentType, encoding, body, 0)
|
||||
}
|
||||
|
||||
// MaxMIMEDepth — how deep the MIME tree is walked.
|
||||
//
|
||||
// The nesting comes off the wire, so the recursion depth is the sender's to
|
||||
// pick: a boundary line is a few bytes, and one message inside MaxMessageBytes
|
||||
// can declare tens of thousands of multipart levels. Real mail is three deep
|
||||
// (mixed, then alternative, then related), so a message past this is malformed
|
||||
// or hostile and truncating the walk costs a body nobody was going to read.
|
||||
const MaxMIMEDepth = 12
|
||||
|
||||
// plaintextBodyAt is plaintextBody carrying the current nesting depth.
|
||||
func plaintextBodyAt(contentType, encoding string, body io.Reader, depth int) (string, error) {
|
||||
if depth > MaxMIMEDepth {
|
||||
return "", nil
|
||||
}
|
||||
mediaType, params, err := mime.ParseMediaType(contentType)
|
||||
if contentType == "" || err != nil {
|
||||
// No Content-Type at all is legal and means text/plain; a broken one is
|
||||
@@ -94,7 +115,7 @@ func plaintextBody(contentType, encoding string, body io.Reader) (string, error)
|
||||
if boundary == "" {
|
||||
return "", fmt.Errorf("email: multipart without boundary")
|
||||
}
|
||||
plain, html, err := multipartText(multipart.NewReader(body, boundary))
|
||||
plain, html, err := multipartText(multipart.NewReader(body, boundary), depth+1)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -124,7 +145,7 @@ func plaintextBody(contentType, encoding string, body io.Reader) (string, error)
|
||||
// contribute either kind. Folding a nested level's answer into one string put
|
||||
// HTML-derived text in the plain bucket, and a real text/plain sibling later in
|
||||
// the message was then thrown away by the "plain is already set" guard.
|
||||
func multipartText(mr *multipart.Reader) (plain, html string, err error) {
|
||||
func multipartText(mr *multipart.Reader, depth int) (plain, html string, err error) {
|
||||
for {
|
||||
part, err := mr.NextPart()
|
||||
if err == io.EOF {
|
||||
@@ -143,8 +164,8 @@ func multipartText(mr *multipart.Reader) (plain, html string, err error) {
|
||||
switch {
|
||||
case strings.HasPrefix(mediaType, "multipart/"):
|
||||
var np, nh string
|
||||
if b := params["boundary"]; b != "" {
|
||||
np, nh, _ = multipartText(multipart.NewReader(part, b))
|
||||
if b := params["boundary"]; b != "" && depth <= MaxMIMEDepth {
|
||||
np, nh, _ = multipartText(multipart.NewReader(part, b), depth+1)
|
||||
}
|
||||
part.Close()
|
||||
if plain == "" {
|
||||
@@ -154,7 +175,7 @@ func multipartText(mr *multipart.Reader) (plain, html string, err error) {
|
||||
html = nh
|
||||
}
|
||||
default:
|
||||
text, terr := plaintextBody(ct, part.Header.Get("Content-Transfer-Encoding"), part)
|
||||
text, terr := plaintextBodyAt(ct, part.Header.Get("Content-Transfer-Encoding"), part, depth)
|
||||
part.Close()
|
||||
if terr != nil || strings.TrimSpace(text) == "" {
|
||||
continue
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -143,6 +144,24 @@ func TestParseTruncatesLongBody(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Nesting depth comes off the wire, so a hostile message must not get to pick
|
||||
// the recursion depth. The walk stops and the headers still come through.
|
||||
func TestParseMessageBoundsMIMEDepth(t *testing.T) {
|
||||
var b strings.Builder
|
||||
b.WriteString("Subject: deep\r\nMIME-Version: 1.0\r\n")
|
||||
for i := 0; i < MaxMIMEDepth+20; i++ {
|
||||
fmt.Fprintf(&b, "Content-Type: multipart/mixed; boundary=\"b%d\"\r\n\r\n--b%d\r\n", i, i)
|
||||
}
|
||||
b.WriteString("Content-Type: text/plain\r\n\r\nглубоко\r\n")
|
||||
msg, err := ParseMessage(7, []byte(b.String()))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseMessage: %v", err)
|
||||
}
|
||||
if msg.Subject != "deep" {
|
||||
t.Errorf("Subject = %q, want the headers to survive", msg.Subject)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollapseSqueezesBlankLines(t *testing.T) {
|
||||
got := collapse(" a b \r\n\r\n\r\n\r\n c \r\n")
|
||||
if got != "a b\n\nc" {
|
||||
|
||||
@@ -131,9 +131,15 @@ const (
|
||||
codeInternal = "internal"
|
||||
)
|
||||
|
||||
// codeOf maps a server-side sentinel to its wire code. Anything not matched
|
||||
// is codeInternal — we never leak internal Go error text to a module; it
|
||||
// gets a generic "internal" and the daemon logs the real error server-side.
|
||||
// codeOf maps a server-side sentinel to its wire code. Anything not matched is
|
||||
// codeInternal.
|
||||
//
|
||||
// This used to claim the text of an unmatched error stays server-side. It does
|
||||
// not: rpcErr below ships err.Error() for codeInternal and codeBadParams,
|
||||
// deliberately, because on those two codes the text is the whole diagnostic and
|
||||
// a module has no other way to see it. Worth knowing before putting a secret in
|
||||
// an error string, and worth knowing twice on a tcp seam, where that string
|
||||
// leaves the box.
|
||||
func codeOf(err error) string {
|
||||
switch {
|
||||
case err == nil:
|
||||
|
||||
@@ -39,11 +39,16 @@ type Client struct {
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// clientTimeout — the whole request, search or article. The server is on the
|
||||
// same box (see the package doc), so this is slack for a cold ZIM read, not a
|
||||
// budget tuned against a flaky link the way websearch.DefaultTimeout is.
|
||||
const clientTimeout = 10 * time.Second
|
||||
|
||||
// New makes a client for a Kiwix base URL like http://127.0.0.1:8034.
|
||||
func New(baseURL string) *Client {
|
||||
return &Client{
|
||||
base: strings.TrimRight(baseURL, "/"),
|
||||
http: &http.Client{Timeout: 10 * time.Second},
|
||||
http: &http.Client{Timeout: clientTimeout},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -62,10 +62,10 @@ func mustLoad() lexiconFile {
|
||||
}
|
||||
for _, name := range []string{
|
||||
"interrogatives", "capture_verbs", "narrative_requests", "cardinals", "ordinals",
|
||||
"day_offsets", "weekdays", "months_genitive", "hours_spoken",
|
||||
"day_offsets", "weekdays", "weekdays_english", "months_genitive", "hours_spoken",
|
||||
"not_place_after_v", "parts_of_day", "reminder_verbs", "half_hour",
|
||||
"filler_particles", "task_done_words", "task_drop_words",
|
||||
"confirm_yes", "confirm_no",
|
||||
"confirm_yes", "confirm_no", "hour_units", "minute_units",
|
||||
} {
|
||||
s, ok := f.Sets[name]
|
||||
if !ok || (len(s.Words) == 0 && len(s.Values) == 0) {
|
||||
@@ -139,7 +139,40 @@ func TaskDropWords() []string { return words("task_drop_words") }
|
||||
// making the utterance a request of its own. A caller strips these (along with
|
||||
// the numbers and the other closed time sets) to see whether an utterance
|
||||
// carries any content beside the value it was asked for. See the set's note.
|
||||
func SlotValueFrame() []string { return words("slot_value_frame") }
|
||||
// The hour and the minute nouns are part of the frame and are kept in their own
|
||||
// sets, so there is one copy of each closed class rather than a copy per caller.
|
||||
func SlotValueFrame() []string {
|
||||
out := words("slot_value_frame")
|
||||
out = append(out, HourUnits()...)
|
||||
out = append(out, MinuteUnits()...)
|
||||
return out
|
||||
}
|
||||
|
||||
// HourUnits returns every form of the hour noun, and MinuteUnits every form of
|
||||
// the minute noun. One home for each, because four router sets used to list the
|
||||
// hour and all four stopped at "часу" (V-609). A caller folding time words into
|
||||
// one set reads these; a caller asking about a single word reads IsHourUnit or
|
||||
// IsMinuteUnit.
|
||||
func HourUnits() []string { return words("hour_units") }
|
||||
|
||||
// MinuteUnits — see HourUnits.
|
||||
func MinuteUnits() []string { return words("minute_units") }
|
||||
|
||||
// IsHourUnit reports whether a word is the hour noun in any form.
|
||||
func IsHourUnit(word string) bool { return inSet("hour_units", word) }
|
||||
|
||||
// IsMinuteUnit reports whether a word is the minute noun in any form.
|
||||
func IsMinuteUnit(word string) bool { return inSet("minute_units", word) }
|
||||
|
||||
func inSet(set, word string) bool {
|
||||
w := norm(word)
|
||||
for _, s := range ru.Sets[set].Words {
|
||||
if w == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// DialogueCancel returns the ways he calls off the request Maven is assembling.
|
||||
// Distinct from TaskDropWords, which abandons an item that already exists.
|
||||
@@ -283,6 +316,20 @@ func DayOffsetIn(text string) (int, bool) {
|
||||
// Go's time.Weekday. An index off the end returns "".
|
||||
func Weekday(i int) string { return at("weekdays", i) }
|
||||
|
||||
// Weekdays returns the seven Russian names in one slice, Sunday first, for a
|
||||
// caller matching a token against all of them rather than rendering one. Only
|
||||
// the nominative is here: every other case lemmatises to it, so an oblique form
|
||||
// is morph's question and not a second list (V-581).
|
||||
func Weekdays() []string { return words("weekdays") }
|
||||
|
||||
// WeekdayEnglish reports the Go time.Weekday index an English weekday names,
|
||||
// singular or plural. English needs the list that Russian does not, because the
|
||||
// vendored dictionary is Russian and leaves "mondays" as it found it.
|
||||
func WeekdayEnglish(word string) (int, bool) {
|
||||
n, ok := ru.Sets["weekdays_english"].Values[norm(word)]
|
||||
return n, ok
|
||||
}
|
||||
|
||||
// MonthGenitive returns the month name a date takes — "10 июля", not "июль".
|
||||
// The set is 1-indexed, so MonthGenitive(int(t.Month())) is the whole call.
|
||||
func MonthGenitive(m int) string { return at("months_genitive", m) }
|
||||
|
||||
@@ -56,13 +56,13 @@
|
||||
}
|
||||
},
|
||||
"cardinals": {
|
||||
"note": "Number words as spoken, with the gender variants Russian requires (один/одна/одно and два/две agree with the noun that follows) and the oblique forms, because a spoken time declines: \"в семь\", \"к семи\", \"около семи\" are three forms of one hour (Vikunja #530). Values are the number itself. Twenties and up are compounds and are read as their parts, so only the round members are listed.",
|
||||
"note": "Number words as spoken, with the gender variants Russian requires (один/одна/одно and два/две agree with the noun that follows) and the oblique forms, because a spoken time declines: \"в семь\", \"к семи\", \"около семи\" are three forms of one hour (Vikunja #530). Values are the number itself. Twenties and up are compounds and are read as their parts, so only the round members are listed. From five up one oblique form serves the genitive, dative and prepositional, so \"пяти\" is the whole set; one to four decline separately and carry the dative and instrumental of their own, because \"к двум часам\" and \"к трём\" are hours he says (V-581).",
|
||||
"values": {
|
||||
"ноль": 0, "нуль": 0, "zero": 0,
|
||||
"один": 1, "одна": 1, "одно": 1, "одного": 1, "одной": 1, "одну": 1, "one": 1,
|
||||
"два": 2, "две": 2, "двух": 2, "two": 2,
|
||||
"три": 3, "трёх": 3, "трех": 3, "three": 3,
|
||||
"четыре": 4, "четырёх": 4, "четырех": 4, "four": 4,
|
||||
"один": 1, "одна": 1, "одно": 1, "одного": 1, "одной": 1, "одну": 1, "одному": 1, "одним": 1, "one": 1,
|
||||
"два": 2, "две": 2, "двух": 2, "двум": 2, "двумя": 2, "two": 2,
|
||||
"три": 3, "трёх": 3, "трех": 3, "трём": 3, "трем": 3, "тремя": 3, "three": 3,
|
||||
"четыре": 4, "четырёх": 4, "четырех": 4, "четырём": 4, "четырем": 4, "четырьмя": 4, "four": 4,
|
||||
"пять": 5, "пяти": 5, "five": 5,
|
||||
"шесть": 6, "шести": 6, "six": 6,
|
||||
"семь": 7, "семи": 7, "seven": 7,
|
||||
@@ -111,6 +111,18 @@
|
||||
"четверг", "пятница", "суббота"
|
||||
]
|
||||
},
|
||||
"weekdays_english": {
|
||||
"note": "The English weekday names with their Go time.Weekday index, plus the plural a habit is spoken in (\"on mondays\"). English is listed as words where Russian is not, because the vendored dictionary is Russian: it lemmatises \"пятницу\" to \"пятница\" on its own and leaves \"mondays\" alone (V-581). So the Russian side of a weekday match is grammar and the English side is data.",
|
||||
"values": {
|
||||
"sunday": 0, "sundays": 0,
|
||||
"monday": 1, "mondays": 1,
|
||||
"tuesday": 2, "tuesdays": 2,
|
||||
"wednesday": 3, "wednesdays": 3,
|
||||
"thursday": 4, "thursdays": 4,
|
||||
"friday": 5, "fridays": 5,
|
||||
"saturday": 6, "saturdays": 6
|
||||
}
|
||||
},
|
||||
"months_genitive": {
|
||||
"note": "The form a date takes: \"10 июля\", not \"июль\". 1-indexed, so slot 0 is empty and month numbers need no arithmetic.",
|
||||
"words": [
|
||||
@@ -198,6 +210,20 @@
|
||||
"передумал", "передумала", "неактуально"
|
||||
]
|
||||
},
|
||||
"hour_units": {
|
||||
"note": "Every form of the hour noun, Russian and English (V-609). One home for a closed class that four router sets used to list separately, and all four stopped at \"часу\": \"напомни к двум часам\" lost its hour and the reminder was left asking \"Когда?\". Russian declines, so the dative plural is as ordinary a way to say an hour as the accusative singular. A caller that folds time words into one set reads HourUnits; a caller asking about one word reads IsHourUnit.",
|
||||
"words": [
|
||||
"час", "часа", "часов", "часу", "часам", "часами", "часах",
|
||||
"hour", "hours"
|
||||
]
|
||||
},
|
||||
"minute_units": {
|
||||
"note": "Every form of the minute noun, Russian and English (V-609). Same class as hour_units one noun over, and it had the same gap: the dative plural \"минутам\" was missing everywhere \"минут\" and \"минуты\" were present.",
|
||||
"words": [
|
||||
"минута", "минуты", "минуту", "минут", "минуте", "минутам", "минутами", "минутах",
|
||||
"minute", "minutes"
|
||||
]
|
||||
},
|
||||
"slot_value_frame": {
|
||||
"note": "The words that can stand around a bare slot value without making the utterance a request of its own (Vikunja #560). Prepositions, hedges and the nouns a spoken time is built from: strip these, the numbers, the interrogatives, the filler particles and the other time sets, and whatever is left is the utterance's OWN content. \"а что если в 11:00\" leaves nothing and is an answer; \"какая сейчас погода в Риме\" leaves \"погода\" and \"Риме\" and is not. Closed because each part of it is closed — Russian has a fixed list of prepositions, and a clock is built from a fixed list of nouns. It is not a stopword list: a word goes in only if it can never be the thing he is asking about.",
|
||||
"words": [
|
||||
@@ -205,10 +231,10 @@
|
||||
"at", "on", "in", "by", "to", "till", "until", "after", "before", "about", "for",
|
||||
"нет", "не", "да", "ага", "угу", "ой", "ох", "тогда", "лучше", "может", "можно", "наверное", "наверно", "пожалуй", "точнее", "скорее", "если", "пусть", "прости", "извини", "слушай", "значит", "как-то", "типа", "вообще-то",
|
||||
"no", "yes", "yeah", "ok", "okay", "sorry", "maybe", "actually", "rather", "then", "well",
|
||||
"час", "часа", "часов", "часу", "часам", "минут", "минута", "минуты", "минуту", "минутах", "полдень", "полночь", "полдня",
|
||||
"полдень", "полночь", "полдня",
|
||||
"утра", "утро", "утру", "дня", "день", "днями", "вечера", "вечер", "вечеру", "ночи", "ночь", "ночью",
|
||||
"сейчас", "теперь", "сегодняшний", "ближайший", "ближайшее",
|
||||
"hour", "hours", "minute", "minutes", "noon", "midnight", "am", "pm", "oclock", "now"
|
||||
"noon", "midnight", "am", "pm", "oclock", "now"
|
||||
]
|
||||
},
|
||||
"dialogue_cancel": {
|
||||
|
||||
@@ -36,6 +36,46 @@ func TestClosedSetsAreComplete(t *testing.T) {
|
||||
if _, ok := Cardinal("бэкап"); ok {
|
||||
t.Error("Cardinal must not answer for a word that is not a number")
|
||||
}
|
||||
|
||||
// A spoken hour declines, and one to four decline further than the rest:
|
||||
// "к двум часам" and "к трём" are hours, and only the dative says so (V-581).
|
||||
for _, tc := range []struct {
|
||||
word string
|
||||
want int
|
||||
}{
|
||||
{"одному", 1}, {"двум", 2}, {"двумя", 2}, {"трём", 3}, {"трем", 3},
|
||||
{"четырём", 4}, {"четырем", 4}, {"пяти", 5}, {"семи", 7},
|
||||
} {
|
||||
if got, ok := Cardinal(tc.word); !ok || got != tc.want {
|
||||
t.Errorf("Cardinal(%q) = %d, %v; want %d, true", tc.word, got, ok, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWeekdaysAreOneList — the second copy of a closed class is the bug (V-581).
|
||||
// Weekdays lived in four files outside this one, so the list is handed out whole
|
||||
// and the English forms, which the Russian dictionary cannot lemmatise, are here.
|
||||
func TestWeekdaysAreOneList(t *testing.T) {
|
||||
days := Weekdays()
|
||||
if len(days) != 7 || days[0] != "воскресенье" || days[1] != "понедельник" {
|
||||
t.Fatalf("Weekdays() = %v; want the seven, Sunday first", days)
|
||||
}
|
||||
for i, name := range days {
|
||||
if Weekday(i) != name {
|
||||
t.Errorf("Weekdays()[%d] = %q, but Weekday(%d) = %q", i, name, i, Weekday(i))
|
||||
}
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
word string
|
||||
want int
|
||||
}{{"sunday", 0}, {"monday", 1}, {"mondays", 1}, {"Friday", 5}, {"saturdays", 6}} {
|
||||
if got, ok := WeekdayEnglish(tc.word); !ok || got != tc.want {
|
||||
t.Errorf("WeekdayEnglish(%q) = %d, %v; want %d, true", tc.word, got, ok, tc.want)
|
||||
}
|
||||
}
|
||||
if _, ok := WeekdayEnglish("понедельник"); ok {
|
||||
t.Error("WeekdayEnglish answered for a Russian word; that side is morph's")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDayOffsetHasNoOrderingTrap — the defect a lookup removes. The callers this
|
||||
|
||||
+33
-7
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
@@ -46,6 +47,7 @@ type Pair struct {
|
||||
interval time.Duration
|
||||
http *http.Client
|
||||
stop chan struct{}
|
||||
stopOnce sync.Once
|
||||
}
|
||||
|
||||
// ErrRemoteUnavailable — the workstation model was required and is not
|
||||
@@ -107,13 +109,11 @@ func (p *Pair) Start(ctx context.Context) {
|
||||
}()
|
||||
}
|
||||
|
||||
// Stop ends the prober. Idempotent.
|
||||
// Stop ends the prober. Idempotent, and safe from two goroutines at once. The
|
||||
// check-then-close it replaced let both callers see an open channel and the
|
||||
// second close panicked, which turned a shutdown race into a crash.
|
||||
func (p *Pair) Stop() {
|
||||
select {
|
||||
case <-p.stop:
|
||||
default:
|
||||
close(p.stop)
|
||||
}
|
||||
p.stopOnce.Do(func() { close(p.stop) })
|
||||
}
|
||||
|
||||
// Available reports whether the workstation will take work right now. It reads
|
||||
@@ -170,7 +170,9 @@ func (p *Pair) Complete(ctx context.Context, r Req) (string, error) {
|
||||
}
|
||||
why := "workstation down"
|
||||
if p.Available() {
|
||||
out, err := p.remote.Complete(ctx, r)
|
||||
rctx, cancel := remoteBudget(ctx)
|
||||
out, err := p.remote.Complete(rctx, r)
|
||||
cancel()
|
||||
if err == nil {
|
||||
log.Print("llm: served by the workstation model")
|
||||
return out, nil
|
||||
@@ -184,6 +186,30 @@ func (p *Pair) Complete(ctx context.Context, r Req) (string, error) {
|
||||
return p.floor.Complete(ctx, r)
|
||||
}
|
||||
|
||||
// remoteBudget bounds the workstation attempt so the floor still has time to
|
||||
// answer. A turn carrying a deadline used to hand the whole of it to the
|
||||
// remote, so a workstation that accepted the connection and then hung ate the
|
||||
// budget and the fallback ran on an already-expired context: the floor
|
||||
// returned the deadline error and the turn broke on the workstation being
|
||||
// slow, which docs/offload.md says must never happen. Half is the split
|
||||
// because both halves have to be able to finish, and there is no reason to
|
||||
// prefer either one when the remote is the part that failed.
|
||||
//
|
||||
// A context with no deadline is left alone. The remote client's own timeout
|
||||
// (workstation.timeout, 90s by default) bounds it there, and shortening that
|
||||
// silently would change the configured budget.
|
||||
func remoteBudget(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
dl, ok := ctx.Deadline()
|
||||
if !ok {
|
||||
return ctx, func() {}
|
||||
}
|
||||
left := time.Until(dl)
|
||||
if left <= 0 {
|
||||
return ctx, func() {}
|
||||
}
|
||||
return context.WithTimeout(ctx, left/2)
|
||||
}
|
||||
|
||||
// CompleteRemote runs r on the workstation or refuses. It never falls back,
|
||||
// because for a world question the resident 1.7B does not answer worse, it
|
||||
// invents. Callers turn ErrRemoteUnavailable into a named gap.
|
||||
|
||||
@@ -154,6 +154,48 @@ func TestRemoteErrorMidRequestFallsBack(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A workstation that accepts the connection and then hangs must not spend the
|
||||
// whole turn budget. It used to: the remote got the caller's context unchanged,
|
||||
// so the fallback ran on an expired one and the floor returned the deadline
|
||||
// error instead of an answer. The turn broke on the workstation being slow,
|
||||
// which is the one outcome docs/offload.md rules out.
|
||||
func TestHangingRemoteLeavesTheFloorABudget(t *testing.T) {
|
||||
var floorHits atomic.Int64
|
||||
// released, not r.Context().Done(): httptest.Server.Close waits for the
|
||||
// handler, and a handler that only watches the request context can outlive
|
||||
// the test when the client hangs up without the server noticing.
|
||||
released := make(chan struct{})
|
||||
hang := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
select {
|
||||
case <-released:
|
||||
case <-r.Context().Done():
|
||||
}
|
||||
}))
|
||||
defer hang.Close()
|
||||
defer close(released)
|
||||
floor := completionServer(t, "floor", &floorHits)
|
||||
up := &atomic.Bool{}
|
||||
up.Store(true)
|
||||
health := healthServer(t, up)
|
||||
|
||||
p := NewPair(New(hang.URL, time.Minute), New(floor.URL, time.Minute), health.URL, time.Hour)
|
||||
p.Start(context.Background())
|
||||
defer p.Stop()
|
||||
if !waitFor(t, p.Available) {
|
||||
t.Fatal("prober never saw the remote come up")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 400*time.Millisecond)
|
||||
defer cancel()
|
||||
out, err := p.Complete(ctx, Req{User: "привет"})
|
||||
if err != nil {
|
||||
t.Fatalf("complete: %v", err)
|
||||
}
|
||||
if out != "floor" || floorHits.Load() != 1 {
|
||||
t.Fatalf("out = %q, floor hits = %d", out, floorHits.Load())
|
||||
}
|
||||
}
|
||||
|
||||
// The naming half of the degradation rule. A world question must not be handed
|
||||
// to the resident model, because it answers by inventing.
|
||||
func TestCompleteRemoteNamesTheGap(t *testing.T) {
|
||||
|
||||
+22
-4
@@ -265,6 +265,16 @@ func (s *Store) Put(kind Kind, mime, source string, data []byte) (Blob, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Every return past the reservation has to give it back, so the defer owns
|
||||
// that rather than each error path: a path that forgot over-counted the
|
||||
// store until the next Open re-walked the directory.
|
||||
stored := false
|
||||
defer func() {
|
||||
if fresh && !stored {
|
||||
s.release(b.Size)
|
||||
}
|
||||
}()
|
||||
|
||||
// The sidecar goes first. Written second, a full disk or a crash between
|
||||
// the two left the bytes on disk with no sidecar, and List only sees
|
||||
// sidecars, so Prune could never collect them: Put returned an error and an
|
||||
@@ -274,11 +284,9 @@ func (s *Store) Put(kind Kind, mime, source string, data []byte) (Blob, error) {
|
||||
}
|
||||
if err := writeFile(blobPath, data); err != nil {
|
||||
_ = os.Remove(metaPath)
|
||||
if fresh {
|
||||
s.release(b.Size)
|
||||
}
|
||||
return Blob{}, err
|
||||
}
|
||||
stored = true
|
||||
return b, nil
|
||||
}
|
||||
|
||||
@@ -331,12 +339,22 @@ func (s *Store) PutFile(kind Kind, mime, source, src string) (Blob, error) {
|
||||
return Blob{}, err
|
||||
}
|
||||
}
|
||||
// Same reasoning as Put: the reservation is released by one defer, not by
|
||||
// whichever error path remembered to.
|
||||
stored := false
|
||||
defer func() {
|
||||
if fresh && !stored {
|
||||
s.release(b.Size)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := writeMeta(metaPath, b); err != nil {
|
||||
return Blob{}, err
|
||||
}
|
||||
if !fresh {
|
||||
// Same bytes already here. Drop the spool copy.
|
||||
_ = os.Remove(src)
|
||||
stored = true
|
||||
return b, nil
|
||||
}
|
||||
if err := os.Chmod(src, filePerm); err != nil {
|
||||
@@ -344,9 +362,9 @@ func (s *Store) PutFile(kind Kind, mime, source, src string) (Blob, error) {
|
||||
}
|
||||
if err := os.Rename(src, blobPath); err != nil {
|
||||
_ = os.Remove(metaPath)
|
||||
s.release(b.Size)
|
||||
return Blob{}, fmt.Errorf("media: move spool: %w", err)
|
||||
}
|
||||
stored = true
|
||||
return b, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -287,6 +287,73 @@ func TestPutLeavesNothingWhenTheBytesCannotBeWritten(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// An over-counted store answers ErrStoreFull while the disk has room, and only
|
||||
// the next Open corrects it. So every failed write has to give its reservation
|
||||
// back, not just the one that remembered to.
|
||||
func TestPutReleasesTheBudgetWhenTheSidecarCannotBeWritten(t *testing.T) {
|
||||
s := testStore(t)
|
||||
data := []byte("no sidecar for this")
|
||||
blockSidecar(t, s, KindImage, data)
|
||||
if _, err := s.Put(KindImage, "image/png", "web:upload", data); err == nil {
|
||||
t.Fatal("put must fail")
|
||||
}
|
||||
if s.Total() != 0 {
|
||||
t.Errorf("total = %d, want the failed put not counted", s.Total())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutFileReleasesTheBudgetWhenTheSidecarCannotBeWritten(t *testing.T) {
|
||||
s := testStore(t)
|
||||
data := []byte("no sidecar for this either")
|
||||
blockSidecar(t, s, KindAudio, data)
|
||||
src := filepath.Join(t.TempDir(), "capture.wav")
|
||||
if err := os.WriteFile(src, data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.PutFile(KindAudio, "audio/wav", "meeting", src); err == nil {
|
||||
t.Fatal("put file must fail")
|
||||
}
|
||||
if s.Total() != 0 {
|
||||
t.Errorf("total = %d, want the failed put not counted", s.Total())
|
||||
}
|
||||
}
|
||||
|
||||
// The chmod arm is PutFile's alone: Put never touches a spool file.
|
||||
func TestPutFileReleasesTheBudgetWhenTheSpoolCannotBeChmodded(t *testing.T) {
|
||||
if os.Geteuid() == 0 {
|
||||
t.Skip("root can chmod a file it does not own")
|
||||
}
|
||||
// A symlink to a file owned by somebody else. Stat and the hash follow it
|
||||
// and succeed; chmod follows it too and is refused.
|
||||
src := filepath.Join(t.TempDir(), "capture.wav")
|
||||
if err := os.Symlink("/etc/hosts", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info, err := os.Stat(src)
|
||||
if err != nil || info.Size() == 0 {
|
||||
t.Skip("no readable /etc/hosts to point at")
|
||||
}
|
||||
s := testStore(t)
|
||||
if _, err := s.PutFile(KindAudio, "audio/wav", "meeting", src); err == nil {
|
||||
t.Fatal("put file must fail")
|
||||
}
|
||||
if s.Total() != 0 {
|
||||
t.Errorf("total = %d, want the failed put not counted", s.Total())
|
||||
}
|
||||
}
|
||||
|
||||
// blockSidecar puts a directory where the sidecar for data has to go, so
|
||||
// writeMeta fails while the blob path is still free.
|
||||
func blockSidecar(t *testing.T, s *Store, kind Kind, data []byte) {
|
||||
t.Helper()
|
||||
sum := sha256.Sum256(data)
|
||||
id := hex.EncodeToString(sum[:])
|
||||
bucket := filepath.Join(s.dir, string(kind), id[:2])
|
||||
if err := os.MkdirAll(filepath.Join(bucket, id+".json"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// The per-blob cap bounds one call and nothing bounded their sum. 64 MiB per
|
||||
// call times unlimited calls inside a seven-day window fills the disk mavend's
|
||||
// database lives on.
|
||||
|
||||
+35
-19
@@ -104,14 +104,22 @@ func OptionalOnly(missing []Item) []Item {
|
||||
}
|
||||
|
||||
// Validate reports the first structural problem with a routine set: missing
|
||||
// name/items, an unparseable HH:MM, an inverted window, a duplicate item key
|
||||
// within a routine, or an out-of-range weekday. Called at config load so a
|
||||
// typo surfaces at startup, not as a silently-broken checklist at runtime.
|
||||
// name/items, an unparseable HH:MM, an inverted window, a duplicate routine or
|
||||
// item key, or an out-of-range weekday. Called at config load so a typo
|
||||
// surfaces at startup, not as a silently-broken checklist at runtime.
|
||||
//
|
||||
// Routine names must be unique because Due keys its once-a-day map by name. Two
|
||||
// routines sharing one would take turns suppressing each other's nudge.
|
||||
func Validate(routines []Routine) error {
|
||||
names := make(map[string]bool, len(routines))
|
||||
for _, r := range routines {
|
||||
if r.Name == "" {
|
||||
return fmt.Errorf("morning: name is required")
|
||||
}
|
||||
if names[r.Name] {
|
||||
return fmt.Errorf("morning routine %q: duplicate name", r.Name)
|
||||
}
|
||||
names[r.Name] = true
|
||||
if len(r.Items) == 0 {
|
||||
return fmt.Errorf("morning routine %q: at least one item is required", r.Name)
|
||||
}
|
||||
@@ -177,6 +185,17 @@ func Evaluate(r Routine, facts map[string]store.Fact, now time.Time) Status {
|
||||
return st
|
||||
}
|
||||
|
||||
// missing lists the items of r with no evidence in [start, now].
|
||||
func missing(r Routine, facts map[string]store.Fact, start, now time.Time) []Item {
|
||||
var out []Item
|
||||
for _, it := range r.Items {
|
||||
if !evidenced(it, facts, start, now) {
|
||||
out = append(out, it)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Outstanding reports the items of a routine that today has no evidence for,
|
||||
// whether or not the window is still open. Evaluate answers "what is missing
|
||||
// right now" and goes silent the moment the window closes; the day plan asks a
|
||||
@@ -191,13 +210,7 @@ func Outstanding(r Routine, facts map[string]store.Fact, now time.Time) []Item {
|
||||
if !ok || now.Before(start) {
|
||||
return nil
|
||||
}
|
||||
var missing []Item
|
||||
for _, it := range r.Items {
|
||||
if !evidenced(it, facts, start, now) {
|
||||
missing = append(missing, it)
|
||||
}
|
||||
}
|
||||
return missing
|
||||
return missing(r, facts, start, now)
|
||||
}
|
||||
|
||||
// Due returns the routines that have reached their nudge time today with at
|
||||
@@ -222,24 +235,19 @@ func Due(routines []Routine, facts map[string]store.Fact, last map[string]time.T
|
||||
if !ok || now.Before(nudgeAt) {
|
||||
continue
|
||||
}
|
||||
var missing []Item
|
||||
for _, it := range r.Items {
|
||||
if !evidenced(it, facts, start, now) {
|
||||
missing = append(missing, it)
|
||||
}
|
||||
}
|
||||
skipped := missing(r, facts, start, now)
|
||||
// A day where only the optional items were skipped is a fine day, and
|
||||
// nagging about it is what teaches him to stop listening (Vikunja
|
||||
// #473). The optional ones still travel in Missing so the message can
|
||||
// mention them when it is being sent anyway.
|
||||
if len(Required(missing)) == 0 {
|
||||
if len(Required(skipped)) == 0 {
|
||||
continue
|
||||
}
|
||||
if prev, seen := last[r.Name]; seen && sameDay(prev, now) {
|
||||
continue
|
||||
}
|
||||
last[r.Name] = now
|
||||
out = append(out, Candidate{Routine: r, Missing: missing})
|
||||
out = append(out, Candidate{Routine: r, Missing: skipped})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -284,13 +292,21 @@ func sameDay(a, b time.Time) bool {
|
||||
return ay == by && am == bm && ad == bd
|
||||
}
|
||||
|
||||
// parseHHMM reads a five-character "HH:MM". Every digit is checked as a digit:
|
||||
// arithmetic alone lets a stray character cancel out, so "2 :00" used to load
|
||||
// as 04:00 and Validate passed the typo it exists to catch.
|
||||
func parseHHMM(s string) (hour, min int, ok bool) {
|
||||
if len(s) != 5 || s[2] != ':' {
|
||||
return 0, 0, false
|
||||
}
|
||||
for _, i := range [4]int{0, 1, 3, 4} {
|
||||
if s[i] < '0' || s[i] > '9' {
|
||||
return 0, 0, false
|
||||
}
|
||||
}
|
||||
h := int(s[0]-'0')*10 + int(s[1]-'0')
|
||||
m := int(s[3]-'0')*10 + int(s[4]-'0')
|
||||
if h < 0 || h > 23 || m < 0 || m > 59 {
|
||||
if h > 23 || m > 59 {
|
||||
return 0, 0, false
|
||||
}
|
||||
return h, m, true
|
||||
|
||||
@@ -56,6 +56,19 @@ func TestValidate(t *testing.T) {
|
||||
if err := Validate([]Routine{bad}); err == nil {
|
||||
t.Fatal("expected error for duplicate item key")
|
||||
}
|
||||
|
||||
// Due keys its once-a-day map by name, so two routines sharing one would
|
||||
// suppress each other's nudge instead of both firing.
|
||||
if err := Validate([]Routine{r, r}); err == nil {
|
||||
t.Fatal("expected error for duplicate routine name")
|
||||
}
|
||||
|
||||
// Arithmetic alone let a stray character cancel out: "2 :00" read as 04:00.
|
||||
bad = r
|
||||
bad.WindowStart = "2 :00"
|
||||
if err := Validate([]Routine{bad}); err == nil {
|
||||
t.Fatal("expected error for a non-digit in window_start")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateInactiveOutsideWindow(t *testing.T) {
|
||||
|
||||
@@ -113,12 +113,12 @@ func BuildPlan(routines []Routine, facts map[string]store.Fact, events, reminder
|
||||
func checklistEntries(routines []Routine, facts map[string]store.Fact, now time.Time) []PlanEntry {
|
||||
var out []PlanEntry
|
||||
for _, r := range routines {
|
||||
missing := Outstanding(r, facts, now)
|
||||
if len(missing) == 0 {
|
||||
left := Outstanding(r, facts, now)
|
||||
if len(left) == 0 {
|
||||
continue
|
||||
}
|
||||
labels := make([]string, 0, len(missing))
|
||||
for _, it := range missing {
|
||||
labels := make([]string, 0, len(left))
|
||||
for _, it := range left {
|
||||
label := it.Label
|
||||
if label == "" {
|
||||
label = it.Key
|
||||
|
||||
+69
-11
@@ -31,6 +31,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
@@ -154,31 +155,78 @@ func clientHandshake(c net.Conn, token string) error {
|
||||
// Listener wraps a net.Listener so Accept performs the token check for a tcp
|
||||
// seam. A connection that fails the check is closed and never surfaces, so
|
||||
// the protocol above this layer only ever sees authorized peers.
|
||||
//
|
||||
// A unix seam takes none of that machinery: Accept delegates straight to the
|
||||
// wrapped listener, which is what it did before the token existed.
|
||||
type Listener struct {
|
||||
net.Listener
|
||||
addr Addr
|
||||
|
||||
start sync.Once
|
||||
closeOnce sync.Once
|
||||
conns chan net.Conn
|
||||
errc chan error // buffered 1, re-armed so every Accept sees the error
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// Accept returns the next authorized connection. Unauthorized peers are
|
||||
// dropped and Accept keeps waiting: a bad token is a rejected stranger, not a
|
||||
// reason to stop serving.
|
||||
//
|
||||
// Each tcp handshake runs in its own goroutine rather than inline here. A peer
|
||||
// that connects and then says nothing holds its greeting open for
|
||||
// handshakeTimeout, and inline that peer stalls every other connection for
|
||||
// five seconds — one silent stranger was enough to freeze the seam.
|
||||
func (l *Listener) Accept() (net.Conn, error) {
|
||||
if l.addr.IsUnix() {
|
||||
return l.Listener.Accept()
|
||||
}
|
||||
l.start.Do(func() { go l.acceptLoop() })
|
||||
select {
|
||||
case c := <-l.conns:
|
||||
return c, nil
|
||||
case err := <-l.errc:
|
||||
l.errc <- err
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// acceptLoop takes connections off the wrapped listener and greets each one
|
||||
// concurrently. It ends on the first listener error, which every later Accept
|
||||
// then reports.
|
||||
func (l *Listener) acceptLoop() {
|
||||
for {
|
||||
c, err := l.Listener.Accept()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
select {
|
||||
case l.errc <- err:
|
||||
case <-l.done:
|
||||
}
|
||||
return
|
||||
}
|
||||
if l.addr.IsUnix() {
|
||||
return c, nil
|
||||
}
|
||||
if err := serverHandshake(c, l.addr.Token); err != nil {
|
||||
_ = c.Close()
|
||||
continue
|
||||
}
|
||||
return c, nil
|
||||
go l.greet(c)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Listener) greet(c net.Conn) {
|
||||
if err := serverHandshake(c, l.addr.Token); err != nil {
|
||||
_ = c.Close()
|
||||
return
|
||||
}
|
||||
select {
|
||||
case l.conns <- c:
|
||||
case <-l.done:
|
||||
_ = c.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// Close stops the listener and releases any connection still waiting to be
|
||||
// handed to Accept.
|
||||
func (l *Listener) Close() error {
|
||||
l.closeOnce.Do(func() { close(l.done) })
|
||||
return l.Listener.Close()
|
||||
}
|
||||
|
||||
// Addr reports the parsed seam address this listener was built from.
|
||||
func (l *Listener) SeamAddr() Addr { return l.addr }
|
||||
|
||||
@@ -236,7 +284,7 @@ func Listen(a Addr) (*Listener, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Listener{Listener: ln, addr: a}, nil
|
||||
return wrap(ln, a), nil
|
||||
}
|
||||
if a.Token == "" {
|
||||
return nil, fmt.Errorf("netaddr: listen %s: tcp seam requires a token", a)
|
||||
@@ -245,7 +293,17 @@ func Listen(a Addr) (*Listener, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("netaddr: listen %s: %w", a, err)
|
||||
}
|
||||
return &Listener{Listener: ln, addr: a}, nil
|
||||
return wrap(ln, a), nil
|
||||
}
|
||||
|
||||
func wrap(ln net.Listener, a Addr) *Listener {
|
||||
return &Listener{
|
||||
Listener: ln,
|
||||
addr: a,
|
||||
conns: make(chan net.Conn),
|
||||
errc: make(chan error, 1),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func listenUnix(path string) (net.Listener, error) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"net"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A scheme-less address must stay unix. Every deploy in the tree writes a bare
|
||||
@@ -140,6 +141,41 @@ func TestTCPUngreetedPeerDoesNotKillTheListener(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A peer that connects and never speaks must not hold the seam. The greeting
|
||||
// it owes is bounded by handshakeTimeout, so serving it on the accept path
|
||||
// costs every later connection those five seconds.
|
||||
func TestTCPSilentPeerDoesNotStallTheSeam(t *testing.T) {
|
||||
ln, addr := listenLoopback(t, "s3cret")
|
||||
defer ln.Close()
|
||||
go echoOnce(ln)
|
||||
|
||||
mute, err := net.Dial("tcp", addr.Address)
|
||||
if err != nil {
|
||||
t.Fatalf("mute dial: %v", err)
|
||||
}
|
||||
defer mute.Close()
|
||||
|
||||
done := make(chan string, 1)
|
||||
go func() {
|
||||
c, err := Dial(addr)
|
||||
if err != nil {
|
||||
done <- "dial: " + err.Error()
|
||||
return
|
||||
}
|
||||
defer c.Close()
|
||||
done <- roundTrip(t, c, "still here")
|
||||
}()
|
||||
|
||||
select {
|
||||
case got := <-done:
|
||||
if got != "still here" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
case <-time.After(handshakeTimeout / 2):
|
||||
t.Fatal("a silent peer stalled the listener")
|
||||
}
|
||||
}
|
||||
|
||||
// A tcp seam with no token is a misconfiguration, and it must fail at bind
|
||||
// rather than serve the owner's turns to anyone who connects.
|
||||
func TestTCPListenRequiresToken(t *testing.T) {
|
||||
|
||||
@@ -33,6 +33,7 @@ import (
|
||||
"net/netip"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -323,7 +324,7 @@ scan:
|
||||
go func(addr string, port int) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
if s.dial(ctx, net.JoinHostPort(addr, itoa(port)), s.cfg.Timeout) {
|
||||
if s.dial(ctx, net.JoinHostPort(addr, strconv.Itoa(port)), s.cfg.Timeout) {
|
||||
results <- result{addr: addr, ports: []int{port}}
|
||||
}
|
||||
}(addr, port)
|
||||
@@ -371,8 +372,6 @@ scan:
|
||||
return Result{Hosts: out, Truncated: truncated}, nil
|
||||
}
|
||||
|
||||
func itoa(n int) string { return fmt.Sprintf("%d", n) }
|
||||
|
||||
func dialTCP(ctx context.Context, addr string, timeout time.Duration) bool {
|
||||
d := net.Dialer{Timeout: timeout}
|
||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
|
||||
@@ -17,6 +17,8 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/lexicon"
|
||||
)
|
||||
|
||||
// Facts — the optional, deployment-specific half of the block. All fields may
|
||||
@@ -34,12 +36,10 @@ type Facts struct {
|
||||
Tools bool // at least one shell act is on the allowlist
|
||||
}
|
||||
|
||||
var ruWeekdays = [...]string{"воскресенье", "понедельник", "вторник", "среда", "четверг", "пятница", "суббота"}
|
||||
|
||||
var ruMonths = [...]string{
|
||||
"января", "февраля", "марта", "апреля", "мая", "июня",
|
||||
"июля", "августа", "сентября", "октября", "ноября", "декабря",
|
||||
}
|
||||
// The weekday and month names are closed classes and live in internal/lexicon,
|
||||
// which indexes weekdays from Sunday the way time.Weekday does and months from
|
||||
// one. This file used to carry its own copies, making four copies of the twelve
|
||||
// months in the tree after cmd/mavend/ruwords.go gave up its own (Vikunja #525).
|
||||
|
||||
// Block renders the context block for one turn. Russian even in front of the
|
||||
// English prompts: the rules it states are Russian grammar (ты/тебя, feminine
|
||||
@@ -61,7 +61,7 @@ func (f Facts) Block(now time.Time) string {
|
||||
}
|
||||
|
||||
b.WriteString(fmt.Sprintf("Сейчас: %s, %d %s %d, %02d:%02d (местное время).\n",
|
||||
ruWeekdays[int(now.Weekday())], now.Day(), ruMonths[int(now.Month())-1], now.Year(),
|
||||
lexicon.Weekday(int(now.Weekday())), now.Day(), lexicon.MonthGenitive(int(now.Month())), now.Year(),
|
||||
now.Hour(), now.Minute()))
|
||||
|
||||
b.WriteString("Умеешь: " + strings.Join(f.can(), "; ") +
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
"What she says after storing something he said, and what she says when storing it failed. Edit the wording here, no Go changes needed.",
|
||||
"Rules: she is feminine about herself, he is a man addressed as ты. Never вы/вас/ваш, never он/его about him. No pet names.",
|
||||
"He hears these many times a day, so most entries carry variants: identical wording is what makes a confirmation stop registering as one.",
|
||||
"Placeholders: {key} {value} the fact he stated, {fn} the action, {text} the task title. His data is interpolated Go-side — the file holds the frame, never his words.",
|
||||
"Placeholders: {key} {value} the fact he stated, {fn} the action, {text} the task title or, in ack_fact_echo, his own sentence. His data is interpolated Go-side — the file holds the frame, never his words.",
|
||||
"ack_fact_echo is the one entry with a single variant, deliberately: what varies in it is his own sentence, which is different every time, and the frame around it is what the simulator scenarios read back.",
|
||||
"An acknowledgement confirms and stops. It does not ask a follow-up question and it does not editorialise about what he stored."
|
||||
],
|
||||
"entries": {
|
||||
@@ -18,6 +19,10 @@
|
||||
"ack_fact_kv": {
|
||||
"variants": ["отметила: {key} = {value}", "записала: {key} — {value}", "запомнила: {key} — {value}"]
|
||||
},
|
||||
"ack_fact_echo": {
|
||||
"fixed": true,
|
||||
"variants": ["записала: {text}"]
|
||||
},
|
||||
"ack_note": {
|
||||
"variants": ["сохранила заметку.", "заметка сохранена.", "записала в заметки."]
|
||||
},
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
_ "embed"
|
||||
"log"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/kami/maven/internal/say"
|
||||
@@ -33,6 +34,7 @@ const (
|
||||
AckFact = "ack_fact"
|
||||
AckFactKey = "ack_fact_key"
|
||||
AckFactValue = "ack_fact_kv"
|
||||
AckFactEcho = "ack_fact_echo"
|
||||
AckNote = "ack_note"
|
||||
AckReminder = "ack_reminder"
|
||||
AckAct = "ack_act"
|
||||
@@ -58,7 +60,7 @@ const (
|
||||
|
||||
// ackKeys — every key the code requires the file to define.
|
||||
var ackKeys = []string{
|
||||
AckFact, AckFactKey, AckFactValue, AckNote, AckReminder, AckAct,
|
||||
AckFact, AckFactKey, AckFactValue, AckFactEcho, AckNote, AckReminder, AckAct,
|
||||
AckTask, AckTaskUrgent, AckTaskDuplicate, AckNudge, AckSnooze, AckGeneric,
|
||||
AckQuietOn, AckQuietOff,
|
||||
FailFact, FailFactUnparsed, FailNote, FailReminder, FailReminderTime,
|
||||
@@ -71,6 +73,7 @@ var ackFloor = map[string]string{
|
||||
AckFact: "записала факт.",
|
||||
AckFactKey: "отметила: {key}",
|
||||
AckFactValue: "отметила: {key} = {value}",
|
||||
AckFactEcho: "записала: {text}",
|
||||
AckNote: "сохранила заметку.",
|
||||
AckReminder: "напомню.",
|
||||
AckAct: "ок, записала действие: {fn}",
|
||||
@@ -109,6 +112,7 @@ func LoadAcks(src rand.Source) (*Acks, error) {
|
||||
// captured, which reads as a successful save of nothing.
|
||||
for _, req := range []struct{ key, ph string }{
|
||||
{AckFactKey, "{key}"}, {AckFactValue, "{key}"}, {AckFactValue, "{value}"},
|
||||
{AckFactEcho, "{text}"},
|
||||
{AckAct, "{fn}"}, {AckTask, "{text}"}, {AckTaskUrgent, "{text}"},
|
||||
} {
|
||||
if err := d.RequirePlaceholder(req.key, req.ph); err != nil {
|
||||
@@ -157,6 +161,26 @@ func DefaultAcks() *Acks {
|
||||
// Ack — one acknowledgement line, the way every caller says it.
|
||||
func Ack(key string, vars map[string]string) string { return DefaultAcks().Say(key, vars) }
|
||||
|
||||
// FactAck — the confirmation for a captured fact, in the words he used (V-592).
|
||||
//
|
||||
// It is a deck line with his sentence dropped into it, and there is no
|
||||
// generation anywhere on this path. Asking a 1.7B to say his sentence back
|
||||
// produced "Проверила, что ты выпел стакан воды" for "я выпил воды": a non-word
|
||||
// for the verb, a glass he never mentioned — lifted straight out of the example
|
||||
// in ReplySystemPrompt — and a claim to have checked something. The fact store
|
||||
// held key=water value="drank" throughout, so nothing was mis-captured and
|
||||
// everything after the capture was invented.
|
||||
//
|
||||
// An empty utterance falls back to the contentless line rather than confirming
|
||||
// a capture of nothing.
|
||||
func FactAck(utterance string) string {
|
||||
utterance = strings.TrimSpace(utterance)
|
||||
if utterance == "" {
|
||||
return Ack(AckFact, nil)
|
||||
}
|
||||
return Ack(AckFactEcho, map[string]string{"text": utterance})
|
||||
}
|
||||
|
||||
// IsAck reports whether text is a line key could have produced. For the daemon
|
||||
// tests, which can no longer compare against one literal.
|
||||
func IsAck(key string, vars map[string]string, text string) bool {
|
||||
|
||||
@@ -30,8 +30,13 @@ const replyTimeout = 60 * time.Second
|
||||
|
||||
// ReplySystemPrompt — the reactive confirmation contract: one short Russian
|
||||
// sentence, feminine self-reference, informal address, no question.
|
||||
//
|
||||
// The example is deliberately contentless. It used to be "Записала, что ты
|
||||
// выпил стакан воды.", and the model copied the glass into a real reply about
|
||||
// water he never described that way (V-592). An example carrying a plausible
|
||||
// completion of the input is an invitation to reuse it.
|
||||
const ReplySystemPrompt = `Ты — Maven, домашняя ассистентка (о себе — в женском роде). Владелец — мужчина, говоришь с ним на "ты", в единственном числе; никогда не "вы"/"ваш" и не "он"/"его". Подтверди действие РОВНО ОДНИМ коротким предложением (≤120 символов), по-русски, спокойно и без официальных формулировок. Не задавай вопросов, не повторяй слова, не добавляй ничего после точки. Отвечай ТОЛЬКО одним объектом JSON с полями "response" (текст) и "mood" (ровно одно из: neutral, happy, thinking, tired, confused).
|
||||
Пример: {"response": "Записала, что ты выпил стакан воды.", "mood": "neutral"}
|
||||
Пример: {"response": "Хорошо, напомню.", "mood": "neutral"}
|
||||
Никогда не пиши "..." в поле response.`
|
||||
|
||||
// Replier phrases reactive confirmations with the resident model. It has no
|
||||
|
||||
@@ -35,16 +35,14 @@ var dayPlanWords = []string{
|
||||
// answer today and stamp it with today's date, which is a wrong answer where
|
||||
// falling through is only a terse one.
|
||||
//
|
||||
// The weekday names are here as a refusal, not as a feature. "какие планы на
|
||||
// понедельник?" carries no other-day token in the сегодня family and does carry
|
||||
// "планы", so the plan used to claim it and recite today.
|
||||
// A weekday is a refusal too, and it is not in this list: IsDayPlanQuery asks
|
||||
// WeekdayIndex, so every case of every name refuses rather than the nine forms
|
||||
// that used to be written out here (V-581). "какие планы на понедельник?"
|
||||
// carries no other-day token in the сегодня family and does carry "планы", so
|
||||
// the plan used to claim it and recite today.
|
||||
var otherDayWords = []string{
|
||||
"завтра", "послезавтра", "вчера", "позавчера",
|
||||
"tomorrow", "yesterday",
|
||||
"понедельник", "вторник", "среду", "среда", "четверг", "пятницу", "пятница",
|
||||
"субботу", "суббота", "воскресенье",
|
||||
"понедельника", "вторника", "четверга", "пятницы", "субботы", "воскресенья",
|
||||
"monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday",
|
||||
"неделю", "неделя", "недели", "неделе",
|
||||
"выходные", "выходных", "выходным",
|
||||
"месяц", "месяца", "месяце",
|
||||
@@ -69,6 +67,9 @@ func IsDayPlanQuery(text string) bool {
|
||||
}
|
||||
toks := planTokens(text)
|
||||
for _, t := range toks {
|
||||
if _, ok := WeekdayIndex(t); ok {
|
||||
return false
|
||||
}
|
||||
for _, w := range otherDayWords {
|
||||
if t == w {
|
||||
return false
|
||||
|
||||
@@ -45,10 +45,10 @@ try:
|
||||
now = datetime.fromisoformat(sys.argv[2])
|
||||
# Pre-process: replace Russian time qualifiers with AM/PM.
|
||||
# Handles "9 утра", "10 часов утра", "3 часа дня" etc.
|
||||
text = re.sub(r'(\d+)\s+(?:час(?:а|ов)?\s+)?утра\b', r'\1 am', text, flags=re.IGNORECASE)
|
||||
text = re.sub(r'(\d+)\s+(?:час(?:а|ов)?\s+)?вечера\b', r'\1 pm', text, flags=re.IGNORECASE)
|
||||
text = re.sub(r'(\d+)\s+(?:час(?:а|ов)?\s+)?дня\b', r'\1 pm', text, flags=re.IGNORECASE)
|
||||
text = re.sub(r'(\d+)\s+(?:час(?:а|ов)?\s+)?ночи\b', r'\1 am', text, flags=re.IGNORECASE)
|
||||
text = re.sub(r'(\d+)\s+(?:час(?:а|ов|у|ам)?\s+)?утра\b', r'\1 am', text, flags=re.IGNORECASE)
|
||||
text = re.sub(r'(\d+)\s+(?:час(?:а|ов|у|ам)?\s+)?вечера\b', r'\1 pm', text, flags=re.IGNORECASE)
|
||||
text = re.sub(r'(\d+)\s+(?:час(?:а|ов|у|ам)?\s+)?дня\b', r'\1 pm', text, flags=re.IGNORECASE)
|
||||
text = re.sub(r'(\d+)\s+(?:час(?:а|ов|у|ам)?\s+)?ночи\b', r'\1 am', text, flags=re.IGNORECASE)
|
||||
# A bare hour after a preposition is dropped on the floor by dateparser:
|
||||
# "завтра в 7" resolves to tomorrow at the CURRENT clock, and "завтра в 7
|
||||
# часов" is read as seven hours from now. Only a qualifier (already an
|
||||
@@ -56,7 +56,9 @@ try:
|
||||
# English "at 7" fails identically, so both prepositions are rewritten.
|
||||
# "на 9" is the same hour said with the other preposition, and it was not
|
||||
# read at all until V-579: "в 9" set the reminder and "на 9" did not.
|
||||
text = re.sub(r'(?<![\w:])(в|во|на|at)\s+([01]?\d|2[0-3])(?:\s+час(?:а|ов)?)?(?![\d:.\w])',
|
||||
# "к двум часам" is a third preposition and the dative that goes with it,
|
||||
# and it was read as no time at all until V-609.
|
||||
text = re.sub(r'(?<![\w:])(в|во|на|к|ко|at|by)\s+([01]?\d|2[0-3])(?:\s+час(?:а|ов|у|ам)?)?(?![\d:.\w])',
|
||||
lambda m: '%s %02d:00' % (m.group(1), int(m.group(2))), text, flags=re.IGNORECASE)
|
||||
settings = {'PREFER_DATES_FROM': 'future', 'RELATIVE_BASE': now}
|
||||
# Two-step: search_dates finds the date substring in text,
|
||||
|
||||
@@ -27,26 +27,10 @@ var habitMarkers = []string{
|
||||
"typically", "normally",
|
||||
}
|
||||
|
||||
// weekdayWords — every form of a weekday name maven needs to recognise,
|
||||
// including the "по …ам" plural the question is usually phrased in.
|
||||
var weekdayWords = map[string]time.Weekday{
|
||||
"понедельник": time.Monday, "понедельникам": time.Monday,
|
||||
"вторник": time.Tuesday, "вторникам": time.Tuesday,
|
||||
"среда": time.Wednesday, "среду": time.Wednesday, "средам": time.Wednesday,
|
||||
"четверг": time.Thursday, "четвергам": time.Thursday,
|
||||
"пятница": time.Friday, "пятницу": time.Friday, "пятницам": time.Friday,
|
||||
"суббота": time.Saturday, "субботу": time.Saturday, "субботам": time.Saturday,
|
||||
"воскресенье": time.Sunday, "воскресеньям": time.Sunday,
|
||||
"воскресенья": time.Sunday, "воскресенью": time.Sunday,
|
||||
"воскресеньем": time.Sunday, "воскресеньях": time.Sunday,
|
||||
"monday": time.Monday, "mondays": time.Monday,
|
||||
"tuesday": time.Tuesday, "tuesdays": time.Tuesday,
|
||||
"wednesday": time.Wednesday, "wednesdays": time.Wednesday,
|
||||
"thursday": time.Thursday, "thursdays": time.Thursday,
|
||||
"friday": time.Friday, "fridays": time.Friday,
|
||||
"saturday": time.Saturday, "saturdays": time.Saturday,
|
||||
"sunday": time.Sunday, "sundays": time.Sunday,
|
||||
}
|
||||
// The weekday a habit question names comes from WeekdayIndex, not from a map
|
||||
// here. This file used to keep its own declension table, which had "воскресеньях"
|
||||
// and no "средах" — a list of forms is finished by whoever last thought of one,
|
||||
// and a dictionary is not (V-581).
|
||||
|
||||
// weekendWords — the weekend as one unit. "что я обычно делаю по выходным?"
|
||||
// has a habit marker and names days, but no weekday name is in it, so it used
|
||||
@@ -77,7 +61,7 @@ func ParseHabitQuery(text string) (HabitQuery, bool) {
|
||||
return HabitQuery{}, false
|
||||
}
|
||||
for _, t := range toks {
|
||||
if wd, ok := weekdayWords[t]; ok {
|
||||
if wd, ok := WeekdayIndex(t); ok {
|
||||
return HabitQuery{Weekday: wd, HasWeekday: true}, true
|
||||
}
|
||||
if weekendWords[t] {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestDativePluralHourIsAnHour — "напомни к двум часам позвонить маме" reached
|
||||
// the daemon with no time at all and she asked the open "Когда?", while "к трём"
|
||||
// one word over read fine (V-609). The word that lost it was "часам", the dative
|
||||
// plural of "час", which four separate hour sets in this package left out.
|
||||
func TestDativePluralHourIsAnHour(t *testing.T) {
|
||||
const s = "напомни к двум часам позвонить маме"
|
||||
if !MentionsTime(s) {
|
||||
t.Errorf("MentionsTime(%q) = false; the sentence names two o'clock", s)
|
||||
}
|
||||
if !NamesAnHour(s) {
|
||||
t.Errorf("NamesAnHour(%q) = false; the sentence names two o'clock", s)
|
||||
}
|
||||
if got, want := SpellOutDigits(s), "напомни к 2 часам позвонить маме"; got != want {
|
||||
t.Errorf("SpellOutDigits(%q) = %q, want %q", s, got, want)
|
||||
}
|
||||
// The slot itself, which is what the daemon reads. It was empty, so
|
||||
// whenGapOf named the hour missing and she asked "Когда?".
|
||||
now := time.Date(2026, 8, 6, 3, 39, 0, 0, time.UTC)
|
||||
ex := Extractor{Time: StubDateTimeParser{}}
|
||||
got := ex.Extract(context.Background(), IntentReminder, s, now)
|
||||
if !got.HasTime {
|
||||
t.Fatalf("the hour was spoken, so the slot must be filled: %+v", got)
|
||||
}
|
||||
if h := got.Time.Hour(); h != 2 && h != 14 {
|
||||
t.Errorf("fire time = %s, want two o'clock in one half of the day or the other", got.Time.Format("15:04"))
|
||||
}
|
||||
}
|
||||
|
||||
// TestHourUnitReachesEverySite — the four sets that read the hour noun now read
|
||||
// one lexicon key, so a form added there is a form all four know. "часам" is the
|
||||
// form that was missing from every one of them.
|
||||
func TestHourUnitReachesEverySite(t *testing.T) {
|
||||
for _, w := range []string{"час", "часа", "часов", "часу", "часам"} {
|
||||
if !numeralContext[w] {
|
||||
t.Errorf("numeralContext is missing %q", w)
|
||||
}
|
||||
if !hourMarkers[w] {
|
||||
t.Errorf("hourMarkers is missing %q", w)
|
||||
}
|
||||
if !timeMarkers[w] {
|
||||
t.Errorf("timeMarkers is missing %q", w)
|
||||
}
|
||||
if _, ok := unitToDuration(2, w); !ok {
|
||||
t.Errorf("unitToDuration does not know %q", w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinuteUnitHasTheSameForms — the same defect one noun over: "минутам" was
|
||||
// missing everywhere "минут" and "минуты" were present.
|
||||
func TestMinuteUnitHasTheSameForms(t *testing.T) {
|
||||
for _, w := range []string{"минут", "минуты", "минуту", "минутам"} {
|
||||
if !numeralContext[w] {
|
||||
t.Errorf("numeralContext is missing %q", w)
|
||||
}
|
||||
if !hourMarkers[w] {
|
||||
t.Errorf("hourMarkers is missing %q", w)
|
||||
}
|
||||
if !timeMarkers[w] {
|
||||
t.Errorf("timeMarkers is missing %q", w)
|
||||
}
|
||||
if _, ok := unitToDuration(20, w); !ok {
|
||||
t.Errorf("unitToDuration does not know %q", w)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,12 +34,21 @@ func numeralDigit(word string) (string, bool) {
|
||||
// numeralContext — the words that make a numeral a time. A numeral is only
|
||||
// rewritten when one of these sits next to it, so "три яблока" in a note is
|
||||
// left alone and "в три часа" is not.
|
||||
var numeralContext = map[string]bool{
|
||||
"в": true, "во": true, "к": true, "около": true, "на": true,
|
||||
"часа": true, "часов": true, "час": true, "часу": true,
|
||||
"утра": true, "вечера": true, "дня": true, "ночи": true,
|
||||
"минут": true, "минуты": true, "минуту": true,
|
||||
"at": true, "by": true,
|
||||
var numeralContext = buildNumeralContext()
|
||||
|
||||
func buildNumeralContext() map[string]bool {
|
||||
m := map[string]bool{
|
||||
"в": true, "во": true, "к": true, "около": true, "на": true,
|
||||
"утра": true, "вечера": true, "дня": true, "ночи": true,
|
||||
"at": true, "by": true,
|
||||
}
|
||||
for _, w := range lexicon.HourUnits() {
|
||||
m[w] = true
|
||||
}
|
||||
for _, w := range lexicon.MinuteUnits() {
|
||||
m[w] = true
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// SpellOutDigits rewrites spoken numbers as digits so the date parsers see the
|
||||
|
||||
@@ -175,10 +175,14 @@ func afterWord(s, w string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// hourPrepositions — the words a spoken hour sits behind. Three, and no more:
|
||||
// hourPrepositions — the words a spoken hour sits behind. Five, and no more:
|
||||
// the lexicon's frame set is much wider, and a word goes in here only when the
|
||||
// number after it is an hour of the day rather than a count of anything.
|
||||
var hourPrepositions = map[string]bool{"в": true, "во": true, "на": true}
|
||||
//
|
||||
// "к" and "ко" joined the three on V-609. "напомни к двум часам" named an hour
|
||||
// and parsed to nothing, so the reminder reached the daemon with no time and she
|
||||
// asked the open question about an hour he had just said.
|
||||
var hourPrepositions = map[string]bool{"в": true, "во": true, "на": true, "к": true, "ко": true}
|
||||
|
||||
// StubDateTimeParser — a tiny relative/absolute parser standing in for
|
||||
// `dateparser` until the i18n module lands. Handles "in Nh"/"in Nm"/"in Ns" and
|
||||
@@ -398,10 +402,18 @@ func leadingWordNumber(s string) (int, string, bool) {
|
||||
}
|
||||
|
||||
func unitToDuration(n int, unit string) (time.Duration, bool) {
|
||||
switch unit {
|
||||
case "h", "hour", "hours", "hr", "hrs":
|
||||
// The hour and the minute nouns are closed classes with one home in the
|
||||
// lexicon, and the list here used to be short of the oblique forms (V-609).
|
||||
if lexicon.IsHourUnit(unit) {
|
||||
return time.Duration(n) * time.Hour, true
|
||||
case "m", "min", "mins", "minute", "minutes":
|
||||
}
|
||||
if lexicon.IsMinuteUnit(unit) {
|
||||
return time.Duration(n) * time.Minute, true
|
||||
}
|
||||
switch unit {
|
||||
case "h", "hr", "hrs":
|
||||
return time.Duration(n) * time.Hour, true
|
||||
case "m", "min", "mins":
|
||||
return time.Duration(n) * time.Minute, true
|
||||
case "s", "sec", "secs", "second", "seconds":
|
||||
return time.Duration(n) * time.Second, true
|
||||
@@ -409,10 +421,6 @@ func unitToDuration(n int, unit string) (time.Duration, bool) {
|
||||
case "day", "days":
|
||||
return time.Duration(n) * 24 * time.Hour, true
|
||||
// Russian units (inflected forms)
|
||||
case "час", "часа", "часов":
|
||||
return time.Duration(n) * time.Hour, true
|
||||
case "минута", "минуты", "минут":
|
||||
return time.Duration(n) * time.Minute, true
|
||||
case "день", "дня", "дней":
|
||||
return time.Duration(n) * 24 * time.Hour, true
|
||||
case "неделя", "недели", "недель":
|
||||
|
||||
@@ -3,6 +3,7 @@ package router
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/lexicon"
|
||||
"github.com/kami/maven/internal/morph"
|
||||
@@ -205,12 +206,16 @@ var hourMarkers = buildHourMarkers()
|
||||
func buildHourMarkers() map[string]bool {
|
||||
m := map[string]bool{
|
||||
"утра": true, "вечера": true, "дня": true, "ночи": true,
|
||||
"часа": true, "часов": true, "час": true, "часу": true,
|
||||
"минут": true, "минуты": true, "минуту": true,
|
||||
"через": true, "спустя": true, "полчаса": true,
|
||||
"полдень": true, "полночь": true,
|
||||
"am": true, "pm": true, "noon": true, "midnight": true, "in": true,
|
||||
}
|
||||
for _, w := range lexicon.HourUnits() {
|
||||
m[w] = true
|
||||
}
|
||||
for _, w := range lexicon.MinuteUnits() {
|
||||
m[w] = true
|
||||
}
|
||||
for _, w := range lexicon.PartsOfDay() {
|
||||
m[w] = true
|
||||
}
|
||||
@@ -234,16 +239,34 @@ func isMonth(tok string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// isWeekday reports whether the token is a day of the week in any case. The
|
||||
// lexicon lists the nominative, and "в пятницу" is what a reminder says, so the
|
||||
// match is by lemma — grammar is morph's job, not a second word list.
|
||||
func isWeekday(tok string) bool {
|
||||
for i := 0; i < 7; i++ {
|
||||
if morph.SameWord(tok, lexicon.Weekday(i)) {
|
||||
return true
|
||||
// WeekdayIndex reports which day of the week a token names, in any case and in
|
||||
// either language, or false when it names none.
|
||||
//
|
||||
// One matcher for the whole daemon (V-581). Four files used to keep a weekday
|
||||
// list of their own and each one was short in a different direction: the habit
|
||||
// map had "воскресеньях" but no "средах", the plan refusal had "среду" but not
|
||||
// "среде", and cmd/mavend matched the STEM "сред" with strings.Contains, so
|
||||
// "среди" and "средство" read as Wednesday. The lexicon lists the nominative,
|
||||
// every Russian case lemmatises to it, and only English needs its forms written
|
||||
// out — the vendored dictionary is Russian and leaves "mondays" alone.
|
||||
func WeekdayIndex(tok string) (time.Weekday, bool) {
|
||||
t := strings.ToLower(strings.TrimSpace(tok))
|
||||
if n, ok := lexicon.WeekdayEnglish(t); ok {
|
||||
return time.Weekday(n), true
|
||||
}
|
||||
for i, name := range lexicon.Weekdays() {
|
||||
if morph.SameWord(t, name) {
|
||||
return time.Weekday(i), true
|
||||
}
|
||||
}
|
||||
return false
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// isWeekday reports whether the token is a day of the week, when the caller
|
||||
// does not need to know which one.
|
||||
func isWeekday(tok string) bool {
|
||||
_, ok := WeekdayIndex(tok)
|
||||
return ok
|
||||
}
|
||||
|
||||
// timeMarkers — the words that name a time on their own: the qualifiers that
|
||||
@@ -255,11 +278,15 @@ var timeMarkers = buildTimeMarkers()
|
||||
func buildTimeMarkers() map[string]bool {
|
||||
m := map[string]bool{
|
||||
"утра": true, "вечера": true, "дня": true, "ночи": true,
|
||||
"часа": true, "часов": true, "час": true, "часу": true,
|
||||
"минут": true, "минуты": true, "минуту": true,
|
||||
"через": true, "полчаса": true, "сейчас": true,
|
||||
"am": true, "pm": true, "noon": true, "midnight": true,
|
||||
}
|
||||
for _, w := range lexicon.HourUnits() {
|
||||
m[w] = true
|
||||
}
|
||||
for _, w := range lexicon.MinuteUnits() {
|
||||
m[w] = true
|
||||
}
|
||||
for _, w := range lexicon.PartsOfDay() {
|
||||
m[w] = true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestWeekdayIndexReplacesFourLists — four files kept a weekday list of their
|
||||
// own and each was short in a different direction (V-581). The forms below are
|
||||
// the ones at least one of those lists missed, so they are the point of having
|
||||
// one matcher: the lexicon names the day and the dictionary answers the case.
|
||||
func TestWeekdayIndexReplacesFourLists(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
word string
|
||||
want time.Weekday
|
||||
}{
|
||||
{"понедельник", time.Monday},
|
||||
{"понедельникам", time.Monday},
|
||||
{"понедельником", time.Monday},
|
||||
{"вторник", time.Tuesday},
|
||||
{"среда", time.Wednesday},
|
||||
{"среду", time.Wednesday},
|
||||
{"среде", time.Wednesday},
|
||||
{"средам", time.Wednesday},
|
||||
{"четверга", time.Thursday},
|
||||
{"пятницу", time.Friday},
|
||||
{"субботам", time.Saturday},
|
||||
{"воскресеньях", time.Sunday},
|
||||
{"Воскресенье", time.Sunday},
|
||||
{"monday", time.Monday},
|
||||
{"Fridays", time.Friday},
|
||||
} {
|
||||
got, ok := WeekdayIndex(tc.word)
|
||||
if !ok || got != tc.want {
|
||||
t.Errorf("WeekdayIndex(%q) = %v, %v; want %v, true", tc.word, got, ok, tc.want)
|
||||
}
|
||||
}
|
||||
// A stem match said yes to all of these. A word match says no.
|
||||
for _, w := range []string{"среди", "средство", "средний", "среднем", "субботник", "", "через"} {
|
||||
if _, ok := WeekdayIndex(w); ok {
|
||||
t.Errorf("WeekdayIndex(%q) claimed a weekday", w)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,13 +38,22 @@ type Routine struct {
|
||||
}
|
||||
|
||||
// Validate reports the first structural problem with a routine set: a missing
|
||||
// name/cron/body or an unparseable cron expression. Called at config load so a
|
||||
// typo surfaces at startup, not as a silently-never-firing routine at runtime.
|
||||
// name/cron/body, a duplicate name or an unparseable cron expression. Called at
|
||||
// config load so a typo surfaces at startup, not as a silently-never-firing
|
||||
// routine at runtime.
|
||||
//
|
||||
// Names must be unique because Due keys its last-fired map by name. Two
|
||||
// routines sharing one would take turns being suppressed by each other's fire.
|
||||
func Validate(routines []Routine) error {
|
||||
seen := make(map[string]bool, len(routines))
|
||||
for _, r := range routines {
|
||||
if r.Name == "" {
|
||||
return fmt.Errorf("routine: name is required")
|
||||
}
|
||||
if seen[r.Name] {
|
||||
return fmt.Errorf("routine %q: duplicate name", r.Name)
|
||||
}
|
||||
seen[r.Name] = true
|
||||
if r.Body == "" {
|
||||
return fmt.Errorf("routine %q: body is required", r.Name)
|
||||
}
|
||||
|
||||
@@ -60,6 +60,12 @@ func TestValidate(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Due keys its last-fired map by name, so two routines sharing one would
|
||||
// take turns being suppressed by the other's fire.
|
||||
if err := Validate(append(ok, ok[0])); err == nil {
|
||||
t.Error("expected an error for a duplicate name, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDue(t *testing.T) {
|
||||
|
||||
@@ -173,8 +173,9 @@ var (
|
||||
)
|
||||
|
||||
// PlainText strips markup and decodes entities — feed summaries are HTML, and
|
||||
// what reaches a note (and possibly the TTS) must be text. Exported because the
|
||||
// crawler's extractor needs exactly this on a bigger input.
|
||||
// what reaches a note (and possibly the TTS) must be text. Exported so a caller
|
||||
// holding raw feed markup can reduce it the same way; crawl/extract.go does the
|
||||
// bigger job on a whole document and does not go through here.
|
||||
func PlainText(s string) string {
|
||||
s = scriptRE.ReplaceAllString(s, " ")
|
||||
s = tagRE.ReplaceAllString(s, " ")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package rss
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -164,7 +165,9 @@ func (p *Poller) PollFeed(ctx context.Context, f FeedConfig, now time.Time) (int
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
feed, err := Parse(strings.NewReader(string(body.Bytes)))
|
||||
// bytes.NewReader and not strings.NewReader(string(…)): the latter copied a
|
||||
// feed document that can run to a megabyte, for nothing.
|
||||
feed, err := Parse(bytes.NewReader(body.Bytes))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
@@ -141,7 +141,8 @@ func LoadSummaries(src rand.Source) (*Summaries, error) {
|
||||
{PlanUncertain, "{line}"},
|
||||
{TasksFirst, "{items}"}, {TasksCandidates, "{items}"},
|
||||
{StallOverdue, "{n}"}, {StallOverdue, "{word}"},
|
||||
{StallSitting, "{n}"}, {StallSitting, "{days}"},
|
||||
{StallSitting, "{n}"}, {StallSitting, "{word}"},
|
||||
{StallSitting, "{days}"}, {StallSitting, "{dayword}"},
|
||||
{StallUnconfirmed, "{n}"}, {StallUnconfirmed, "{word}"},
|
||||
{ReasonOverdueDays, "{n}"}, {ReasonOverdueDays, "{word}"},
|
||||
{ReasonInDays, "{n}"}, {ReasonInDays, "{word}"},
|
||||
|
||||
@@ -295,14 +295,15 @@ func (c *Client) do(ctx context.Context, method, path string, body []byte) ([]by
|
||||
return nil, fmt.Errorf("smarthome: %s %s: %w", method, path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
// The status only, and read before the body: an error page can carry the
|
||||
// instance's own detail, and there is no reason to pull it into memory to
|
||||
// discard it.
|
||||
return nil, fmt.Errorf("smarthome: %s %s: http %d", method, path, resp.StatusCode)
|
||||
}
|
||||
out, err := io.ReadAll(io.LimitReader(resp.Body, maxBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("smarthome: read %s: %w", path, err)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
// The body of an error can contain the instance's own detail; the token
|
||||
// never appears in it, but keep it to one line anyway.
|
||||
return nil, fmt.Errorf("smarthome: %s %s: http %d", method, path, resp.StatusCode)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -75,8 +75,8 @@ func (r *Recognizer) Identify(ctx context.Context, a audio.Audio) (Match, error)
|
||||
if !a.Format.IsValid() {
|
||||
return Match{}, fmt.Errorf("%w: %+v", ErrBadFormat, a.Format)
|
||||
}
|
||||
if seconds(a) < r.minSec {
|
||||
return Match{}, fmt.Errorf("%w: %.1fs, need %.1fs", ErrTooShort, seconds(a), r.minSec)
|
||||
if sec := seconds(a); sec < r.minSec {
|
||||
return Match{}, fmt.Errorf("%w: %.1fs, need %.1fs", ErrTooShort, sec, r.minSec)
|
||||
}
|
||||
vec, err := r.embed(ctx, a)
|
||||
if err != nil {
|
||||
|
||||
@@ -55,17 +55,15 @@ func (s *Store) LastSent(ctx context.Context, key string) (time.Time, error) {
|
||||
// alarm (voice acknowledgment, Telegram callback, etc.).
|
||||
func (s *Store) MarkAcked(ctx context.Context, key string) error {
|
||||
now := time.Now()
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
// No pending nudges is not an error — already acked or never sent — so the
|
||||
// rows-affected count is not read at all: every outcome below this line is
|
||||
// the same nil.
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE nudges SET outcome = 'acted', outcome_ts = ?
|
||||
WHERE rule = ? AND channel = 'telegram' AND outcome = 'pending'`,
|
||||
now.UnixMilli(), key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mark acked %s: %w", key, err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
// no pending nudges — already acked or never sent; not an error.
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
@@ -103,30 +102,16 @@ func (s *Store) ReembedAll(ctx context.Context, currentID string, embed EmbedFun
|
||||
id, text, kind string
|
||||
}
|
||||
var vecs []vecRow
|
||||
rows, err = tx.QueryContext(ctx, `SELECT id, meta FROM memory_vectors`)
|
||||
memRows, err := allMemVectorMetas(ctx, tx)
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("reembed: read memory vectors: %w", err)
|
||||
return res, fmt.Errorf("reembed: %w", err)
|
||||
}
|
||||
for rows.Next() {
|
||||
var id, metaJSON string
|
||||
if err := rows.Scan(&id, &metaJSON); err != nil {
|
||||
rows.Close()
|
||||
return res, fmt.Errorf("reembed: memory row: %w", err)
|
||||
}
|
||||
meta := map[string]string{}
|
||||
if err := json.Unmarshal([]byte(metaJSON), &meta); err != nil {
|
||||
rows.Close()
|
||||
return res, fmt.Errorf("reembed: meta for %q: %w", id, err)
|
||||
}
|
||||
if meta["text"] == "" {
|
||||
for _, v := range memRows {
|
||||
if v.Meta["text"] == "" {
|
||||
res.NoText++
|
||||
continue
|
||||
}
|
||||
vecs = append(vecs, vecRow{id: id, text: meta["text"], kind: meta["type"]})
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return res, fmt.Errorf("reembed: memory vectors: %w", err)
|
||||
vecs = append(vecs, vecRow{id: v.ID, text: v.Meta["text"], kind: v.Meta["type"]})
|
||||
}
|
||||
|
||||
for _, v := range vecs {
|
||||
|
||||
@@ -64,9 +64,9 @@ func (s *Store) RepairFactVectors(ctx context.Context, embed EmbedFunc) (FactVec
|
||||
return res, nil
|
||||
}
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id, meta FROM memory_vectors`)
|
||||
memRows, err := allMemVectorMetas(ctx, s.db)
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("repair fact vectors: read: %w", err)
|
||||
return res, fmt.Errorf("repair fact vectors: %w", err)
|
||||
}
|
||||
type factVec struct {
|
||||
id, key string
|
||||
@@ -75,33 +75,19 @@ func (s *Store) RepairFactVectors(ctx context.Context, embed EmbedFunc) (FactVec
|
||||
}
|
||||
var vecs []factVec
|
||||
newest := map[string]int64{} // key → newest ts seen for it
|
||||
for rows.Next() {
|
||||
var id, metaJSON string
|
||||
if err := rows.Scan(&id, &metaJSON); err != nil {
|
||||
rows.Close()
|
||||
return res, fmt.Errorf("repair fact vectors: row: %w", err)
|
||||
}
|
||||
meta := map[string]string{}
|
||||
if err := json.Unmarshal([]byte(metaJSON), &meta); err != nil {
|
||||
rows.Close()
|
||||
return res, fmt.Errorf("repair fact vectors: meta for %q: %w", id, err)
|
||||
}
|
||||
if meta["type"] != "fact" {
|
||||
for _, v := range memRows {
|
||||
if v.Meta["type"] != "fact" {
|
||||
continue
|
||||
}
|
||||
key, ts, ok := splitFactVectorID(id)
|
||||
key, ts, ok := splitFactVectorID(v.ID)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
vecs = append(vecs, factVec{id: id, key: key, meta: meta, ts: ts})
|
||||
vecs = append(vecs, factVec{id: v.ID, key: key, meta: v.Meta, ts: ts})
|
||||
if ts > newest[key] {
|
||||
newest[key] = ts
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return res, fmt.Errorf("repair fact vectors: rows: %w", err)
|
||||
}
|
||||
|
||||
for _, v := range vecs {
|
||||
drop := v.ts < newest[v.key]
|
||||
|
||||
@@ -169,6 +169,49 @@ func (m *MemoryStore) DeletePrefix(ctx context.Context, prefix string) (int64, e
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// memVectorRow is one memory_vectors row with its meta blob decoded — the
|
||||
// shape both ReembedAll (backfill.go) and RepairFactVectors (factvectors.go)
|
||||
// read the whole table as, before each decides what to do with a row on its
|
||||
// own terms (one keys off meta["text"], the other off meta["type"] and the
|
||||
// id's embedded key/timestamp). Query-then-scan was duplicated across the two
|
||||
// before this, id-for-id.
|
||||
type memVectorRow struct {
|
||||
ID string
|
||||
Meta map[string]string
|
||||
}
|
||||
|
||||
// queryContexter is the common surface *sql.DB and *sql.Tx share that
|
||||
// allMemVectorMetas needs. ReembedAll reads inside a transaction so its
|
||||
// migration is atomic; RepairFactVectors reads directly off the db handle.
|
||||
type queryContexter interface {
|
||||
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
|
||||
}
|
||||
|
||||
// allMemVectorMetas reads every memory_vectors row and decodes its meta blob.
|
||||
func allMemVectorMetas(ctx context.Context, q queryContexter) ([]memVectorRow, error) {
|
||||
rows, err := q.QueryContext(ctx, `SELECT id, meta FROM memory_vectors`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read memory vectors: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []memVectorRow
|
||||
for rows.Next() {
|
||||
var id, metaJSON string
|
||||
if err := rows.Scan(&id, &metaJSON); err != nil {
|
||||
return nil, fmt.Errorf("memory vector row: %w", err)
|
||||
}
|
||||
meta := map[string]string{}
|
||||
if err := json.Unmarshal([]byte(metaJSON), &meta); err != nil {
|
||||
return nil, fmt.Errorf("meta for %q: %w", id, err)
|
||||
}
|
||||
out = append(out, memVectorRow{ID: id, Meta: meta})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("memory vectors: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// escapeLike neutralises the LIKE wildcards in a literal prefix.
|
||||
func escapeLike(s string) string {
|
||||
r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
|
||||
|
||||
@@ -187,7 +187,10 @@ func (s *Store) AcceptProposedRoutine(ctx context.Context, id int64, ts time.Tim
|
||||
if err != nil {
|
||||
return fmt.Errorf("accept proposed routine: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("accept proposed routine: rows affected: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
return fmt.Errorf("%w: id=%d not in 'proposed' status", ErrProposedRoutineNotFound, id)
|
||||
}
|
||||
|
||||
+16
-16
@@ -208,14 +208,7 @@ const SpokenLimit = 5
|
||||
// DayPlan.Spoken is built core-side: two formatters drift, and then she says
|
||||
// one order and shows another.
|
||||
func FormatRU(ranked []Ranked) string {
|
||||
var open, cands []Ranked
|
||||
for _, r := range ranked {
|
||||
if r.Status == StatusCandidate {
|
||||
cands = append(cands, r)
|
||||
} else {
|
||||
open = append(open, r)
|
||||
}
|
||||
}
|
||||
open, cands := split(ranked)
|
||||
if len(open) == 0 && len(cands) == 0 {
|
||||
return say.S(say.TasksNone, nil)
|
||||
}
|
||||
@@ -243,6 +236,20 @@ func FormatRU(ranked []Ranked) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// split separates confirmed work from candidates, preserving Rank's order
|
||||
// within each half. FormatRU and Spoken have to agree on where the line falls,
|
||||
// so they read it from one place.
|
||||
func split(ranked []Ranked) (open, cands []Ranked) {
|
||||
for _, r := range ranked {
|
||||
if r.Status == StatusCandidate {
|
||||
cands = append(cands, r)
|
||||
} else {
|
||||
open = append(open, r)
|
||||
}
|
||||
}
|
||||
return open, cands
|
||||
}
|
||||
|
||||
// joinRU lists up to limit tasks, then says how many are left. withReasons
|
||||
// attaches the parenthesised reason — candidates are listed bare, since their
|
||||
// due dates are Maven's reading of a mail and not something he stated.
|
||||
@@ -273,14 +280,7 @@ func joinRU(rs []Ranked, limit int, withReasons bool) string {
|
||||
// an ordinal resolves against is built here and not by a caller guessing how
|
||||
// the renderer split and truncated it.
|
||||
func Spoken(ranked []Ranked) []Ranked {
|
||||
var open, cands []Ranked
|
||||
for _, r := range ranked {
|
||||
if r.Status == StatusCandidate {
|
||||
cands = append(cands, r)
|
||||
} else {
|
||||
open = append(open, r)
|
||||
}
|
||||
}
|
||||
open, cands := split(ranked)
|
||||
out := make([]Ranked, 0, 2*SpokenLimit)
|
||||
for _, group := range [][]Ranked{open, cands} {
|
||||
if len(group) > SpokenLimit {
|
||||
|
||||
@@ -56,10 +56,16 @@ func Stalls(items []Item, now time.Time) []Stall {
|
||||
// meant to act on.
|
||||
continue
|
||||
}
|
||||
if it.Due != nil && it.Due.Before(now) {
|
||||
// Whole calendar days, the reading Rank already uses. An instant
|
||||
// comparison calls a task due at 18:00 overdue from 18:01, so the count
|
||||
// above the table would say "просрочено" beside a row whose own reason
|
||||
// still said "сегодня".
|
||||
if it.Due != nil && dayDelta(*it.Due, now) < 0 {
|
||||
overdue++
|
||||
}
|
||||
if now.Sub(it.Created) >= StallDays*24*time.Hour {
|
||||
// A row with no capture time has not sat for anything. Without the
|
||||
// guard its zero time is January of year 1 and it always counts.
|
||||
if !it.Created.IsZero() && now.Sub(it.Created) >= StallDays*24*time.Hour {
|
||||
sitting++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,27 @@ func TestStallsSaysNothingWhenThereIsNothing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStallsCountsOverdueByCalendarDay(t *testing.T) {
|
||||
// Same reading as Rank, or the count above the table contradicts the reason
|
||||
// in the row: due at 09:00, asked at 12:00, and it is still due today.
|
||||
now := stallNow()
|
||||
earlier := now.Add(-3 * time.Hour)
|
||||
items := []Item{{ID: 1, Text: "оплатить интернет", Status: StatusOpen, Created: now.Add(-time.Hour), Due: &earlier}}
|
||||
if got := Stalls(items, now); len(got) != 0 {
|
||||
t.Fatalf("shapes = %+v, want none — a task due today is not overdue", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStallsIgnoresATaskWithNoCaptureTime(t *testing.T) {
|
||||
// The zero time is January of year 1, so an unstamped row would count as
|
||||
// sitting forever.
|
||||
now := stallNow()
|
||||
items := []Item{{ID: 1, Text: "купить молоко", Status: StatusOpen}}
|
||||
if got := Stalls(items, now); len(got) != 0 {
|
||||
t.Fatalf("shapes = %+v, want none", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStallsCountsNoJudgement(t *testing.T) {
|
||||
// The line this shape may not cross. Every sentence states a count; none of
|
||||
// them says whether the work matters or should be dropped.
|
||||
|
||||
@@ -62,9 +62,10 @@ func capSegment(s string) string {
|
||||
// the tool name when the argv carries no second word.
|
||||
func CapabilityOf(t ipc.Tool) Capability {
|
||||
if entityID, service, ok := smarthome.ParseCmd(t.Cmd); ok {
|
||||
domain := entityID
|
||||
if i := strings.Index(entityID, "."); i > 0 {
|
||||
domain = entityID[:i]
|
||||
// One parse of an entity id, in the package that owns the format.
|
||||
domain := smarthome.DomainOf(entityID)
|
||||
if domain == "" {
|
||||
domain = entityID
|
||||
}
|
||||
return Capability{Scope: "house", Domain: domain, Action: service}
|
||||
}
|
||||
@@ -122,7 +123,8 @@ func GroupByDomain(tools []ipc.Tool) []CapabilityGroup {
|
||||
byKey := map[string][]ipc.Tool{}
|
||||
for _, t := range tools {
|
||||
c := CapabilityOf(t)
|
||||
byKey[capSegment(c.Scope)+"."+capSegment(c.Domain)] = append(byKey[capSegment(c.Scope)+"."+capSegment(c.Domain)], t)
|
||||
key := capSegment(c.Scope) + "." + capSegment(c.Domain)
|
||||
byKey[key] = append(byKey[key], t)
|
||||
}
|
||||
out := make([]CapabilityGroup, 0, len(byKey))
|
||||
for k, v := range byKey {
|
||||
|
||||
@@ -189,10 +189,16 @@ func (e *Executor) Exec(ctx context.Context, name string, args []string, confirm
|
||||
defer cancel()
|
||||
return e.home.CallService(ctx, entityID, service)
|
||||
}
|
||||
argv := append(append([]string(nil), t.Cmd...), args...)
|
||||
if len(argv) == 0 {
|
||||
// The row's own argv is what names the program. An enabled row with an empty
|
||||
// cmd used to fall through to exec with argv built from args alone, so the
|
||||
// spoken tail became argv[0] and STT text picked the binary. A proposal is
|
||||
// drafted with no cmd, and /tools can enable one before anybody fills it in,
|
||||
// so this was reachable without any compromise. A row that names nothing runs
|
||||
// nothing.
|
||||
if len(t.Cmd) == 0 {
|
||||
return "", ErrNotEnabled
|
||||
}
|
||||
argv := append(append([]string(nil), t.Cmd...), args...)
|
||||
ctx, cancel := context.WithTimeout(ctx, e.timeout)
|
||||
defer cancel()
|
||||
return e.run(ctx, argv)
|
||||
|
||||
@@ -299,3 +299,24 @@ func TestExecSmartHomeRowConfirmsEvenWhenNotMarkedDestructive(t *testing.T) {
|
||||
t.Fatalf("calls = %d, want 1 after the confirm turn", fh.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecEmptyCmdRefuses pins the fix for a row that names no program. Such a
|
||||
// row used to build argv from the spoken args alone, so STT text became argv[0]
|
||||
// and free text picked the binary. A proposal is drafted with no cmd and can be
|
||||
// enabled before anybody fills it in, so this needed no compromise to reach.
|
||||
func TestExecEmptyCmdRefuses(t *testing.T) {
|
||||
api := fakeAPI{tools: map[string]ipc.Tool{
|
||||
"blank": {Name: "blank", Scope: "homelab", Status: "enabled"},
|
||||
}}
|
||||
ran := false
|
||||
e := NewExecutor(api, time.Second)
|
||||
e.run = func(_ context.Context, _ []string) (string, error) { ran = true; return "ok", nil }
|
||||
|
||||
// Confirmed, because an empty cmd derives to TierDestructive.
|
||||
if _, err := e.Exec(context.Background(), "blank", []string{"curl", "evil.sh"}, true); !errors.Is(err, ErrNotEnabled) {
|
||||
t.Fatalf("err = %v, want ErrNotEnabled", err)
|
||||
}
|
||||
if ran {
|
||||
t.Fatal("a row with no cmd ran a program named by the utterance")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,9 @@ func TestLexiconRewritesNames(t *testing.T) {
|
||||
// Two names in a row share the space between them, which one pass
|
||||
// would consume.
|
||||
{"GPU GPU", "джи-пи-ю джи-пи-ю"},
|
||||
// Two passes cover a run of any length, because the first pass takes
|
||||
// every other name and leaves both boundaries of the ones it skipped.
|
||||
{"GPU GPU GPU GPU", "джи-пи-ю джи-пи-ю джи-пи-ю джи-пи-ю"},
|
||||
// Not a word boundary: a name inside a longer token is left alone.
|
||||
{"vikunjaless", "vikunjaless"},
|
||||
{"ничего не совпало", "ничего не совпало"},
|
||||
|
||||
+9
-5
@@ -20,6 +20,7 @@ package tts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
@@ -50,17 +51,20 @@ func NewStub() *Stub { return &Stub{} }
|
||||
// silent no-op a bug could hide behind).
|
||||
func (s *Stub) Synthesize(_ context.Context, text string) (audio.Audio, error) {
|
||||
const durMs = 200
|
||||
const samples = 16000 * durMs / 1000 // 3200 samples @ 16k
|
||||
pcm := make([]byte, samples*2)
|
||||
// The rate is read off the canonical format rather than written again, so
|
||||
// the tone stays in tune with the shape the seam declares.
|
||||
rate := audio.PCM16kMono.SampleRate
|
||||
bytesPerSample := audio.PCM16kMono.SampleBits / 8
|
||||
samples := rate * durMs / 1000
|
||||
pcm := make([]byte, samples*bytesPerSample)
|
||||
freq := 220.0 // A3
|
||||
if len(text) > 0 {
|
||||
freq = 180.0 + float64(text[0]%6)*60 // 180..480 Hz band
|
||||
}
|
||||
for i := 0; i < samples; i++ {
|
||||
t := float64(i) / 16000.0
|
||||
t := float64(i) / float64(rate)
|
||||
v := int16(12000 * math.Sin(2*math.Pi*freq*t))
|
||||
pcm[i*2] = byte(v)
|
||||
pcm[i*2+1] = byte(v >> 8)
|
||||
binary.LittleEndian.PutUint16(pcm[i*2:], uint16(v))
|
||||
}
|
||||
return audio.Audio{Format: audio.PCM16kMono, Bytes: pcm}, nil
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/kami/maven/internal/lexicon"
|
||||
"github.com/kami/maven/internal/say"
|
||||
)
|
||||
|
||||
// The month names are a closed class and live in internal/lexicon, 1-indexed,
|
||||
@@ -32,7 +33,7 @@ func Speakable(s string) string {
|
||||
})
|
||||
s = reTime.ReplaceAllStringFunc(s, func(m string) string {
|
||||
p := reTime.FindStringSubmatch(m)
|
||||
return p[1] + " часов " + p[2] + " минут"
|
||||
return spokenTime(mustInt(p[1]), mustInt(p[2]))
|
||||
})
|
||||
s = reDots.ReplaceAllStringFunc(s, func(m string) string {
|
||||
return strings.Join(strings.Split(m, "."), " точка ")
|
||||
@@ -46,6 +47,20 @@ func Speakable(s string) string {
|
||||
return Pronounce(s)
|
||||
}
|
||||
|
||||
// spokenTime reads a clock time the way it is said rather than the way it is
|
||||
// written. Two things the written form gets wrong out loud. The noun after a
|
||||
// numeral inflects, so 21:00 is "час" and 22:00 is "часа", where the old
|
||||
// rewrite said "часов" for every hour and "минут" for every minute. And a
|
||||
// leading zero is punctuation, not a word: 14:00 is "14 часов" and 9:05 is
|
||||
// "9 часов 5 минут", never "00 минут" or "05 минут".
|
||||
func spokenTime(h, m int) string {
|
||||
out := strconv.Itoa(h) + " " + say.CountWord(h, "час", "часа", "часов")
|
||||
if m == 0 {
|
||||
return out
|
||||
}
|
||||
return out + " " + strconv.Itoa(m) + " " + say.CountWord(m, "минута", "минуты", "минут")
|
||||
}
|
||||
|
||||
func spokenDate(dd, mm, yyyy string) string {
|
||||
mi, _ := strconv.Atoi(mm)
|
||||
if mi < 1 || mi > 12 {
|
||||
|
||||
@@ -6,8 +6,11 @@ func TestSpeakable(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"напомню 10.07.2026", "напомню 10 июля 2026"},
|
||||
{"срок 01.01", "срок 1 января"},
|
||||
{"встреча в 14:00", "встреча в 14 часов 00 минут"},
|
||||
{"в 9:05 подъём", "в 9 часов 05 минут подъём"},
|
||||
{"встреча в 14:00", "встреча в 14 часов"},
|
||||
{"в 9:05 подъём", "в 9 часов 5 минут подъём"},
|
||||
{"в 21:00 отбой", "в 21 час отбой"},
|
||||
{"в 22:02 отбой", "в 22 часа 2 минуты отбой"},
|
||||
{"в 1:01 проснулся", "в 1 час 1 минута проснулся"},
|
||||
{"это 3.2.1 версия", "это 3 точка 2 точка 1 версия"},
|
||||
{"без чисел", "без чисел"},
|
||||
}
|
||||
|
||||
@@ -67,13 +67,10 @@ func (s *StubReplier) Reply(d router.Decision) string {
|
||||
case router.IntentReminder:
|
||||
return phraser.Ack(phraser.AckReminder, nil)
|
||||
case router.IntentFact:
|
||||
if d.Slots.HasKey {
|
||||
if d.Slots.Value != "" {
|
||||
return phraser.Ack(phraser.AckFactValue, map[string]string{"key": d.Slots.Key, "value": d.Slots.Value})
|
||||
}
|
||||
return phraser.Ack(phraser.AckFactKey, map[string]string{"key": d.Slots.Key})
|
||||
}
|
||||
return phraser.Ack(phraser.AckFact, nil)
|
||||
// His words, not the key the parser filed them under (V-592). The key
|
||||
// is machine vocabulary — "water", "meal" — and reading it back was
|
||||
// never a confirmation he could check.
|
||||
return phraser.FactAck(d.Utterance)
|
||||
case router.IntentNote:
|
||||
return phraser.Ack(phraser.AckNote, nil)
|
||||
case router.IntentQuery:
|
||||
|
||||
@@ -13,13 +13,19 @@ import (
|
||||
"github.com/kami/maven/internal/morph"
|
||||
)
|
||||
|
||||
// clientTimeout — the whole request, geocode or forecast. Both are one call to
|
||||
// a public API over the internet rather than a LAN service, hence longer than
|
||||
// a bare "it's slow" budget; there is no retry behind it, so a slow reply
|
||||
// still costs the caller the whole wait.
|
||||
const clientTimeout = 10 * time.Second
|
||||
|
||||
type OpenMeteoProvider struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewOpenMeteoProvider() *OpenMeteoProvider {
|
||||
return &OpenMeteoProvider{
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
httpClient: &http.Client{Timeout: clientTimeout},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +83,9 @@ func (p *OpenMeteoProvider) CurrentWeather(ctx context.Context, location string)
|
||||
return Weather{}, fmt.Errorf("open-meteo: http: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return Weather{}, fmt.Errorf("open-meteo forecast: http %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
@@ -186,6 +195,9 @@ func (p *OpenMeteoProvider) geocodeOne(ctx context.Context, location string) (la
|
||||
return 0, 0, "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return 0, 0, "", fmt.Errorf("open-meteo geocode: http %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
|
||||
@@ -3,9 +3,11 @@ package weather
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -76,6 +78,87 @@ func (t *mockTransport) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||
return http.DefaultTransport.RoundTrip(req)
|
||||
}
|
||||
|
||||
// TestOpenMeteoProvider_ForecastHTTPError — a non-200 forecast reply must not
|
||||
// decode into a zero-value Weather{Temperature: 0, Condition: "неизвестно"}
|
||||
// reported as success (Vikunja #589). It must be a distinct error, not
|
||||
// ErrLocationUnknown, so the caller does not blame the owner's city.
|
||||
func TestOpenMeteoProvider_ForecastHTTPError(t *testing.T) {
|
||||
mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/v1/search" {
|
||||
json.NewEncoder(w).Encode(geoResponse{
|
||||
Results: []geoResult{{Name: "Moscow", Latitude: 55.7558, Longitude: 37.6173, Country: "Russia"}},
|
||||
})
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer mock.Close()
|
||||
|
||||
p := NewOpenMeteoProvider()
|
||||
p.SetClient(&http.Client{Transport: &mockTransport{
|
||||
geoURL: mock.URL + "/v1/search",
|
||||
forecastURL: mock.URL + "/v1/forecast",
|
||||
}})
|
||||
|
||||
w, err := p.CurrentWeather(context.Background(), "Moscow")
|
||||
if err == nil {
|
||||
t.Fatalf("CurrentWeather: want error, got Weather{%+v}", w)
|
||||
}
|
||||
if errors.Is(err, ErrLocationUnknown) {
|
||||
t.Fatalf("CurrentWeather: want a service error, got ErrLocationUnknown: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "500") {
|
||||
t.Errorf("CurrentWeather: error %q does not name the status", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenMeteoProvider_GeocodeHTTPError — a non-200 geocode reply must not be
|
||||
// read as "no such city" (ErrLocationUnknown). A 500 is a service outage, and
|
||||
// the owner hears a different sentence for each (Vikunja #589).
|
||||
func TestOpenMeteoProvider_GeocodeHTTPError(t *testing.T) {
|
||||
mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer mock.Close()
|
||||
|
||||
p := NewOpenMeteoProvider()
|
||||
p.SetClient(&http.Client{Transport: &mockTransport{
|
||||
geoURL: mock.URL + "/v1/search",
|
||||
forecastURL: mock.URL + "/v1/forecast",
|
||||
}})
|
||||
|
||||
_, err := p.CurrentWeather(context.Background(), "Уфе")
|
||||
if err == nil {
|
||||
t.Fatal("CurrentWeather: want error")
|
||||
}
|
||||
if errors.Is(err, ErrLocationUnknown) {
|
||||
t.Fatalf("CurrentWeather: want a service error, got ErrLocationUnknown: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "500") {
|
||||
t.Errorf("CurrentWeather: error %q does not name the status", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenMeteoProvider_GeocodeEmptyResult — a genuine 200 reply with no
|
||||
// results is still ErrLocationUnknown, distinct from a service failure.
|
||||
func TestOpenMeteoProvider_GeocodeEmptyResult(t *testing.T) {
|
||||
mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(geoResponse{Results: nil})
|
||||
}))
|
||||
defer mock.Close()
|
||||
|
||||
p := NewOpenMeteoProvider()
|
||||
p.SetClient(&http.Client{Transport: &mockTransport{
|
||||
geoURL: mock.URL + "/v1/search",
|
||||
forecastURL: mock.URL + "/v1/forecast",
|
||||
}})
|
||||
|
||||
_, err := p.CurrentWeather(context.Background(), "Атлантида")
|
||||
if !errors.Is(err, ErrLocationUnknown) {
|
||||
t.Fatalf("CurrentWeather: want ErrLocationUnknown, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStubProvider(t *testing.T) {
|
||||
p := NewStubProvider()
|
||||
_, err := p.CurrentWeather(context.Background(), "Moscow")
|
||||
|
||||
@@ -14,11 +14,13 @@ import (
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -56,8 +58,16 @@ type credentialAssertion struct {
|
||||
|
||||
// RP — the relying party instance. Holds config and transient challenge state.
|
||||
// A single-user daemon has one RP.
|
||||
//
|
||||
// mavweb serves the four passkey endpoints from its HTTP handlers, so the two
|
||||
// challenge maps are reached concurrently even on a single-user box: a browser
|
||||
// retrying an assertion while another tab begins one is enough. A concurrent
|
||||
// map write is a fatal runtime error, not a recovered panic, so it would take
|
||||
// the whole daemon down from an endpoint that answers before any credential is
|
||||
// proven. Every read and write of regs and asserts is under mu.
|
||||
type RP struct {
|
||||
cfg Config
|
||||
mu sync.Mutex
|
||||
regs map[string]*credentialRegistration
|
||||
asserts map[string]*credentialAssertion
|
||||
challengeTTL time.Duration
|
||||
@@ -75,6 +85,13 @@ func NewRP(cfg Config) *RP {
|
||||
|
||||
// CleanExpired removes challenges older than the TTL.
|
||||
func (rp *RP) CleanExpired() {
|
||||
rp.mu.Lock()
|
||||
defer rp.mu.Unlock()
|
||||
rp.cleanExpired()
|
||||
}
|
||||
|
||||
// cleanExpired is CleanExpired for a caller that already holds mu.
|
||||
func (rp *RP) cleanExpired() {
|
||||
now := time.Now()
|
||||
for k, r := range rp.regs {
|
||||
if now.Sub(r.CreatedAt) > rp.challengeTTL {
|
||||
@@ -97,12 +114,14 @@ func (rp *RP) CreationOptions(userID []byte, userName string) (map[string]any, s
|
||||
}
|
||||
challengeB64 := base64.RawURLEncoding.EncodeToString(challenge)
|
||||
|
||||
rp.CleanExpired()
|
||||
rp.mu.Lock()
|
||||
rp.cleanExpired()
|
||||
rp.regs[challengeB64] = &credentialRegistration{
|
||||
Challenge: challengeB64,
|
||||
UserID: userID,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
rp.mu.Unlock()
|
||||
|
||||
return map[string]any{
|
||||
"rp": map[string]string{
|
||||
@@ -136,12 +155,10 @@ func (rp *RP) CreationOptions(userID []byte, userName string) (map[string]any, s
|
||||
|
||||
// FinishRegistration parses the browser's response and stores the credential.
|
||||
func (rp *RP) FinishRegistration(save CredentialSaver, challengeB64 string, resp map[string]any) (string, error) {
|
||||
rp.CleanExpired()
|
||||
reg, ok := rp.regs[challengeB64]
|
||||
reg, ok := rp.takeReg(challengeB64)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("webauthn: unknown or expired challenge")
|
||||
}
|
||||
delete(rp.regs, challengeB64)
|
||||
|
||||
credID := rawString(resp, "id")
|
||||
if credID == "" {
|
||||
@@ -188,11 +205,13 @@ func (rp *RP) AssertionOptions() (map[string]any, string, error) {
|
||||
}
|
||||
challengeB64 := base64.RawURLEncoding.EncodeToString(challenge)
|
||||
|
||||
rp.CleanExpired()
|
||||
rp.mu.Lock()
|
||||
rp.cleanExpired()
|
||||
rp.asserts[challengeB64] = &credentialAssertion{
|
||||
Challenge: challengeB64,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
rp.mu.Unlock()
|
||||
|
||||
return map[string]any{
|
||||
"challenge": challengeB64,
|
||||
@@ -215,11 +234,9 @@ func (rp *RP) AssertionOptions() (map[string]any, string, error) {
|
||||
// FinishAssertion verifies the browser's assertion response and returns the
|
||||
// verified credential ID.
|
||||
func (rp *RP) FinishAssertion(lookup CredentialLookup, updateSignCount SignCountUpdater, challengeB64 string, resp map[string]any) (string, error) {
|
||||
rp.CleanExpired()
|
||||
if _, ok := rp.asserts[challengeB64]; !ok {
|
||||
if !rp.takeAssert(challengeB64) {
|
||||
return "", fmt.Errorf("webauthn: unknown or expired challenge")
|
||||
}
|
||||
delete(rp.asserts, challengeB64)
|
||||
|
||||
credID := rawString(resp, "id")
|
||||
if credID == "" {
|
||||
@@ -298,6 +315,34 @@ func (rp *RP) FinishAssertion(lookup CredentialLookup, updateSignCount SignCount
|
||||
return credID, nil
|
||||
}
|
||||
|
||||
// takeReg removes and returns the in-flight registration for challengeB64.
|
||||
// Taking under one lock is what makes a challenge single-use: looking it up
|
||||
// and deleting it separately lets two replays of the same response both find
|
||||
// it before either deletes.
|
||||
func (rp *RP) takeReg(challengeB64 string) (*credentialRegistration, bool) {
|
||||
rp.mu.Lock()
|
||||
defer rp.mu.Unlock()
|
||||
rp.cleanExpired()
|
||||
reg, ok := rp.regs[challengeB64]
|
||||
if ok {
|
||||
delete(rp.regs, challengeB64)
|
||||
}
|
||||
return reg, ok
|
||||
}
|
||||
|
||||
// takeAssert removes the in-flight assertion for challengeB64 and reports
|
||||
// whether it was there. Single-use for the same reason takeReg is.
|
||||
func (rp *RP) takeAssert(challengeB64 string) bool {
|
||||
rp.mu.Lock()
|
||||
defer rp.mu.Unlock()
|
||||
rp.cleanExpired()
|
||||
if _, ok := rp.asserts[challengeB64]; !ok {
|
||||
return false
|
||||
}
|
||||
delete(rp.asserts, challengeB64)
|
||||
return true
|
||||
}
|
||||
|
||||
func verifyClientDataBytes(clientDataJSON []byte, expectedType, expectedChallenge, expectedOrigin string) error {
|
||||
var cdj struct {
|
||||
Type string `json:"type"`
|
||||
@@ -310,7 +355,10 @@ func verifyClientDataBytes(clientDataJSON []byte, expectedType, expectedChalleng
|
||||
if cdj.Type != expectedType {
|
||||
return fmt.Errorf("webauthn: unexpected type %q", cdj.Type)
|
||||
}
|
||||
if cdj.Challenge != expectedChallenge {
|
||||
// Constant time, because the challenge is the one secret in clientDataJSON:
|
||||
// it is 32 bytes of crypto/rand the browser has to echo back, and a
|
||||
// byte-at-a-time compare is the shape that leaks a guessed prefix.
|
||||
if subtle.ConstantTimeCompare([]byte(cdj.Challenge), []byte(expectedChallenge)) != 1 {
|
||||
return fmt.Errorf("webauthn: challenge mismatch")
|
||||
}
|
||||
if cdj.Origin != expectedOrigin {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -179,6 +180,53 @@ func TestRegisterAssertRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The passkey endpoints are HTTP handlers, so two browsers beginning a
|
||||
// challenge at once reach the same RP. Under -race this fails on the bare maps
|
||||
// it used to keep, and in production a concurrent map write is fatal.
|
||||
func TestChallengeMapsAreConcurrencySafe(t *testing.T) {
|
||||
rp := NewRP(Config{Origin: testOrigin, RPID: testRPID, RPName: "maven"})
|
||||
lookup := func(string) ([]byte, int64, error) { return nil, 0, nil }
|
||||
upd := func(string, int64) error { return nil }
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 16; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < 32; j++ {
|
||||
_, chal, err := rp.AssertionOptions()
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
_, _ = rp.FinishAssertion(lookup, upd, chal, map[string]any{})
|
||||
if _, _, err := rp.CreationOptions([]byte("u"), "user"); err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
rp.CleanExpired()
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// A challenge is single-use: the second presentation of one already spent is
|
||||
// unknown, whichever goroutine gets there first.
|
||||
func TestAssertionChallengeIsSingleUse(t *testing.T) {
|
||||
rp := NewRP(Config{Origin: testOrigin, RPID: testRPID, RPName: "maven"})
|
||||
_, chal, err := rp.AssertionOptions()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !rp.takeAssert(chal) {
|
||||
t.Fatal("first take of a fresh challenge failed")
|
||||
}
|
||||
if rp.takeAssert(chal) {
|
||||
t.Fatal("a spent challenge was accepted twice")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssertRejectsWrongOrigin — a phished assertion from another origin fails.
|
||||
func TestAssertRejectsWrongOrigin(t *testing.T) {
|
||||
err := verifyClientDataBytes(clientData("webauthn.get", "abc", "https://evil.test"), "webauthn.get", "abc", testOrigin)
|
||||
|
||||
@@ -61,6 +61,14 @@ var (
|
||||
ErrStatus = errors.New("webfetch: non-2xx status")
|
||||
)
|
||||
|
||||
// StatusError is a non-2xx reply, carrying the code. It unwraps to ErrStatus,
|
||||
// so errors.Is keeps working, and it exists so a caller can tell a 404 from a
|
||||
// 503 with errors.As instead of grepping the message for digits.
|
||||
type StatusError struct{ Code int }
|
||||
|
||||
func (e *StatusError) Error() string { return fmt.Sprintf("webfetch: non-2xx status: %d", e.Code) }
|
||||
func (e *StatusError) Unwrap() error { return ErrStatus }
|
||||
|
||||
// Config are the limits. Every zero value means "the default above", so
|
||||
// Config{} is safe; the only field that changes behaviour by being empty is
|
||||
// AllowHosts (empty ⇒ any public host that is not denied).
|
||||
@@ -232,6 +240,13 @@ func (f *Fetcher) do(ctx context.Context, method, rawURL string, body []byte, hd
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Status first, body second. A server that answered 500 has no body worth
|
||||
// reading, and reading it anyway cost up to MaxBytes off the wire and
|
||||
// reported an oversized error page as ErrTooLarge, which names the wrong
|
||||
// cause. The body is closed either way by the defer above.
|
||||
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||
return nil, &StatusError{Code: resp.StatusCode}
|
||||
}
|
||||
respBody, err := io.ReadAll(io.LimitReader(resp.Body, f.cfg.MaxBytes+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -239,9 +254,6 @@ func (f *Fetcher) do(ctx context.Context, method, rawURL string, body []byte, hd
|
||||
if int64(len(respBody)) > f.cfg.MaxBytes {
|
||||
return nil, fmt.Errorf("%w (%d bytes)", ErrTooLarge, f.cfg.MaxBytes)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||
return nil, fmt.Errorf("%w: %d", ErrStatus, resp.StatusCode)
|
||||
}
|
||||
out := &Response{
|
||||
URL: resp.Request.URL.String(),
|
||||
Status: resp.StatusCode,
|
||||
@@ -307,8 +319,9 @@ func (f *Fetcher) waitTurn(ctx context.Context, host string) error {
|
||||
}
|
||||
|
||||
// HostMatches reports whether host equals one of pats or is a subdomain of one.
|
||||
// Exported because the crawler applies the same rule to links it decides not to
|
||||
// follow, before it ever builds a request.
|
||||
// Exported so anything that wants to apply the same allow/deny rule to a link
|
||||
// before building a request reads it from here rather than reimplementing it.
|
||||
// Nothing outside this package calls it today.
|
||||
func HostMatches(host string, pats []string) bool {
|
||||
host = strings.ToLower(strings.TrimSuffix(host, "."))
|
||||
for _, p := range pats {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package webfetch
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
@@ -212,6 +213,26 @@ func TestNon2xxIsAnError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNon2xxCarriesTheCodeAndBeatsTheSizeCap(t *testing.T) {
|
||||
// A big error page used to be read in full and reported as ErrTooLarge,
|
||||
// which names the size and hides the 503. The status is checked first now,
|
||||
// and the code survives for a caller that has to tell 5xx from 404.
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
w.Write(bytes.Repeat([]byte("x"), 5000))
|
||||
}))
|
||||
defer srv.Close()
|
||||
f := testFetcher(t, Config{MaxBytes: 100})
|
||||
_, err := f.Get(context.Background(), srv.URL)
|
||||
if !errors.Is(err, ErrStatus) || errors.Is(err, ErrTooLarge) {
|
||||
t.Fatalf("error = %v, want ErrStatus and not ErrTooLarge", err)
|
||||
}
|
||||
var se *StatusError
|
||||
if !errors.As(err, &se) || se.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("error = %v, want a StatusError carrying 503", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserAgentIsSent(t *testing.T) {
|
||||
got := make(chan string, 1)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
+18
-14
@@ -37,8 +37,9 @@ type Server struct {
|
||||
addr netaddr.Addr
|
||||
ln net.Listener
|
||||
|
||||
wg sync.WaitGroup
|
||||
done chan struct{}
|
||||
wg sync.WaitGroup
|
||||
done chan struct{}
|
||||
closeOnce sync.Once
|
||||
|
||||
// connCount — assigned per accepted conn, used in logs to distinguish
|
||||
// concurrent connections. Monotonic; not load-bearing for correctness.
|
||||
@@ -180,20 +181,23 @@ func (srv *Server) dispatch(ctx context.Context, req Request) (json.RawMessage,
|
||||
}
|
||||
|
||||
// Close stops accepting and waits for in-flight connections to drain. The
|
||||
// socket file is removed so a restart can rebind cleanly. Idempotent.
|
||||
// socket file is removed so a restart can rebind cleanly.
|
||||
//
|
||||
// Idempotent, and safe from two goroutines at once. The check-then-close it
|
||||
// replaced let both callers see an open channel and the second close panicked,
|
||||
// so a shutdown racing a signal handler took the process down the one way a
|
||||
// clean shutdown is supposed to prevent.
|
||||
func (srv *Server) Close() error {
|
||||
select {
|
||||
case <-srv.done:
|
||||
return nil
|
||||
default:
|
||||
var err error
|
||||
srv.closeOnce.Do(func() {
|
||||
close(srv.done)
|
||||
}
|
||||
if srv.ln == nil {
|
||||
return nil
|
||||
}
|
||||
err := srv.ln.Close()
|
||||
srv.wg.Wait()
|
||||
netaddr.Cleanup(srv.addr)
|
||||
if srv.ln == nil {
|
||||
return
|
||||
}
|
||||
err = srv.ln.Close()
|
||||
srv.wg.Wait()
|
||||
netaddr.Cleanup(srv.addr)
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -229,15 +229,16 @@ func (c *Client) diff(ctx context.Context, serverTimestamp int64) (diffResponse,
|
||||
return diffResponse{}, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
// The status only, and read before the body: the body of a failed diff
|
||||
// can echo account data, this string reaches the log, and there is no
|
||||
// reason to pull megabytes of an error page into memory to discard it.
|
||||
return diffResponse{}, fmt.Errorf("zenmoney diff: %s", res.Status)
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(res.Body, 32<<20))
|
||||
if err != nil {
|
||||
return diffResponse{}, err
|
||||
}
|
||||
if res.StatusCode != http.StatusOK {
|
||||
// The status only. The body of a failed diff can echo account data, and
|
||||
// this string reaches the log.
|
||||
return diffResponse{}, fmt.Errorf("zenmoney diff: %s", res.Status)
|
||||
}
|
||||
var out diffResponse
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return diffResponse{}, fmt.Errorf("zenmoney diff: decode: %w", err)
|
||||
|
||||
Reference in New Issue
Block a user