Files
email_agent/internal/hub/hub.go
Sam Rolfe a349b42b1f 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>
2026-10-11 22:38:49 +11:00

94 lines
3.4 KiB
Go

// Package hub implements the project side of the Hub contract
// (central_ai_llm_master/docs/ask-contract.md) and the outbound notify call.
//
// The contract is one-directional by design: the Hub asks a project about its
// own data and the project answers. A project never learns the Hub's data model
// and the Hub never learns the project's. The project endpoints are
// GET /capabilities and POST /ask (read only); POST /notify is the
// project-to-Hub direction.
//
// Phase 7 (FR-12). Answers are computed from SQLite; /ask performs no writes.
package hub
import (
"context"
"encoding/json"
"time"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/store"
)
// ProjectName is the value the Hub matches in routing.md.
const ProjectName = "email_agent"
// Capabilities is the GET /capabilities reply. The Hub reads it to route a
// question to this project. Keep the topic list short and plain; it holds no
// private data.
type Capabilities struct {
Project string `json:"project"`
Topics []string `json:"topics"`
People []string `json:"people"`
}
// DefaultCapabilities returns the topics this service can answer. "email",
// "inbox", "mail", and "message" match the routing.md topic map. Read/unread is
// deliberately absent: the service does not track read state.
func DefaultCapabilities() Capabilities {
return Capabilities{
Project: ProjectName,
Topics: []string{"email", "inbox", "mail", "message"},
People: []string{"sam"},
}
}
// AskRequest is the POST /ask body (ask-contract.md).
type AskRequest struct {
Question string `json:"question"`
Asker string `json:"asker"`
Person string `json:"person,omitempty"`
Source string `json:"source,omitempty"`
}
// AskResponse is the POST /ask reply. A nil Answer means the project cannot
// answer and Reason explains why. The shape is frozen by the contract.
type AskResponse struct {
Answer *string `json:"answer"`
Data map[string]any `json:"data,omitempty"`
Expires string `json:"expires,omitempty"`
Reason string `json:"reason,omitempty"`
}
// answerText is a small helper so the pointer handling stays in one place.
func answerText(s string) *string { return &s }
// cannotAnswer builds the contract's "cannot answer" reply.
func cannotAnswer(reason string) AskResponse {
return AskResponse{Answer: nil, Reason: reason}
}
// expiresIn formats an RFC3339 expiry used by data-bearing answers. An inbox
// answer goes stale quickly because mail arrives and is triaged.
func expiresIn(now time.Time, d time.Duration) string {
return now.UTC().Add(d).Format(time.RFC3339)
}
// NotifyRequest is the project-to-Hub POST /notify body (ask-contract.md). The
// Hub may use it to speak or notify.
type NotifyRequest struct {
From string `json:"from"`
Level string `json:"level"`
Message string `json:"message"`
Person string `json:"person,omitempty"`
}
// jsonBody marshals a notify payload, kept small and deterministic for tests.
func jsonBody(v any) ([]byte, error) { return json.Marshal(v) }
// Reader is the read-only data surface the answerer uses. *store.Store
// satisfies it; tests can supply a fake. Keeping it an interface makes the
// read-only guarantee explicit and easy to assert.
type Reader interface {
CountByCategory(ctx context.Context, folder string) ([]store.CategoryCount, error)
MessagesByCategory(ctx context.Context, folder string, categories []string, limit int) ([]store.MessageListItem, error)
}