c5317eb2b4
Continues the decomposition PR #50 started. voice.go 542 -> 365: quiet_toggle.go 144 resolveQuietToggle, quietInflections, quietStem, quietTokens, quietPhrase, quietOn/OffPhrases, classifyQuietToggle (quiet_toggle_test.go already existed for these) patterns.go +44 detectPattern, next to detectAndPropose which it calls and which patterns.go's own header already pointed at What is left in voice.go is the handler: reactiveHandler, HandlePushToTalk, handleText, runTurn, applyAction, replySystem, chatHistory, reply. Move-only: all 133 distinct non-blank lines removed from voice.go were matched in the two destination files, zero lines added to voice.go. The only non-move edits are import lists (log added to patterns.go, unicode and internal/pattern dropped from voice.go) and two comments that pointed at voice.go for code that is no longer there.
121 lines
4.9 KiB
Go
121 lines
4.9 KiB
Go
// mavend/patterns.go — the shared detect+propose step of pattern inference
|
|
// (Vikunja #43). Event *extraction* (fact -> action/object) happens at fact-
|
|
// write time in detectPattern below, tied to whichever channel wrote the
|
|
// fact. Detection — turning a run of events into a proposed routine — is
|
|
// channel-agnostic: it only needs what's already in the events table, so it
|
|
// runs both right after a voice fact-write (for the immediate "напоминать?"
|
|
// confirmation) and, proactively, from the digestion tick (tick.go's
|
|
// detectPatterns) over every action+object pair on record, not just the one
|
|
// that was just talked about.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/pattern"
|
|
"github.com/kami/maven/internal/store"
|
|
)
|
|
|
|
// detectAndPropose runs the pattern detector over every recorded event for
|
|
// action+object and, if a stable pattern is found and nothing has been
|
|
// proposed/accepted/dismissed for this pair yet, creates a proposed_routines
|
|
// row. Returns (nil, 0, nil) — not an error — whenever there is nothing new
|
|
// to report: too few events, irregular intervals, or a pair that already has
|
|
// a row in any status. That last case is the one that matters most: it is
|
|
// how a routine the owner already DISMISSED stays dismissed forever, because
|
|
// the row survives dismissal (status flips in place, see
|
|
// store.DismissProposedRoutine) and both the Lookup check here and the
|
|
// table's UNIQUE(action, object) constraint refuse to create a second one.
|
|
func detectAndPropose(ctx context.Context, ds *store.Store, action, object string, ts time.Time) (*pattern.ProposedRoutine, int64, error) {
|
|
events, err := ds.EventsFor(ctx, action, object)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("events for %s/%s: %w", action, object, err)
|
|
}
|
|
patEvents := make([]pattern.Event, len(events))
|
|
for i, e := range events {
|
|
patEvents[i] = pattern.Event{
|
|
FactID: e.FactID,
|
|
Action: e.Action,
|
|
Object: e.Object,
|
|
Ts: e.Ts,
|
|
}
|
|
}
|
|
r, err := pattern.Detect(patEvents)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("detect %s/%s: %w", action, object, err)
|
|
}
|
|
if r == nil {
|
|
return nil, 0, nil // not enough data or intervals too irregular
|
|
}
|
|
|
|
// Belt: check first so the common "nothing new" case never even attempts
|
|
// an insert. Suspenders: CreateProposedRoutine's ON CONFLICT DO NOTHING
|
|
// (backed by the UNIQUE(action,object) constraint) is the actual
|
|
// guarantee — this Lookup is an optimization, not the source of truth.
|
|
existing, err := ds.LookupProposedRoutine(ctx, r.Action, r.Object)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("lookup proposed routine %s/%s: %w", action, object, err)
|
|
}
|
|
if existing != nil {
|
|
return nil, 0, nil // already proposed, accepted, or dismissed — say nothing
|
|
}
|
|
|
|
id, err := ds.CreateProposedRoutine(ctx, r.Action, r.Object, r.IntervalDays, ts)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrProposedRoutineExists) {
|
|
return nil, 0, nil // lost a race with another caller — not an error
|
|
}
|
|
return nil, 0, fmt.Errorf("create proposed routine %s/%s: %w", action, object, err)
|
|
}
|
|
return r, id, nil
|
|
}
|
|
|
|
// detectPattern extracts an event from the written fact and runs the pattern
|
|
// detector. If a stable recurring pattern is found and no proposed routine
|
|
// exists for this action+object yet, one is created and the user is prompted
|
|
// to confirm via the park() mechanism. Returns the suggestion phrase when a
|
|
// new proposal was created and parked; "" otherwise.
|
|
func (h *reactiveHandler) detectPattern(ctx context.Context, factID int64, key, value string, ts time.Time) string {
|
|
ev := pattern.Extract(factID, key, value, ts)
|
|
if ev == nil {
|
|
return "" // not an actionable event
|
|
}
|
|
if _, err := h.dataStore.CreateEvent(ctx, factID, ev.Action, ev.Object, ts); err != nil {
|
|
log.Printf("voice: create event: %v", err)
|
|
return ""
|
|
}
|
|
// Detect+propose (Vikunja #43) is shared with the digestion tick's
|
|
// proactive scan — see detectAndPropose above. Event *extraction* stays
|
|
// here, tied to this fact write; detection over the accumulated history does
|
|
// not need to happen right now for the voice path to have already done
|
|
// its job — it's dedupe-safe to also let the next tick find the same
|
|
// pattern independently.
|
|
r, id, err := detectAndPropose(ctx, h.dataStore, ev.Action, ev.Object, ts)
|
|
if err != nil {
|
|
log.Printf("voice: detect pattern %s/%s: %v", ev.Action, ev.Object, err)
|
|
return ""
|
|
}
|
|
if r == nil {
|
|
return "" // not enough data, too irregular, or already proposed/decided
|
|
}
|
|
log.Printf("voice: proposed routine: %s/%s every %.1f days", r.Action, r.Object, r.IntervalDays)
|
|
|
|
// Park the proposal for voice confirmation.
|
|
phrase := pattern.PhraseRoutine(r)
|
|
h.mu.Lock()
|
|
h.pendingRoutine = &pendingRoutineConfirm{
|
|
routineID: id,
|
|
action: r.Action,
|
|
object: r.Object,
|
|
interval: r.IntervalDays,
|
|
phrase: phrase,
|
|
expiry: ts.Add(confirmTTL),
|
|
}
|
|
h.mu.Unlock()
|
|
return phrase
|
|
}
|