Files
Cyrene_v2/internal/services/gateway/contracts.go
T
AskaEth be38651ba4
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(llm): 用量落库 + 只读汇总端点 + 面板用量小卡(契约 v0.64)
- model 层新增可选 UsageSink(nil ⇒ 逐字不变,既有用例零改动)
- 组合根注入 UsageWriter:有界队列 1024 + 单写者 + 批量事务(128/批、2s 兜底),
  队列满丢弃 + 限流 WARN,绝不阻塞模型调用;sink 挂 RuntimeLLM(热重载不断)
- 迁移 0017:表 llm_usage(13 列,session_id 可空)+ idx_created_at + idx_session_id
- 新端点 GET /api/v1/admin/llm/usage(owner|admin 读;缺省近 24h、上限 90 天、
  超限 400 不静默裁剪;hitRate = cacheHit/(cacheHit+cacheMiss),分母 0 ⇒ 0;
  绝不含凭据与正文;未接线/查库失败 ⇒ 500 usage_unavailable)
- LLM_USAGE_RETENTION(默认 720h,≤0 = 关闭清理):启动清一次 + 每小时
- 面板 LLM 页「用量(近 24h)」小卡 + Docs 两份同步 v0.64
- 已知缺口(如实登记):session_id 恒 NULL,需 loop 侧在请求上打标,留列后接
2026-10-04 22:16:26 +08:00

264 lines
13 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"
"net/http"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/iot"
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/emotion"
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/knowledge"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/task"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/automations"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/backthink"
)
// 本文件声明网关所消费的依赖契约。每一个都是窄接口,
// 由组合根注入的具体后端满足(见 deps.go 中的
// Deps),因此网关绝不直接取用具体实现,
// 测试也能替换成桩。
// Authenticator 抽象凭据 + RBAC 服务。*auth.JWTService 是
// 生产实现;它是接口,因此测试可替换为 mock。
type Authenticator interface {
// Sign 为 sub 签发一个带给定角色与生命周期的 token。
Sign(sub string, role auth.Role, ttl time.Duration) (string, error)
// Verify 校验一个 token 并返回其 claims。
Verify(token string) (*auth.Claims, error)
// Authorize 返回一个 HTTP 中间件,强制至少一个所需角色。
Authorize(required ...auth.Role) func(http.Handler) http.Handler
}
// CapabilityStore 抽象管理 API 中用于枚举插件与渠道的全局
// 能力注册表。*capability.Registry 是生产
// 实现。
type CapabilityStore interface {
// List 返回 Kind 匹配的每一个能力,kind 为空时
// 返回全部。
List(kind capreg.Kind) []capreg.Capability
// SetPolicy 替换指定能力的可达性标记。
SetPolicy(name string, p capreg.ToolPolicy) error
}
// Chatter 运行单次 agent 回合。*loop.Loop 是生产实现。
type Chatter interface {
// Run 为 sessionID 驱动一个回合并返回最终答案。
Run(ctx context.Context, sessionID string, messages []model.Message) (*loop.Result, error)
}
// SessionStore 列出已知的渠道会话。*session.SessionManager 是
// 生产实现。
type SessionStore interface {
List(ctx context.Context) ([]session.Session, error)
}
// PersonaStore 列出已注册角色(每个与一个人格一一绑定)。
// *persona/registry.Registry 是生产实现。网关把
// 每个 Character 映射为其线上摘要;该接口刻意返回
// 领域类型,使线上结构不进入存储契约。
type PersonaStore interface {
List() []personareg.Character
// Persona 返回给定 id 的人格(用于读取其视觉资产)。
// 生产注册表实现了它;线上摘要从它读取 Avatar /
// ChatBackground,使每个角色的视觉保持角色作用域。
Persona(id string) (*loader.Persona, bool)
}
// PersonaMoodStore 是人格存储的*可选*能力,该存储同时持有
// 每人格的情绪状态(design/01 §2:情绪跟随人格)。
// 生产实现 *personareg.Registry 实现了它;网关通过对这个窄
// 接口做断言来读取某角色的 mood,因此只列出人格的存储
// (测试替身、未来的只读目录)无需持有情绪机
// 就能继续满足 PersonaStore。当存储未实现它时,
// 某角色的 mood 只读作 "",而不是被凭空造出。
type PersonaMoodStore interface {
Emotions() *emotion.Manager
}
// MemoryStore 列出记忆条目。*runtime/memory.Store 是生产
// 实现。网关把每个 MemoryItem 映射为其线上摘要。
type MemoryStore interface {
List(ctx context.Context) ([]memory.MemoryItem, error)
}
// HistoryStore 是持久化聊天历史存储(design/16)。*runtime/history.Store
// 是生产实现。为 nil 时会话/消息端点退化:列出
// 会话回退到渠道会话视图,而创建/取消息/删除
// 端点返回明确的 501。
type HistoryStore interface {
Append(ctx context.Context, msg history.Message) error
// AppendFull 像 Append 一样持久化 msg,但返回完整的已存消息
// (含分配的 id/时间戳),使编排器能广播它以进行
// 跨端实时同步(design/16 §4.2)。
AppendFull(ctx context.Context, msg history.Message) (history.Message, error)
List(ctx context.Context, sessionID string, before int64, limit int, platform string) ([]history.Message, error)
// ListAfter 按最旧优先返回至多 limit 条严格新于 after 的消息,
// 用于重连后的前向增量追赶("before" 游标只能
// 向后走)。platform 的过滤同 List。
ListAfter(ctx context.Context, sessionID string, after int64, limit int, platform string) ([]history.Message, error)
Recent(ctx context.Context, sessionID string, n int) ([]history.Message, error)
ListPlatformLog(ctx context.Context, platform string, before int64, limit int) ([]history.Message, error)
ListAdapterLog(ctx context.Context, adapterID string, before int64, limit int) ([]history.Message, error)
ListAllLog(ctx context.Context, before int64, limit int) ([]history.Message, error)
DeleteSession(ctx context.Context, sessionID string) error
// Search 为 ?q= 消息搜索路径返回会话作用域的内容匹配
// (最新优先)。空查询得到空切片。
Search(ctx context.Context, sessionID, query string, before int64, limit int) ([]history.Message, error)
// UpdateMessage 编辑单条消息的内容并返回更新后的
// 消息。未知 id 返回 history.ErrMessageNotFound。
UpdateMessage(ctx context.Context, id, content string) (history.Message, error)
// DeleteMessage 移除单条消息。未知 id 返回
// history.ErrMessageNotFound。
DeleteMessage(ctx context.Context, id string) error
// RenameSession 设置会话的显示标题(main 或 sub)。未知 id
// 返回 history.ErrSessionNotFound。
RenameSession(ctx context.Context, sessionID, title string) error
// SetPinned 置顶/取消置顶一个会话(main 或 sub)。未知 id 返回
// history.ErrSessionNotFound。
SetPinned(ctx context.Context, sessionID string, pinned bool) error
NewSubSession(ctx context.Context, ownerID string) (string, error)
// MarkSessionRead 把会话的读游标(D4)推进到 before 并返回
// 结果游标作为 Unix 毫秒时间戳;before 为零意味着
// 「现在」。游标只向前移动。未知 id 返回
// history.ErrSessionNotFound。
MarkSessionRead(ctx context.Context, sessionID string, before time.Time) (int64, error)
ListSessions(ctx context.Context) ([]history.SessionMeta, error)
}
// IoTStore 列出 IoT 设备。*iot.Manager 是生产实现。
type IoTStore interface {
List(ctx context.Context) ([]iot.Device, error)
}
// SubagentAuditor 为 subagent_ask WS 路由审计子代理的对话
// 上下文。*orchestration.Scheduler 是生产实现。
type SubagentAuditor interface {
AuditContext(ctx context.Context, id string, mode orchestration.AuditMode, n int, query string) (*orchestration.AuditResult, error)
}
// TaskOutputStore 为 task_output_query WS 路由查询任务输出。
// *task.Manager 是生产实现。
type TaskOutputStore interface {
TaskOutput(ctx context.Context, id string) (task.OutputEvent, error)
}
// AutomationStore 为管理列表枚举自动化与提醒。
// *automations.Service 是生产实现。网关把每条
// 记录映射为其线上结构。
type AutomationStore interface {
List() []automations.AutomationRecord
}
// UserStore 枚举管理面板可见的账号与 API key。
// 生产实现读取已配置的管理账号加上
// auth.APIKey 表。网关把每个摘要映射为其线上结构。
type UserStore interface {
List() []auth.UserSummary
}
// RefreshGrantStore 管理非透明 refresh token(auth.RefreshStore 是
// 生产实现)。它是登录与 POST /auth/refresh、/auth/logout 端点
// 所返回 refreshToken 背后的凭据。只持久化哈希;
// 明文 token 每次签发/轮换恰好穿过该接口一次。
// 可选:为 nil 时登录省略 refreshToken,
// refresh 与 logout 端点返回明确的 not-configured 错误。
type RefreshGrantStore interface {
// Issue 为 userID/role 铸造一个 refresh token,返回其明文
// (仅可见一次)及其过期时间。
Issue(ctx context.Context, userID string, role auth.Role, ttl time.Duration) (string, time.Time, error)
// Rotate 校验 plaintext,并在 opts.Rotate 设置时吊销它并
// 返回替代品。对未知、已吊销或已过期的 token 返回
// auth.ErrInvalidGrant 错误。
Rotate(ctx context.Context, plaintext string, opts auth.RotateOptions) (auth.RefreshToken, string, error)
// Revoke 把匹配 plaintext 的 token 标记为已吊销,并返回是否
// 找到了存活的 token。它是幂等的。
Revoke(ctx context.Context, plaintext string) (bool, error)
// RevokeAll 删除每一个 refresh token(秘密轮换时的全局
// 失效)并返回移除了多少个。
RevokeAll(ctx context.Context) (int64, error)
// RevokeByUser 删除 userID 的每一个 refresh token(按访问
// 令牌登出,此时不知道具体是哪一份授权)并返回
// 移除了多少个。
RevokeByUser(ctx context.Context, userID string) (int64, error)
}
// BackthinkStore 为 M9/M10 监控面板枚举持久化的后台思考
// 记录。*backthink.Backthink 是生产实现。
type BackthinkStore interface {
List(ctx context.Context, limit int) ([]backthink.Record, error)
}
// ChannelConfigStore 是运行时可编辑的渠道适配器配置
// (channels.json,design/04 §3.1)。它由组合根的渠道存储
// 实现:Load 返回按平台生效的账号(文件 → 环境变量
// 回退),Path 报告 channels.json 路径,Reload 在面板保存后
// 热替换某平台运行中的适配器(Bridge.Reload)。Load 返回
// 带真实 token 的账号配置,使 owner 写路径能持久化它们;
// 管理读路径在返回客户端前对它们脱敏。
type ChannelConfigStore interface {
Path() string
// Load 返回按平台生效的账号(文件 → 环境变量回退),
// 含持久化它们所需的秘密 token。
Load() map[string][]config.ChannelAccount
// Reload 重建并热替换该平台运行中的适配器。
Reload(platform string) error
}
// OwnerRegistrar 把一个渠道账号 id 登记进主人白名单
// (configs/users/owner.yaml)。它是结构化的身份红线:扫码登录
// 确认会把扫码者绑定的 ilink_user_id 盖进白名单,使
// 渠道 bridge 经 actor 元数据认出它是主人,绝不是提示词。
// 组合根实现了它(cmd/cyrene 的 ownerRegistrar)以持久化
// 白名单并热更新活的 owner profile。登记是尽力而为:
// 失败由调用方记日志,但不会让已经施加的
// channels.json 持久化失败。
type OwnerRegistrar interface {
RegisterChannelOwner(ctx context.Context, platform, accountID string) error
}
// RagStore 支撑管理侧知识库 RAG 模块(design/10 §3.12 module 8)。
// *runtime/knowledge.Store 是生产实现:它列出/搜索
// 项目索引、导入文档(切分 + 索引)、删除文档
// (项目条目 + 级联章节)。不能写入的实现
// 会报告明确的错误(knowledge.ErrWriteUnsupported),而不是编造
// 成功;handler 把文档缺失映射为 404。
type RagStore interface {
// ListDocuments 枚举知识库文档(项目索引
// 条目)。
ListDocuments(ctx context.Context) ([]knowledge.Doc, error)
// Search 应答一次知识库查询(行级层级 + 可选
// 语义/向量排序)。
Search(ctx context.Context, query string) ([]knowledge.Hit, error)
// IndexDocument 把一份文档上传/切分/嵌入进项目索引。
IndexDocument(ctx context.Context, doc knowledge.Doc) error
// DeleteDocument 按 id 移除一份文档(及其章节),在
// 不存在该文档时返回 knowledge.ErrDocumentNotFound。
DeleteDocument(ctx context.Context, id string) error
}
// LlmUsageStore 是 LLM 用量汇总的**只读**出口(切片 U1,
// GET /api/v1/admin/llm/usage)。*runtime/model.UsageReader 是生产
// 实现:它在一块时间窗内聚合 `llm_usage` 表的 token 计数与缓存两档,
// 按用途与模型分解。
//
// 汇总里**没有任何凭据、也没有消息正文** —— 那些东西根本没进
// `llm_usage` 表(写入侧 model.UsageWriter 的样本里就没有这些字段)。
// 为 nil 时端点诚实作答 500 usage_unavailable,绝不回一份全 0
// 的假汇总。
type LlmUsageStore interface {
// Summary 聚合 q 描述的时间窗与用途过滤。q 的时间窗由
// handler 校验并补齐(缺省近 24h、上限 90 天)。
Summary(ctx context.Context, q model.UsageQuery) (model.UsageSummary, error)
}