package hub import ( "context" "fmt" "strings" "time" "gitea.lab.audasmedia.com.au/sam/email_agent/internal/store" ) // ImportantCategories are the categories that mean "needs Sam's attention". They // come from AGENTS.md §13.2 (action, important). var ImportantCategories = []string{"Action", "Important"} // answerer decisions. /ask is a small read-only question-answering surface, not // an LLM call: it must answer in under three seconds and never guess. type Answerer struct { Reader Reader // Now is injectable so expiry timestamps are deterministic in tests. Now func() time.Time } // NewAnswerer builds an Answerer over a read-only Reader. func NewAnswerer(r Reader) *Answerer { return &Answerer{Reader: r, Now: func() time.Time { return time.Now().UTC() }} } // now returns the current time, tolerating a nil Now (as in a zero Answerer). func (a *Answerer) now() time.Time { if a.Now == nil { return time.Now().UTC() } return a.Now() } // Answer returns a short, speakable answer for one question. It never writes // state. A question it cannot answer returns answer=null plus a reason. func (a *Answerer) Answer(ctx context.Context, req AskRequest) AskResponse { now := a.now() asker := strings.ToLower(strings.TrimSpace(req.Asker)) person := strings.ToLower(strings.TrimSpace(req.Person)) if person == "" { person = asker } switch { case asker == "": return cannotAnswer("no asker was given") case asker != "sam": return cannotAnswer("I only answer for Sam") case person != "sam": return cannotAnswer("no data for that person") } q := normalizeQuestion(req.Question) if q == "" { return cannotAnswer("no question was given") } // The service does not track read state; say so instead of inventing one. if containsAny(q, "unread", "not read", "mark as read") { return cannotAnswer("I do not track read or unread mail") } counts, err := a.Reader.CountByCategory(ctx, "inbox") if err != nil { return cannotAnswer("the inbox could not be read") } byCategory := map[string]int{} total := 0 for _, c := range counts { byCategory[strings.ToLower(strings.TrimSpace(c.Category))] = c.Count total += c.Count } switch { case containsAny(q, "important", "urgent", "needs attention", "need attention", "needs a decision", "needs a reply", "needs action", "action required", "needs doing", "follow up", "follow-up", "to do", "todo"): return a.answerImportant(ctx, now, byCategory) case containsAny(q, "spam", "junk"): return a.answerSpam(ctx, now, byCategory) case containsAny(q, "newsletter", "unsubscribe", "subscription", "marketing", "digest"): return a.answerNewsletter(now, byCategory) case containsAny(q, "how many", "how much", "count", "number of"): return a.answerCount(now, total, counts) case containsAny(q, "summary", "overview", "what's in", "whats in", "what is in", "anything new", "catch me up", "update me", "anything in my inbox", "anything"): return a.answerSummary(now, total, byCategory) default: return cannotAnswer("I can answer questions about important mail, spam, newsletters, and inbox counts") } } // answerImportant reports how many inbox messages need attention. func (a *Answerer) answerImportant(ctx context.Context, now time.Time, byCategory map[string]int) AskResponse { n := byCategory["action"] + byCategory["important"] resp := AskResponse{Expires: expiresIn(now, 10*time.Minute)} if n == 0 { resp.Answer = answerText("Nothing important is waiting in your inbox.") resp.Data = map[string]any{"count": 0} return resp } items, err := a.Reader.MessagesByCategory(ctx, "inbox", ImportantCategories, 6) if err != nil { return cannotAnswer("the inbox could not be read") } switch { case n == 1 && len(items) > 0: resp.Answer = answerText(fmt.Sprintf("One email needs your attention: %q from %s.", truncate(items[0].Subject, 60), senderName(items[0].FromAddr))) case len(items) > 0: resp.Answer = answerText(fmt.Sprintf("%d emails need your attention, including %q from %s.", n, truncate(items[0].Subject, 60), senderName(items[0].FromAddr))) default: resp.Answer = answerText(fmt.Sprintf("%d emails need your attention.", n)) } resp.Data = map[string]any{ "count": n, "messages": messageData(items), } return resp } // answerSpam reports spam. Spam is always recommended, never auto-deleted // (AGENTS.md §10.5), so the wording stays neutral. func (a *Answerer) answerSpam(ctx context.Context, now time.Time, byCategory map[string]int) AskResponse { n := byCategory["spam"] resp := AskResponse{Expires: expiresIn(now, 10*time.Minute)} if n == 0 { resp.Answer = answerText("There is no spam in your inbox right now.") resp.Data = map[string]any{"count": 0} return resp } items, err := a.Reader.MessagesByCategory(ctx, "inbox", []string{"Spam"}, 6) if err != nil { return cannotAnswer("the inbox could not be read") } resp.Answer = answerText(fmt.Sprintf("%d spam emails are in your inbox.", n)) resp.Data = map[string]any{"count": n, "messages": messageData(items)} return resp } func (a *Answerer) answerNewsletter(now time.Time, byCategory map[string]int) AskResponse { n := byCategory["newsletter"] answer := fmt.Sprintf("Your inbox has %d newsletters.", n) if n == 1 { answer = "Your inbox has 1 newsletter." } return AskResponse{ Answer: answerText(answer), Data: map[string]any{"count": n}, Expires: expiresIn(now, 10*time.Minute), } } func (a *Answerer) answerCount(now time.Time, total int, counts []store.CategoryCount) AskResponse { return AskResponse{ Answer: answerText(fmt.Sprintf("Your inbox has %d emails.", total)), Data: map[string]any{"total": total, "by_category": categoryMap(counts)}, Expires: expiresIn(now, 10*time.Minute), } } func (a *Answerer) answerSummary(now time.Time, total int, byCategory map[string]int) AskResponse { needs := byCategory["action"] + byCategory["important"] news := byCategory["newsletter"] answer := fmt.Sprintf("Your inbox has %d emails: %d need attention and %d are newsletters.", total, needs, news) return AskResponse{ Answer: answerText(answer), Data: map[string]any{ "total": total, "needs_action": needs, "newsletters": news, "spam": byCategory["spam"], }, Expires: expiresIn(now, 10*time.Minute), } } // messageData projects a message list into the compact `data` payload the Hub // may ignore (ask-contract.md). func messageData(items []store.MessageListItem) []map[string]any { out := make([]map[string]any, 0, len(items)) for _, m := range items { out = append(out, map[string]any{ "id": m.ID, "from": m.FromAddr, "subject": m.Subject, "category": m.Category, "importance": m.Importance, "action": m.Action, }) } return out } // categoryMap turns the count rows into a JSON-friendly map keyed by the // category name. The unclassified bucket uses the name "unclassified". func categoryMap(counts []store.CategoryCount) map[string]int { out := make(map[string]int, len(counts)) for _, c := range counts { name := strings.ToLower(strings.TrimSpace(c.Category)) if name == "" { name = "unclassified" } out[name] = c.Count } return out } // normalizeQuestion lowercases and collapses whitespace so intent matching is // stable. func normalizeQuestion(q string) string { return strings.Join(strings.Fields(strings.ToLower(q)), " ") } func containsAny(q string, subs ...string) bool { for _, s := range subs { if strings.Contains(q, s) { return true } } return false } // senderName extracts a display name from a From header, falling back to the // address or the raw value. func senderName(addr string) string { addr = strings.TrimSpace(addr) if i := strings.Index(addr, "<"); i > 0 { name := strings.Trim(strings.TrimSpace(addr[:i]), `"'`) if name != "" { return name } } if i := strings.Index(addr, "<"); i >= 0 { if j := strings.Index(addr[i:], ">"); j > i { return strings.TrimSpace(addr[i+1 : i+j]) } } return addr } // truncate shortens s to at most n runes so a spoken answer stays short. func truncate(s string, n int) string { s = strings.TrimSpace(s) if s == "" { return "(no subject)" } r := []rune(s) if len(r) <= n { return s } return strings.TrimSpace(string(r[:n-1])) + "…" }