package pattern import ( "fmt" "math" "strings" ) // ProposedRoutine is a detected recurring pattern that the system wants to // suggest as a reminder. Returned by Detect when intervals are stable. type ProposedRoutine struct { Action string Object string IntervalDays float64 // mean interval in days (float for sub-day precision) N int // number of events used } // MaxIntervalRatio is the maximum ratio between the longest and shortest // interval for a pattern to be considered stable. ±50% variance allowed. const MaxIntervalRatio = 1.5 // MinEvents is the minimum number of events needed to detect a pattern. // With N events, there are N-1 intervals; we need at least 2 intervals // before proposing anything. const MinEvents = 3 // Detect checks whether a sequence of events for the same action+object // forms a stable recurring pattern. Returns a ProposedRoutine when: // - At least MinEvents events exist (≥2 intervals) // - The ratio longest/shortest interval ≤ MaxIntervalRatio // // Returns nil when there aren't enough events or the intervals are too // irregular — false negatives are harmless. The only dangerous mistake // is a false positive, and this detector makes none: the confirmation // gate (voice park or web page) catches any we do produce. func Detect(events []Event) (*ProposedRoutine, error) { if len(events) < MinEvents { return nil, nil // not enough data } nIntervals := len(events) - 1 intervals := make([]float64, nIntervals) var sum float64 var min float64 = math.MaxFloat64 var max float64 for i := 0; i < nIntervals; i++ { diff := events[i+1].Ts.Sub(events[i].Ts) days := diff.Hours() / 24.0 if days <= 0 { // Two events at the same timestamp — can't compute a meaningful // interval. Skip this candidate silently. return nil, nil } intervals[i] = days sum += days if days < min { min = days } if days > max { max = days } } // Stability check: the most extreme intervals shouldn't differ by // more than MaxIntervalRatio. A ratio of 1.5 means a 7-day pattern // can have intervals between ~5.6 and ~8.4 days. if min > 0 && max/min > MaxIntervalRatio { return nil, nil // too irregular } mean := sum / float64(nIntervals) return &ProposedRoutine{ Action: events[0].Action, Object: events[0].Object, IntervalDays: math.Round(mean*10) / 10, // round to 1 decimal N: len(events), }, nil } // PhraseRoutine generates a human-readable suggestion string for a // detected routine. Returns a Russian phrase like // "ты заправляешь поилку раз в 7 дней — напоминать?" func PhraseRoutine(p *ProposedRoutine) string { actionWord := p.Action objectWord := p.Object days := int(math.Round(p.IntervalDays)) // Russian grammatical gender/hardcoded — matches maven's existing persona. var intervalPhrase string switch { case days < 1: intervalPhrase = "каждый день" case days == 1: intervalPhrase = "каждый день" case days < 7: intervalPhrase = fmt.Sprintf("раз в %d дня", days) if days%10 == 1 && days%100 != 11 { intervalPhrase = fmt.Sprintf("раз в %d день", days) } case days == 7: intervalPhrase = "раз в неделю" case days%7 == 0: intervalPhrase = fmt.Sprintf("раз в %d недели", days/7) if (days/7)%10 == 1 && (days/7)%100 != 11 { intervalPhrase = fmt.Sprintf("раз в %d неделю", days/7) } case days < 30: intervalPhrase = fmt.Sprintf("раз в %d дней", days) default: intervalPhrase = fmt.Sprintf("каждые %d дней", days) } objectDisplay := strings.ReplaceAll(objectWord, "_", " ") return fmt.Sprintf("ты %s %s %s — напоминать?", actionVerb(actionWord), objectDisplay, intervalPhrase) } // actionVerb returns a conjugated Russian verb form for "you do" (ты-form). func actionVerb(action string) string { switch action { case "refill": return "заправляешь" case "feed": return "кормишь" case "change": return "меняешь" case "clean": return "чистишь" case "take": return "принимаешь" case "walk": return "выгуливаешь" case "water": return "поливаешь" default: return action + " (делаешь)" } }