Files
Cyrene_v2/internal/services/gateway/notifications.go
T
AskaEth 153a9d6211
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(runtime+admin): A2 LLM异步提取接线 + A4 Tracing接线 + A6通知后端聚合
A2 异步记忆提取:
- loop/memory.go: asyncExtractor接口(ExtractAsync)+extractMemoryAsync(60s超时,WithOutCancel,dispatch即返回,后台落库,失败仅Warn)+persistMemories共用
- run.go 三个回合落定点改异步提取; 删除run.go逐步提取(design/03'每轮后', 避免多轮工具N次LLM调用/重复写)
- cmd/cyrene/extractor.go buildMemoryExtractor: llmGroupUsable为真→LLMExtractor, 否则回落ManualExtractor(日志说明); keylessEndpoint放行loopback/私网

A4 可观测性 Tracing:
- cmd/cyrene/observability.go: newAppTraceWriter+traceFanout(多sink)+gatewaySpanSink(走gw.ObserveSpan,喂环形缓冲+LLM/subagent指标)
- wire.go 注入; 未改trace/gateway数据结构

A6 通知后端:
- internal/services/gateway/notifications.go(311行): GET /admin/notifications?type=&before=&limit= (adminAuth)
- 聚合已有源(obs.audit SQLite + s.backthink), createdAt倒序, id=source:id; 类型派生(channel/system/audit/backthink); 无新建表/无新Deps/wire零改动
- 诚实: backthink仅shouldPush计, 未注入源为空, 报错500, 未读游标后置
- 契约与前端Y一致: {id,type,title,body,source,createdAt}, before含游标(<=), type未知400
- Docs/api-client v0.33

全仓 build/vet/test绿(56包)+win/darwin/linux-arm64交叉编译绿; 新增测试(memory_async/extractor/observability/notifications)
2026-09-30 09:03:16 +08:00

312 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package gateway
import (
"context"
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"time"
"unicode/utf8"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/backthink"
)
// Notification feed limits (design/10 §3 #16). Compile-time constants so no bare
// magic number appears in the run path (project rule §10).
const (
defaultNotificationLimit = 50
maxNotificationLimit = 200
// notificationFetchFactor widens the per-source read. The merged page must be
// the newest N of the union, which the per-source newest-N always contains,
// but with a `before` cursor one source can dominate the newest window and
// starve the other — the factor pays for that.
notificationFetchFactor = 4
// notificationBodyRunes caps a backthink summary in the feed; the full text
// stays available on GET /api/v1/admin/backthink.
notificationBodyRunes = 200
)
// The unified feed's families, used both as the item `type` and as the `?type=`
// filter. They are derived from the sources the composition root already has
// (design/10 §3 #16: 先聚合现有源为统一 feed,避免新建重模型).
const (
// NotificationTypeSystem is a system/security operation: backup & restore,
// JWT rotation, API-key revocation, plugin install/uninstall, tool policy.
NotificationTypeSystem = "system"
// NotificationTypeChannel is a channel lifecycle or connection event
// (channel.open / close / save / reject / login).
NotificationTypeChannel = "channel"
// NotificationTypeBackthink is one proactive push the background thinker
// decided to send.
NotificationTypeBackthink = "backthink"
// NotificationTypeAudit is the remaining admin audit trail: personas, memory,
// users, RAG, sessions, uploads, audio.
NotificationTypeAudit = "audit"
)
// Provenance labels carried on an item's `source` field.
const (
notificationSourceAudit = "audit"
notificationSourceBackthink = "backthink"
// notificationIDPrefixAudit / ...Backthink namespace the source's own record id
// so the merged feed has stable, collision-free, client-dedupable ids.
notificationIDPrefixAudit = "audit:"
notificationIDPrefixBackthink = "backthink:"
)
// AdminNotification is one item of the unified notification feed (design/10 §3
// #16). It is a read-only projection of an existing record — audit rows and
// backthink records — never a new persisted model: `id` is "<source>:<原生 id>",
// `title`/`body` are derived from the source record, and `createdAt` is epoch
// milliseconds.
type AdminNotification struct {
ID string `json:"id"`
Type string `json:"type"`
Title string `json:"title"`
Body string `json:"body,omitempty"`
Source string `json:"source"`
CreatedAt int64 `json:"createdAt"`
}
// adminListNotifications returns the unified notification feed (owner/admin
// read): the admin audit trail and the background thinker's proactive pushes,
// merged newest-first and filterable by family and by a `before` cursor.
//
// A source that is not injected contributes no items — the feed reports what
// actually exists instead of fabricating a family — so a deployment without the
// backthink service simply has no `backthink` items.
func (s *Server) adminListNotifications(w http.ResponseWriter, r *http.Request) {
limit := parseNotificationLimit(r)
typ := strings.TrimSpace(r.URL.Query().Get("type"))
if typ != "" && !validNotificationType(typ) {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "invalid_type",
"type must be one of system|channel|backthink|audit"))
return
}
before, hasBefore := parseNotificationBefore(r)
items, err := s.notificationItems(r.Context(), notificationFetchLimit(limit))
if err != nil {
s.log.Error("list notifications", "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "notifications_failed",
"failed to list notifications"))
return
}
out := make([]AdminNotification, 0, limit)
for _, it := range items {
if typ != "" && it.Type != typ {
continue
}
if hasBefore && it.CreatedAt > before {
continue
}
out = append(out, it)
if len(out) == limit {
break
}
}
writeJSON(w, http.StatusOK, out)
}
// notificationItems reads both sources and returns their merge, newest first. A
// source failure is returned (never swallowed into a silently shorter feed), so
// the endpoint answers a clear error instead of pretending there was no event.
func (s *Server) notificationItems(ctx context.Context, fetch int) ([]AdminNotification, error) {
auditItems, err := s.notificationsFromAudit(ctx, fetch)
if err != nil {
return nil, err
}
btItems, err := s.notificationsFromBackthink(ctx, fetch)
if err != nil {
return nil, err
}
items := append(auditItems, btItems...)
sort.SliceStable(items, func(i, j int) bool {
if items[i].CreatedAt != items[j].CreatedAt {
return items[i].CreatedAt > items[j].CreatedAt
}
// Descending id keeps the order deterministic for items sharing a
// millisecond (the cursor can split one millisecond across pages).
return items[i].ID > items[j].ID
})
return items, nil
}
// notificationsFromAudit projects the admin audit trail (audit_logs, persisted
// by NewSQLiteAuditStore) into feed items. The fallback in-memory store holds
// only records really appended in this process, so an unwired store yields an
// empty family rather than fabricated entries.
func (s *Server) notificationsFromAudit(ctx context.Context, fetch int) ([]AdminNotification, error) {
if s.obs == nil || s.obs.audit == nil {
return nil, nil
}
records, err := s.obs.audit.List(ctx, fetch)
if err != nil {
return nil, fmt.Errorf("gateway: notification audit source: %w", err)
}
out := make([]AdminNotification, 0, len(records))
for _, rec := range records {
out = append(out, AdminNotification{
ID: notificationIDPrefixAudit + strconv.FormatInt(rec.ID, 10),
Type: notificationTypeOfAudit(rec.What),
Title: rec.What,
Body: notificationAuditBody(rec),
Source: notificationSourceAudit,
CreatedAt: notificationAuditAt(rec.When),
})
}
return out, nil
}
// notificationsFromBackthink projects the background thinker's records into feed
// items. Only records that decided to push are notifications (a plain thinking
// record is not an event the owner needs to be told about); each carries the
// delivery state so the feed never claims an undelivered push was sent.
func (s *Server) notificationsFromBackthink(ctx context.Context, fetch int) ([]AdminNotification, error) {
if s.backthink == nil {
return nil, nil
}
records, err := s.backthink.List(ctx, fetch)
if err != nil {
return nil, fmt.Errorf("gateway: notification backthink source: %w", err)
}
out := make([]AdminNotification, 0, len(records))
for _, rec := range records {
if !rec.ShouldPush {
continue
}
out = append(out, AdminNotification{
ID: notificationIDPrefixBackthink + strconv.FormatInt(rec.ID, 10),
Type: NotificationTypeBackthink,
Title: backthinkNotificationTitle(rec),
Body: clipRunes(strings.TrimSpace(rec.Conclusion), notificationBodyRunes),
Source: notificationSourceBackthink,
CreatedAt: rec.At.UnixMilli(),
})
}
return out, nil
}
// notificationTypeOfAudit classifies an audit operation into a feed family: the
// channel lifecycle is `channel`, and the system/plugin/policy operations are
// `system`; everything else stays `audit`.
func notificationTypeOfAudit(what string) string {
switch {
case strings.HasPrefix(what, "channel."):
return NotificationTypeChannel
case strings.HasPrefix(what, "system."),
strings.HasPrefix(what, "plugin."),
strings.HasPrefix(what, "policy."):
return NotificationTypeSystem
default:
return NotificationTypeAudit
}
}
// notificationAuditBody derives an audit item's body from its who/what/result
// trail, e.g. "owner:owner · ok".
func notificationAuditBody(rec AuditRecord) string {
parts := make([]string, 0, 2)
if who := strings.TrimSpace(rec.Who); who != "" {
parts = append(parts, who)
}
if result := strings.TrimSpace(rec.Result); result != "" {
parts = append(parts, result)
}
return strings.Join(parts, " · ")
}
// notificationAuditAt parses an audit record's RFC3339 timestamp into epoch
// milliseconds. An unparseable timestamp yields 0: the item still appears (oldest
// first) instead of being dropped or stamped with a made-up time.
func notificationAuditAt(when string) int64 {
ts, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(when))
if err != nil {
return 0
}
return ts.UnixMilli()
}
// backthinkNotificationTitle labels one proactive push with its trigger and
// delivery state, so the feed distinguishes "sent" from "decided but not
// delivered" (route none / no online client / push failure).
func backthinkNotificationTitle(rec backthink.Record) string {
state := "未送达"
if rec.PushResult.Pushed {
state = "已送达"
}
if trigger := strings.TrimSpace(rec.Trigger); trigger != "" {
return "主动推送(" + trigger + " · " + state + ")"
}
return "主动推送(" + state + ")"
}
// clipRunes truncates s to at most max runes, appending an ellipsis when it was
// cut. It never splits a multi-byte rune.
func clipRunes(s string, max int) string {
if max <= 0 || utf8.RuneCountInString(s) <= max {
return s
}
runes := []rune(s)
return string(runes[:max]) + "…"
}
// parseNotificationLimit parses the ?limit= query param, clamping to
// [1, maxNotificationLimit] and selecting defaultNotificationLimit when absent or
// invalid.
func parseNotificationLimit(r *http.Request) int {
n, err := strconv.Atoi(r.URL.Query().Get("limit"))
if err != nil || n <= 0 {
return defaultNotificationLimit
}
if n > maxNotificationLimit {
return maxNotificationLimit
}
return n
}
// parseNotificationBefore parses the ?before= epoch-millisecond cursor. The
// cursor is inclusive (items with createdAt <= before are returned): the sources
// resolve to milliseconds, so a page boundary can fall inside one millisecond,
// and the client deduplicates by id. An absent or malformed cursor means "from
// the newest item".
func parseNotificationBefore(r *http.Request) (int64, bool) {
raw := strings.TrimSpace(r.URL.Query().Get("before"))
if raw == "" {
return 0, false
}
ms, err := strconv.ParseInt(raw, 10, 64)
if err != nil || ms <= 0 {
return 0, false
}
return ms, true
}
// notificationFetchLimit widens a page request into the per-source read window,
// capped at maxObsLimit (the audit store's own ceiling) and never below the page.
func notificationFetchLimit(limit int) int {
fetch := limit * notificationFetchFactor
if fetch > maxObsLimit {
fetch = maxObsLimit
}
if fetch < limit {
fetch = limit
}
return fetch
}
// validNotificationType reports whether t names a feed family. An unknown family
// is rejected with 400 rather than answered with a silently empty feed.
func validNotificationType(t string) bool {
switch t {
case NotificationTypeSystem, NotificationTypeChannel, NotificationTypeBackthink, NotificationTypeAudit:
return true
default:
return false
}
}