Phase 7: daily summary and read-only Hub API (AUD-73)
- internal/hub: project side of the frozen ask-contract. GET /capabilities, read-only POST /ask answerer (no LLM, <3s), and the outbound POST /notify client. - internal/apprise: daily-summary delivery through the house Apprise API. - internal/summary: deterministic plain-text daily summary builder. - internal/store: CountByCategory, MessagesByCategory, and summary CRUD. - internal/server: /capabilities and /ask (bearer-token protected), plus the Reports screen (/reports, build, send, delete). - main.go: -summary, -summary-send, and -notify CLI entry points for n8n. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
260
internal/hub/ask.go
Normal file
260
internal/hub/ask.go
Normal file
@@ -0,0 +1,260 @@
|
||||
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])) + "…"
|
||||
}
|
||||
Reference in New Issue
Block a user