整理(tidy)与入库前审查(D3)都只做软动作:status 改 merged/archived, 正文一个字不改、行一行不删。因此只要有一条「能看到什么记忆」的读路径 漏了 status 闸,清理就等于白清 —— 生产库上那 10 条 merged 行就是反例。 - store.go:ListByPersona / List 加 status='active' - rules.go:ListScope / ListProject 加 status='active' - Get 刻意不过闸(撤销软合并要先读得到被吸收的行),并在注释里写明 - gateway/admin.go collectMemories 只加注释:口径只有一处,不重复过滤 验证:go build ./... + go vet 绿;memory 包全量测试绿; 新增 status_gate_test.go(4 条读路径逐条断言 + Retriever 端到端 「内容逐字相同的两条,软合并后只召回一条」+ Get 反向护栏); 用 -overlay 把闸门变异成恒真 ⇒ 5 个读路径用例与检索端到端用例全红。
242 lines
11 KiB
Go
242 lines
11 KiB
Go
package gateway
|
||
|
||
import "context"
|
||
|
||
// backthinkListLimit 是未指定 limit 时管理面板索取的
|
||
// 最近 think 记录条数。
|
||
const backthinkListLimit = 50
|
||
|
||
// PluginSummary 是管理列表中一个能力/插件的线上结构。
|
||
type PluginSummary struct {
|
||
Name string `json:"name"`
|
||
Kind string `json:"kind"`
|
||
Description string `json:"description"`
|
||
AllowInGroup bool `json:"allowInGroup"`
|
||
AllowInStranger bool `json:"allowInStranger"`
|
||
}
|
||
|
||
// ChannelSummary 是一个已注册渠道适配器的线上结构。Accounts
|
||
// 承载该平台已配置的账号(已脱敏,不含原始 token),使
|
||
// 面板能展示并编辑账号配置;当 s.channels 未接线
|
||
// 或该平台没有已配置账号时省略。
|
||
//
|
||
// NeedsRescan/LastError(v0.39)报告适配器需要主人重新授权
|
||
// (见 admin_channels_rescan.go 中的 rescanAware);二者仅在适配器
|
||
// 确实要求时才出现,否则一律省略。
|
||
type ChannelSummary struct {
|
||
Name string `json:"name"`
|
||
Connected bool `json:"connected"`
|
||
NeedsRescan bool `json:"needsRescan,omitempty"`
|
||
LastError string `json:"lastError,omitempty"`
|
||
Accounts []AdminChannelAccount `json:"accounts,omitempty"`
|
||
}
|
||
|
||
// policyRequest 是 POST /api/v1/admin/plugins/policy 的请求体。
|
||
type policyRequest struct {
|
||
Name string `json:"name"`
|
||
AllowInGroup bool `json:"allowInGroup"`
|
||
AllowInStranger bool `json:"allowInStranger"`
|
||
}
|
||
|
||
// AdminLlmReasoning 是一个 provider 组的推理开关形状声明(models.json 的
|
||
// `reasoning` 块的线上投影)。kind 为空串表示「未声明形状」(等同 auto:
|
||
// 按模型名/baseURL 推断,有歧义就不下发),而不是「后端没报告」——
|
||
// 后者由整个字段缺失表示。
|
||
type AdminLlmReasoning struct {
|
||
Kind string `json:"kind"`
|
||
}
|
||
|
||
// AdminLlmProvider 是管理配置中一个 LLM provider 的线上结构。
|
||
// API key 刻意缺位:它绝不返回给客户端。
|
||
//
|
||
// 这里列出的每个可编辑字段都必须在读侧如实回填:面板是「读回来 → 改一处 →
|
||
// 整组提交」,读侧少一个键就等于保存时把它抹掉。
|
||
type AdminLlmProvider struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
Provider string `json:"provider"`
|
||
BaseURL string `json:"baseUrl"`
|
||
Model string `json:"model"`
|
||
// Multimodal = **主模型自己能否收图**(裁决 14.4.1)。它不表示「本部署
|
||
// 有视觉能力」:图片识别/OCR 工具用的模型由 .env 的 LLM_VISION_MODEL /
|
||
// LLM_OCR_MODEL 单独配置,与这里无关。
|
||
Multimodal bool `json:"multimodal"`
|
||
Enabled bool `json:"enabled"`
|
||
Fallback string `json:"fallback,omitempty"`
|
||
// MaxTokens 是该分组自身的 token 预算(0 = 未设置;此时运行时
|
||
// 在最后一个 fallback 步骤回退到 LLM_FALLBACK_MAX_TOKENS,
|
||
// 再回退到适配器默认值)。
|
||
MaxTokens int `json:"maxTokens,omitempty"`
|
||
// ContextWindow 是该分组声明的**模型上下文窗口**(token)。0(= 键被省略,
|
||
// 与 MaxTokens 同形)表示未声明:运行时按回落窗口保守推导并记一条 WARN,
|
||
// 只有声明了真实窗口才能吃到按窗口推导的上下文预算。它是 .env 侧
|
||
// LLM_CONTEXT_WINDOW 的对偶(per-group 播种)。
|
||
ContextWindow int `json:"contextWindow,omitempty"`
|
||
// Reasoning 是该组的推理开关形状(裁决 03.4.2 / 08.4.1)。始终下发
|
||
// (kind 可能为空串),使面板能把「未声明」当成一种明确状态回填。
|
||
Reasoning *AdminLlmReasoning `json:"reasoning"`
|
||
// PersonaModels 是 persona → 模型名的路由表(裁决 03.4.1),键为该组内
|
||
// 的 persona id;空/缺失 = 该组没有 per-persona 模型绑定。
|
||
PersonaModels map[string]string `json:"personaModels,omitempty"`
|
||
// PersonaProviders 是 persona → wire 协议的覆盖表,取值只能是本 build
|
||
// 承认的协议名(见 AdminLlmConfig.PersonaProviderOptions)。
|
||
PersonaProviders map[string]string `json:"personaProviders,omitempty"`
|
||
}
|
||
|
||
// AdminLlmConfig 是 GET /api/v1/admin/llm 的线上结构。
|
||
//
|
||
// ReasoningKinds / PersonaProviderOptions 是**面板表单选项的取值域**:它们由
|
||
// 配置层的常量清单直接生成,因此面板给出的候选与后端会接受的取值永远一致
|
||
// (面板不硬编码自己的一套枚举)。
|
||
type AdminLlmConfig struct {
|
||
Providers []AdminLlmProvider `json:"providers"`
|
||
// ReasoningKinds 是 responses 中 reasoning.kind 的合法取值(不含空串)。
|
||
ReasoningKinds []string `json:"reasoningKinds"`
|
||
// PersonaProviderOptions 是 personaProviders 值的合法取值(协议名)。
|
||
PersonaProviderOptions []string `json:"personaProviderOptions"`
|
||
}
|
||
|
||
// AdminAutomationRecord 是管理列表中一条自动化/提醒的线上结构。
|
||
// 对 cron 类型,Cron 承载 cron 表达式;lastRun 与
|
||
// nextRun 在值未知时省略。
|
||
type AdminAutomationRecord struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
Kind string `json:"kind"`
|
||
Cron string `json:"cron,omitempty"`
|
||
Enabled bool `json:"enabled"`
|
||
LastRun string `json:"lastRun,omitempty"`
|
||
NextRun string `json:"nextRun,omitempty"`
|
||
}
|
||
|
||
// AdminUserRecord 是管理列表中一个账号/API key 的线上结构。
|
||
type AdminUserRecord struct {
|
||
ID string `json:"id"`
|
||
Username string `json:"username"`
|
||
Role string `json:"role"`
|
||
CreatedAt string `json:"createdAt,omitempty"`
|
||
}
|
||
|
||
// AdminBackthinkItem 是管理监控列表中一条后台思考记录的
|
||
// 线上结构。
|
||
//
|
||
// Pushed 只回答「发没发出」;Route/Detail/Bypassed(design/12 §4、
|
||
// design/10 §3 #12)回答「走了哪条路、为什么打扰/为什么不打扰」——
|
||
// 它们直接来自后端已存的 PushResult,因此面板不必靠猜来解释
|
||
// 一次静默。
|
||
//
|
||
// Urgency / UrgencyLowerBound / QuietHoursBypassed 回答「这一轮有多紧急、
|
||
// 是不是穿透了安静时段」。三者都**只在存档可证时**出现,缺席 ≠ 常规档位:
|
||
// 取值来源与优先级见 admin_backthink_urgency.go —— 优先读每轮落库的**精确**
|
||
// 档位;只有 `urgencyLowerBound:true` 的行才是**推导出的下限**(旧行按安静时段
|
||
// 窗口规则反推),窗口之外且无落库档位的旧行干脆不下发,而不是编一个 routine。
|
||
type AdminBackthinkItem struct {
|
||
ID string `json:"id"`
|
||
Trigger string `json:"trigger"`
|
||
Conclusion string `json:"conclusion,omitempty"`
|
||
ShouldNotify bool `json:"shouldNotify"`
|
||
Pushed bool `json:"pushed"`
|
||
// Route 是投递路由:none | rate_limited | active_client | im_bridge。
|
||
Route string `json:"route,omitempty"`
|
||
// Detail 是打扰决策或投递的简短原因(如 quiet hours、无活跃客户端)。
|
||
Detail string `json:"detail,omitempty"`
|
||
// Bypassed 表示这是事件驱动的推送、绕过了限频闸("该发就发")。
|
||
Bypassed bool `json:"bypassed"`
|
||
At int64 `json:"at"`
|
||
// Urgency 是这一轮的紧急度档位(urgency 档位词汇:routine | warning |
|
||
// critical)。**默认读到的是该轮实际生效的精确档位**(每一轮随记录落库);
|
||
// 只有与 `urgencyLowerBound:true` 同时出现时才读作「≥ 该档(可能更高)」;
|
||
// 无法证明时省略该键(面板显示「未知」,**不是**「例常」)。
|
||
Urgency string `json:"urgency,omitempty"`
|
||
// UrgencyLowerBound 为 true 时表示 Urgency 是**推导出的下限**而不是精确档位:
|
||
// 该行没有落库档位(`backthink_records.urgency` 列出现之前的旧行,或从未走到
|
||
// 档位判定点的行),值由安静时段窗口规则反推(见 admin_backthink_urgency.go)。
|
||
// 只在为 true 时出现:精确档位与「无档位」都不必在线上区分。客户端**不得**把
|
||
// 它显示成精确档位。
|
||
UrgencyLowerBound bool `json:"urgencyLowerBound,omitempty"`
|
||
// QuietHoursBypassed 为 true 时表示这一轮**穿透了安静时段**(轮次时间落在
|
||
// 安静时段窗口内且该轮判定要推送)。只在为 true 时出现:安静时段之外的轮次
|
||
// 与「没穿透」不必在线上区分,缺席即「没有这件事」。
|
||
QuietHoursBypassed bool `json:"quietHoursBypassed,omitempty"`
|
||
}
|
||
|
||
// AdminRagDoc 是管理 RAG 列表中一个知识库文档(项目索引
|
||
// 条目)的线上结构。
|
||
type AdminRagDoc struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
Desc string `json:"desc,omitempty"`
|
||
Chunks int `json:"chunks"`
|
||
Embedded bool `json:"embedded"`
|
||
}
|
||
|
||
// AdminRagHit 是一条 RAG 检索命中的线上结构。
|
||
type AdminRagHit struct {
|
||
DocID string `json:"docId"`
|
||
DocName string `json:"docName"`
|
||
Chapter string `json:"chapter,omitempty"`
|
||
Text string `json:"text"`
|
||
Score float64 `json:"score"`
|
||
Semantic bool `json:"semantic"`
|
||
}
|
||
|
||
// AdminRagResponse 是 GET /api/v1/admin/rag 的线上结构。Hits 仅
|
||
// 在提供了查询参数时出现,因此调用方能区分「未请求搜索」
|
||
// 与「搜索了但没找到」。
|
||
type AdminRagResponse struct {
|
||
Documents []AdminRagDoc `json:"documents"`
|
||
Hits []AdminRagHit `json:"hits,omitempty"`
|
||
}
|
||
|
||
// ragQueryRequest 是 POST /api/v1/admin/rag/query 的请求体。
|
||
type ragQueryRequest struct {
|
||
Query string `json:"query"`
|
||
}
|
||
|
||
// collectSessions 从已配置的存储返回非 nil 的会话切片。
|
||
func (s *Server) collectSessions(ctx context.Context) []SessionSummary {
|
||
out := make([]SessionSummary, 0)
|
||
if s.sessions == nil {
|
||
return out
|
||
}
|
||
list, err := s.sessions.List(ctx)
|
||
if err != nil {
|
||
s.log.Error("list sessions", "err", err)
|
||
return out
|
||
}
|
||
for _, sess := range list {
|
||
out = append(out, summaryOfSession(sess))
|
||
}
|
||
return out
|
||
}
|
||
|
||
// collectMemories 从已配置的存储返回非 nil 的记忆切片。
|
||
//
|
||
// 存储层的 List 已经过 status 闸(只给在册条目),因此面板记忆列表天然
|
||
// 不显示被软合并/被归档的行 —— 这里刻意**不加**第二次过滤,避免两处
|
||
// 口径各自漂移(design/23 §8/§10)。
|
||
func (s *Server) collectMemories(ctx context.Context) []MemorySummary {
|
||
out := make([]MemorySummary, 0)
|
||
if s.memories == nil {
|
||
return out
|
||
}
|
||
list, err := s.memories.List(ctx)
|
||
if err != nil {
|
||
s.log.Error("list memories", "err", err)
|
||
return out
|
||
}
|
||
for _, it := range list {
|
||
out = append(out, MemorySummary{ID: it.ID, Scope: string(it.Scope), Text: it.Content})
|
||
}
|
||
return out
|
||
}
|
||
|
||
// iotDeviceSummary 是一个 IoT 设备的线上结构。
|
||
type iotDeviceSummary struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
Type string `json:"type"`
|
||
State map[string]any `json:"state"`
|
||
Online bool `json:"online"`
|
||
}
|