d032c198c8
链路打通 tenant → 计费事实源: - 契约:Task.Meta 加 MetaTenantID;UsageEvent 加 TenantID。 - 提交:网关 task.Meta[MetaTenantID]=tenantID(c);dispatcher emitUsage 带租户。 - 明细表 sundynix_usage_event(追加式,task_id 唯一→幂等防重投重复计费): tenant/owner/model/tokens + credits_micro + cost_micros。 - 折算:credits=total_tok/TOKENS_PER_CREDIT×credit_weight(token 基准,设 1 即 token 直计); cost 按 Pricing 折算;Pricing 加 credit_weight 列(每模型积分权重,缺省 1)。 模型名空则回退激活 chat 模型(近似,忽略 failover 备用模型,已在设计标注)。 - 网关 SubscribeUsage 折算落明细(保留 Redis 日计数作快速配额校验)。 live 验证:提交任务→一行 usage_event,tenant 匹配用户租户、 credits=89tok/1000×2×1e6=178000 微积分、cost=45/1000×1+44/1000×2=133000 微元 CNY, 折算数学与幂等键均正确。 设计见 SAAS_P2_DESIGN.md。增量2(credit_ledger 余额软扣 + rollup)待做。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
257 lines
14 KiB
Go
257 lines
14 KiB
Go
// Package contract 是 Gateway / Dispatcher / MCP 之间的共享契约:
|
||
// Task 数据结构与 NATS subject 命名约定。
|
||
package contract
|
||
|
||
import "encoding/json"
|
||
|
||
// NATS subject / stream 约定(与 README、各服务 config 保持一致)。
|
||
const (
|
||
StreamTasks = "SUNDYNIX_TASKS" // JetStream stream 名
|
||
SubjectTasks = "sundynix.tasks" // 任务发布主题前缀;实际为 sundynix.tasks.<id>
|
||
SubjectTasksAll = "sundynix.tasks.>" // stream 捕获的通配
|
||
SubjectStream = "sundynix.streams" // Token 回流前缀;实际 sundynix.streams.<id>
|
||
ConsumerDurable = "dispatchers" // Dispatcher 持久消费者(队列组负载均衡)
|
||
|
||
// HeaderStreamEnd 是 Token 流的结束信号(core NATS 消息头)。
|
||
// 置为 "1" 的消息体为空,表示该 task 的 Token 流结束。
|
||
HeaderStreamEnd = "X-Stream-End"
|
||
|
||
// MCP 工具调用约定(第 4 层 Dispatcher → 第 5 层 MCP Tools)。
|
||
// 用 core NATS request-reply:同步拿结果,队列组内负载均衡。
|
||
SubjectToolsGo = "sundynix.tools.go" // Go I/O 型工具前缀;实际 sundynix.tools.go.<tool>
|
||
SubjectToolsGoAll = "sundynix.tools.go.>" // mcp-go 通配订阅
|
||
SubjectToolsPy = "sundynix.tools.py" // Python 算法型工具前缀;实际 sundynix.tools.py.<tool>
|
||
SubjectToolsPyAll = "sundynix.tools.py.>" // mcp-py 通配订阅
|
||
QueueToolsGo = "mcp-go-workers" // mcp-go 队列组(多副本负载均衡)
|
||
QueueToolsPy = "mcp-py-workers" // mcp-py 队列组
|
||
|
||
// QueueGateway 是网关侧事件订阅/配置应答的队列组:多网关副本下,每条
|
||
// eval/usage/status 事件与每个 config 请求只由组内一个副本处理,避免重复落库/重复应答(HA)。
|
||
QueueGateway = "gateway-workers"
|
||
|
||
// 服务探活:dispatcher 既无 HTTP 端点也不挂工具,单独用一个 core NATS
|
||
// request-reply 心跳主题让控制面(管理端「服务状态」)能判定它在不在线。
|
||
SubjectHealthDispatcher = "sundynix.health.dispatcher"
|
||
|
||
// 任务生命周期状态回写:dispatcher 开跑/跑完/出错经此主题广播,网关订阅落 PG 并推 UI。
|
||
// core NATS pub-sub(状态是幂等覆盖,丢一条由下一条纠正,无需持久化)。
|
||
// 注意:必须在 sundynix.tasks.> 之外,否则会被任务流捕获成"幽灵任务"自我放大。
|
||
SubjectTaskStatus = "sundynix.status.task"
|
||
|
||
// 人工审批(HITL)决定回传前缀:实际 sundynix.approval.<task_id>。
|
||
// UI 点批准/拒绝 → 网关 nc.Publish 到此。阻塞模型经 core NATS WaitApproval 收;
|
||
// 中断/恢复模型经下方 JetStream 流持久捕获、由 dispatcher 持久消费者驱动 resume(抗离线)。
|
||
SubjectApproval = "sundynix.approval"
|
||
SubjectApprovalAll = "sundynix.approval.>" // 审批决定流捕获的通配
|
||
StreamApprovals = "SUNDYNIX_APPROVALS" // 审批决定 JetStream 流(持久,决定不因 dispatcher 离线而丢)
|
||
ConsumerApprovals = "approval-resumers" // 审批决定持久消费者(队列组:多副本下每条决定只一个副本处理 resume)
|
||
|
||
// BucketCheckpoints 是 HITL 持久化中断的 JetStream KV 桶名:存 compose 图 checkpoint
|
||
// (键=task_id)与 resume 记录(键=pending:task_id),dispatcher 重启后可据此恢复在途审批。
|
||
BucketCheckpoints = "SUNDYNIX_CHECKPOINTS"
|
||
|
||
// 自动化评测结果回写:dispatcher 评完经此广播,网关订阅落 PG 并供 UI 查询。core NATS pub-sub。
|
||
SubjectEval = "sundynix.eval.task"
|
||
|
||
// Token 用量回写:dispatcher 任务收尾经此广播本轮 token 用量,网关订阅累加到用户日预算并供计费。core NATS pub-sub。
|
||
SubjectUsage = "sundynix.usage.task"
|
||
)
|
||
|
||
// UsageEvent 是一次任务的 token 用量(dispatcher 收尾经 SubjectUsage 回流给网关累计/计费)。
|
||
// dispatcher 不持有计价,只报原始 token + 模型名;真钱成本由网关据 Pricing 折算。
|
||
type UsageEvent struct {
|
||
TaskID string `json:"task_id"`
|
||
UserID string `json:"user_id,omitempty"`
|
||
TenantID string `json:"tenant_id,omitempty"` // 租户标识(按租户计量 / 计费)
|
||
Model string `json:"model,omitempty"`
|
||
PromptTok int `json:"prompt_tok"` // 输入 token(估算)
|
||
CompTok int `json:"comp_tok"` // 输出 token(估算)
|
||
TotalTok int `json:"total_tok"` // 合计
|
||
Exceeded bool `json:"exceeded"` // 是否触顶单任务预算被中止
|
||
TS int64 `json:"ts"` // unix 毫秒
|
||
}
|
||
|
||
// 评测质量分级(据综合分 + 忠实度阈值,闭环门控/告警用)。
|
||
const (
|
||
EvalOK = "ok" // 综合 ≥ 0.75 且无忠实度风险
|
||
EvalWarn = "warn" // 0.5 ≤ 综合 < 0.75,或忠实度偏低
|
||
EvalPoor = "poor" // 综合 < 0.5
|
||
)
|
||
|
||
// EvalEvent 是一次自动化评测的结果(经 SubjectEval 回流给网关落库)。
|
||
type EvalEvent struct {
|
||
TaskID string `json:"task_id"`
|
||
Overall float64 `json:"overall"` // 综合分 [0,1]
|
||
Rule float64 `json:"rule"` // 规则分
|
||
LLM float64 `json:"llm"` // LLM 质量分
|
||
Faithful float64 `json:"faithful"` // RAG 忠实度分(0=无来源未评)
|
||
Level string `json:"level"` // ok / warn / poor(纠偏后的终值)
|
||
Flags []string `json:"flags,omitempty"` // 命中问题(规则 + 未被来源支持)
|
||
Reason string `json:"reason,omitempty"` // 评语
|
||
Sources int `json:"sources,omitempty"` // 检索来源数
|
||
Corrected bool `json:"corrected,omitempty"` // 是否经低分自动纠偏重生成后采纳(恒温器闭环)
|
||
TS int64 `json:"ts"` // unix 毫秒
|
||
}
|
||
|
||
// 任务生命周期状态机:submitted(网关建任务)→ running(dispatcher 开跑)
|
||
// → done / failed / timeout(dispatcher 收尾)。
|
||
// HITL:执行到审批节点 → waiting(等人工决定)→ 批准回 running / 拒绝→rejected。
|
||
const (
|
||
TaskSubmitted = "submitted"
|
||
TaskRunning = "running"
|
||
TaskDone = "done"
|
||
TaskFailed = "failed"
|
||
TaskTimeout = "timeout"
|
||
TaskWaiting = "waiting" // 等待人工审批
|
||
TaskRejected = "rejected" // 人工拒绝(或审批超时,安全默认拒绝)
|
||
)
|
||
|
||
// TaskStatusEvent 是一次任务状态流转事件(经 SubjectTaskStatus 回流给网关)。
|
||
type TaskStatusEvent struct {
|
||
TaskID string `json:"task_id"`
|
||
Status string `json:"status"` // running / done / failed / timeout / waiting / rejected
|
||
Detail string `json:"detail,omitempty"` // 失败原因 / 审批摘要等
|
||
TS int64 `json:"ts"` // unix 毫秒
|
||
}
|
||
|
||
// ApprovalSubject 返回某任务的人工审批决定回传主题。
|
||
func ApprovalSubject(id string) string { return SubjectApproval + "." + id }
|
||
|
||
// ApprovalDecision 是一次人工审批的结果(UI → 网关 → dispatcher)。
|
||
type ApprovalDecision struct {
|
||
TaskID string `json:"task_id"`
|
||
Node string `json:"node,omitempty"` // 审批节点 id(可空:按 task 维度兜底匹配)
|
||
Approved bool `json:"approved"` // true=批准放行,false=拒绝中止
|
||
Note string `json:"note,omitempty"` // 审批人备注
|
||
By string `json:"by,omitempty"` // 审批人(用户 id)
|
||
TS int64 `json:"ts"` // unix 毫秒
|
||
}
|
||
|
||
const (
|
||
// MetaUserID 是 Task.Meta 中承载已登录用户标识的键(用于偏好记忆召回)。
|
||
MetaUserID = "user_id"
|
||
// MetaTenantID 是 Task.Meta 中承载租户标识的键(用于用量按租户计量 / 计费)。
|
||
MetaTenantID = "tenant_id"
|
||
// MetaSessionID 是 Task.Meta 中承载会话标识的键(用于短期多轮历史)。
|
||
MetaSessionID = "session_id"
|
||
// MetaSafetyCheck 是输入护栏「灰区升级」标志:网关 Tier1(归一化+正则)判为疑似但不确定时置 true,
|
||
// Dispatcher 执行前据此调 LLM jailbreak 分类器(Tier2)裁决。明确干净/明确恶意的输入不带此标志,不付 LLM 成本。
|
||
MetaSafetyCheck = "safety_check"
|
||
// MetaTokenBudget 是单任务 token 预算上限(数字):网关按用户/套餐下发,Dispatcher 据此封顶单任务用量,
|
||
// 触顶即中止(防失控成本)。缺省用 Dispatcher 的 env TASK_TOKEN_BUDGET。
|
||
MetaTokenBudget = "token_budget"
|
||
|
||
// 配置控制面按 kind 寻址:sundynix.config.<kind>.get / .updated。
|
||
// Gateway 持有配置,消费方(Dispatcher/mcp-go)经 NATS 取用/订阅变更。
|
||
ConfigKindChat = "chat" // 对话模型(Dispatcher 用)
|
||
ConfigKindEmbedding = "embedding" // 向量模型(mcp-go RAG 用)
|
||
|
||
// 报告生成:Task.Meta[MetaIntent]==IntentReport 时,Dispatcher 走专用多步编排
|
||
// (规划大纲 → 各章节并行检索+撰写 → 汇聚 → 渲染 Word),而非通用对话图。
|
||
MetaIntent = "intent"
|
||
IntentReport = "report"
|
||
MetaTopic = "topic" // 报告主题
|
||
MetaKB = "kb" // 报告依据的知识库(可空,则不挂检索)
|
||
)
|
||
|
||
// ConfigGetSubject / ConfigUpdatedSubject 返回某类配置的 request / 广播主题。
|
||
func ConfigGetSubject(kind string) string { return "sundynix.config." + kind + ".get" }
|
||
func ConfigUpdatedSubject(kind string) string { return "sundynix.config." + kind + ".updated" }
|
||
|
||
// SubjectExec 是执行可视化事件的回流前缀;实际 sundynix.exec.<task_id>。
|
||
// 与 Token 流(sundynix.streams.<id>)分流:Token 是零拷贝字节,Exec 是结构化节点事件。
|
||
const SubjectExec = "sundynix.exec"
|
||
|
||
// ExecSubject 返回某任务的执行事件回流主题。
|
||
func ExecSubject(id string) string { return SubjectExec + "." + id }
|
||
|
||
// ExecEvent 是一次任务执行中某节点/阶段的生命周期事件(经 sundynix.exec.<id> 回流给 UI,
|
||
// 用于"运行·观测"的实时轨迹:节点点亮、工具调用入参/产出、各阶段耗时)。
|
||
type ExecEvent struct {
|
||
Seq int `json:"seq"` // 任务内自增序号(保序)
|
||
TS int64 `json:"ts"` // unix 毫秒
|
||
Node string `json:"node"` // 稳定节点 id:init / tool:wiki_search / prompt / model / plan / section:0 / render
|
||
Kind string `json:"kind"` // 归类着色:memory|tool|prompt|model|plan|section|render|system
|
||
Phase string `json:"phase"` // start|end|error|info
|
||
Label string `json:"label"` // 人读标题
|
||
Detail string `json:"detail,omitempty"` // 入参/产出/计数预览
|
||
MS int64 `json:"ms,omitempty"` // end 事件的耗时(毫秒)
|
||
}
|
||
|
||
// TripleView 是回流给 UI 的一条知识三元组(主体-关系-客体),用于实时展示抽取过程与图谱。
|
||
type TripleView struct {
|
||
S string `json:"s"`
|
||
P string `json:"p"`
|
||
O string `json:"o"`
|
||
}
|
||
|
||
// IngestEvent 是入库流水线的实时进度事件(经 sundynix.streams.<job_id> 回流给 UI)。
|
||
type IngestEvent struct {
|
||
Stage string `json:"stage"` // 解析/解析完成/切块/向量化/写Milvus/写Bleve/抽实体/写Neo4j/完成/失败
|
||
Msg string `json:"msg,omitempty"` // 文案
|
||
Done int `json:"done,omitempty"` // 进度(如已向量化块数)
|
||
Total int `json:"total,omitempty"` // 总数
|
||
Chunks []string `json:"chunks,omitempty"` // 切块预览(切块阶段发一次)
|
||
Preview string `json:"preview,omitempty"` // 解析阶段:解析出的文本片段预览
|
||
Triples []TripleView `json:"triples,omitempty"` // 抽实体阶段:LLM 抽出的知识三元组(实时浮现 + 喂图谱)
|
||
Error string `json:"error,omitempty"`
|
||
}
|
||
|
||
// ModelConfig 是一个模型后端的连接配置(provider 抽象,chat 与 embedding 同形)。
|
||
// 开发期指向第三方在线 API(OpenAI 兼容);生产期可换自部署或其它在线模型。
|
||
type ModelConfig struct {
|
||
Provider string `json:"provider"` // openai-compatible / vllm / ...
|
||
BaseURL string `json:"base_url"` // 如 https://api.deepseek.com
|
||
APIKey string `json:"api_key,omitempty"`
|
||
Model string `json:"model"` // 如 deepseek-chat / text-embedding-v3
|
||
// Fallbacks 是主模型调用失败/超时时按序切换的备用模型(仅 chat 用,骑在主配置里一并下发)。
|
||
// 网关把"其它 enabled chat 模型"填进来;dispatcher 据此把模型包成 failover 链。
|
||
Fallbacks []ModelConfig `json:"fallbacks,omitempty"`
|
||
}
|
||
|
||
// Ready 报告该配置是否足以发起真实推理。
|
||
func (m *ModelConfig) Ready() bool {
|
||
return m != nil && m.BaseURL != "" && m.Model != ""
|
||
}
|
||
|
||
// Task 是 DSL 解析组装后的可调度任务,在 NATS 上以 JSON 传输。
|
||
type Task struct {
|
||
ID string `json:"id"`
|
||
Graph json.RawMessage `json:"graph"` // React Flow 导出的 Agent 编排图
|
||
Meta map[string]any `json:"meta,omitempty"`
|
||
}
|
||
|
||
// TaskSubject 返回某任务的发布主题。
|
||
func TaskSubject(id string) string { return SubjectTasks + "." + id }
|
||
|
||
// StreamSubject 返回某任务的 Token 回流主题。
|
||
func StreamSubject(id string) string { return SubjectStream + "." + id }
|
||
|
||
// ToolSubjectGo / ToolSubjectPy 返回某工具的调用主题。
|
||
func ToolSubjectGo(tool string) string { return SubjectToolsGo + "." + tool }
|
||
func ToolSubjectPy(tool string) string { return SubjectToolsPy + "." + tool }
|
||
|
||
// ToolCall 是 Dispatcher 对一个 MCP 工具的调用请求(NATS request 体)。
|
||
type ToolCall struct {
|
||
Tool string `json:"tool"` // 工具名,如 wiki_search
|
||
Args map[string]any `json:"args,omitempty"` // 工具参数
|
||
TaskID string `json:"task_id,omitempty"` // 触发该调用的任务(便于追踪)
|
||
}
|
||
|
||
// ToolResult 是 MCP 工具的应答(NATS reply 体)。
|
||
type ToolResult struct {
|
||
OK bool `json:"ok"`
|
||
Content string `json:"content,omitempty"` // 工具产出(如检索结果文本)
|
||
Error string `json:"error,omitempty"` // 非空表示工具内部出错
|
||
}
|
||
|
||
// Marshal / Unmarshal 便捷方法。
|
||
func (t *Task) Marshal() ([]byte, error) { return json.Marshal(t) }
|
||
func Unmarshal(b []byte) (*Task, error) {
|
||
var t Task
|
||
if err := json.Unmarshal(b, &t); err != nil {
|
||
return nil, err
|
||
}
|
||
return &t, nil
|
||
}
|