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:
93
internal/hub/hub.go
Normal file
93
internal/hub/hub.go
Normal file
@@ -0,0 +1,93 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user