From 3dc1232bc20bd6b24f2c9b2eeb60810b94f05d20 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 4 Oct 2026 16:27:54 +0800 Subject: [PATCH] =?UTF-8?q?docs(comments):=20=E6=B3=A8=E9=87=8A=E4=B8=AD?= =?UTF-8?q?=E6=96=87=E5=8C=96=20=C2=B7=20internal/runtime/model=20+=20inte?= =?UTF-8?q?rnal/services/backthink=EF=BC=8854=20=E6=96=87=E4=BB=B6?= =?UTF-8?q?=EF=BC=8C=E7=BA=AF=E6=9B=BF=E6=8D=A2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 只翻注释/文档字符串:`go/scanner` 剥注释后按 token 序列+literal 逐 token 比对,**54/54 文件 SAME**; 改动文件总行数与 HEAD 漂移 = 0(严格 1:1,无合并/拆行) - 裁决编号与 design 节号逐字保留(03.4.1/03.4.2/03.4.3/14.4.1/14.4.5/07.4.2-4、design/04 §3.1/§3.2 等); `chat_template_kwargs` / `enable_thinking` / `reasoning_effort` / `thinking:{type}` 字段名与形状说明未动 - 存疑译法(已登记):`persona` 在叙事语境用「角色」、标识符语境保留 `persona`(仓库既有模块本身混用); `rung`→「级」;`provider` 存在「提供商/提供者」两种落笔(可按需一键统一) - 复核:build/vet 绿 + 两包测试绿;gofmt 干净;残留英文注释 0 条 - 既有存量(未修,只报告):`model/anthropic_test.go` 612 行 >500;`applyAnthropicEvent` 115 行 >80 --- internal/runtime/model/adapter.go | 44 +-- internal/runtime/model/anthropic.go | 168 +++++----- .../runtime/model/anthropic_budget_test.go | 20 +- .../anthropic_deepseek_reasoning_test.go | 66 ++-- internal/runtime/model/anthropic_encode.go | 56 ++-- internal/runtime/model/anthropic_reasoning.go | 64 ++-- internal/runtime/model/anthropic_stream.go | 40 +-- internal/runtime/model/anthropic_test.go | 18 +- internal/runtime/model/budget_test.go | 34 +- .../runtime/model/deepseek_reasoning_test.go | 72 ++--- internal/runtime/model/factory.go | 86 ++--- internal/runtime/model/factory_test.go | 6 +- internal/runtime/model/fallback.go | 78 ++--- internal/runtime/model/local_rung_test.go | 38 +-- internal/runtime/model/model.go | 298 +++++++++--------- internal/runtime/model/openai.go | 76 ++--- internal/runtime/model/openai_encode.go | 96 +++--- internal/runtime/model/openai_response.go | 24 +- internal/runtime/model/openai_stream.go | 86 ++--- internal/runtime/model/openai_test.go | 12 +- .../runtime/model/persona_provider_test.go | 38 +-- internal/runtime/model/persona_router_test.go | 28 +- internal/runtime/model/reasoning.go | 118 +++---- .../runtime/model/reasoning_shape_test.go | 24 +- internal/runtime/model/reasoning_test.go | 72 ++--- internal/runtime/model/router.go | 132 ++++---- internal/runtime/model/runtime.go | 190 +++++------ internal/runtime/model/runtime_test.go | 14 +- internal/runtime/model/stream.go | 136 ++++---- internal/runtime/model/truncation.go | 92 +++--- internal/runtime/model/truncation_test.go | 54 ++-- internal/services/backthink/backthink.go | 244 +++++++------- internal/services/backthink/backthink_test.go | 54 ++-- internal/services/backthink/cfg.go | 106 +++---- internal/services/backthink/context.go | 64 ++-- internal/services/backthink/decide.go | 126 ++++---- internal/services/backthink/decide_test.go | 30 +- internal/services/backthink/event_dedup.go | 22 +- internal/services/backthink/mocks_test.go | 22 +- internal/services/backthink/proactive.go | 114 +++---- internal/services/backthink/proactive_test.go | 36 +-- internal/services/backthink/push.go | 258 +++++++-------- internal/services/backthink/push_outlet.go | 112 +++---- .../services/backthink/push_outlet_test.go | 38 +-- internal/services/backthink/push_test.go | 56 ++-- internal/services/backthink/sinks.go | 28 +- internal/services/backthink/sinks_test.go | 18 +- internal/services/backthink/state.go | 34 +- internal/services/backthink/state_test.go | 8 +- internal/services/backthink/store.go | 46 +-- internal/services/backthink/store_test.go | 2 +- internal/services/backthink/thinker.go | 32 +- internal/services/backthink/thinker_test.go | 56 ++-- internal/services/backthink/trigger.go | 60 ++-- 54 files changed, 1923 insertions(+), 1923 deletions(-) diff --git a/internal/runtime/model/adapter.go b/internal/runtime/model/adapter.go index c2bfe12..720c0b5 100644 --- a/internal/runtime/model/adapter.go +++ b/internal/runtime/model/adapter.go @@ -5,40 +5,40 @@ import ( "errors" ) -// Errors returned by the model package. Each is wrapped with %w on the way out -// so callers can test for a category while keeping the underlying cause. +// model 包返回的错误。向外传递时一律用 %w 包装, +// 以便调用方既能按类别判定,又能保留底层原因。 var ( - // ErrNoModel is returned when no model name can be resolved. + // 无法解析出任何模型名时返回 ErrNoModel。 ErrNoModel = errors.New("model: no model configured") - // ErrNoSteps is returned when a fallback chain has no steps. + // 降级链没有任何步骤时返回 ErrNoSteps。 ErrNoSteps = errors.New("model: empty fallback chain") - // ErrUpstream is returned when the provider request fails (network, status, - // decode) or every fallback step fails. + // 提供商请求失败(网络、状态码、解码)或降级链的 + // 每一步都失败时,返回 ErrUpstream。 ErrUpstream = errors.New("model: upstream request failed") - // ErrBadRequest is returned when the request cannot be encoded for the wire. + // 请求无法编码为线格式时返回 ErrBadRequest。 ErrBadRequest = errors.New("model: invalid request") - // ErrStream is returned when a stream ends before completion. + // 流在完成前就结束时返回 ErrStream。 ErrStream = errors.New("model: stream closed before completion") ) -// Recorder receives a CallRecord after a successfully completed LLM call. It is -// invoked by adapters so the runtime (M9 monitor/frontend) can collect usage -// without coupling to a storage backend. +// Recorder 在一次成功完成的 LLM 调用之后接收 CallRecord。 +// 它由各适配器调用,好让运行时(M9 监控/前端)收集用量, +// 而不必与任何存储后端耦合。 type Recorder func(CallRecord) -// Adapter abstracts a single LLM provider. Implementations are vendor-specific -// but speak the normalized Request/Response vocabulary; a fallback Chain or a -// Router can compose them without knowing which provider is behind each one. +// Adapter 抽象单个 LLM 提供商。各实现面向具体厂商, +// 但都说归一化的 Request/Response 词汇;降级 Chain 或 +// Router 都能组合它们,无需知道每个背后是哪家提供商。 type Adapter interface { - // Name returns a stable identifier for the provider/adapter. + // Name 返回提供商/适配器的稳定标识。 Name() string - // Chat performs a non-streaming completion and returns the full response. + // Chat 执行一次非流式补全并返回完整响应。 Chat(ctx context.Context, req Request, opts Options) (Response, error) - // ChatStream streams structured events via onEvent and returns the aggregated - // response (with usage when the provider reports it). onEvent may be nil to - // discard events while still aggregating. Every provider-supported segment is - // bracketed (text_start/delta/end, tool_call_start/delta/end), so the consumer - // can render each independently; segments adapters cannot yet distinguish are - // delivered as text_delta events. + // ChatStream 通过 onEvent 流出结构化事件,并返回聚合后的 + // 响应(提供商上报用量时一并带上)。onEvent 可为 nil, + // 此时仍照常聚合、只是丢弃事件。提供商支持的每个片段 + // 都被成对包裹(text_start/delta/end、tool_call_start/delta/end), + // 消费者因此可以独立渲染各片段;适配器尚无法区分的片段 + // 以 text_delta 事件投递。 ChatStream(ctx context.Context, req Request, onEvent func(StreamEvent)) (Response, error) } diff --git a/internal/runtime/model/anthropic.go b/internal/runtime/model/anthropic.go index 3c0eb15..876d0d0 100644 --- a/internal/runtime/model/anthropic.go +++ b/internal/runtime/model/anthropic.go @@ -15,41 +15,41 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" ) -// Anthropic Messages (/v1/messages) wire protocol constants. +// Anthropic Messages(/v1/messages)线协议常量。 const ( - // anthropicAPIVersion is the Messages API version sent on every request. + // anthropicAPIVersion 是每次请求都要发送的 Messages API 版本。 anthropicAPIVersion = "2023-06-01" - // anthropicDefaultMaxTokens is the default max_tokens sent when - // Request.MaxTokens is zero. Anthropic requires max_tokens to be present and - // positive, so a sensible default is applied rather than omitting it. - // max_tokens is shared by thinking + text, so it must leave room for both. + // anthropicDefaultMaxTokens 是 Request.MaxTokens 为零时发送的默认 + // max_tokens。Anthropic 要求 max_tokens 必须存在且为正, + // 因此这里给出合理默认值而不是省略它。 + // max_tokens 由思考与正文共享,必须给两者都留出空间。 anthropicDefaultMaxTokens = 8192 - // anthropicDataPrefix marks a payload embedded as a data: URI. + // anthropicDataPrefix 标记以 data: URI 内嵌的负载。 anthropicDataPrefix = "data:" - // anthropicThinkingTypeEnabled is the wire value of an enabled extended- - // thinking directive. + // anthropicThinkingTypeEnabled 是「开启扩展思考」指令的 + // 线取值。 anthropicThinkingTypeEnabled = "enabled" ) -// anthropicThinking is the wire form of the extended-thinking directive. It is -// omitted entirely when the caller did not ask for thinking (or asked for it to -// be off), because Anthropic treats a missing field as "no thinking". DeepSeek's -// /anthropic endpoint instead defaults to enabled, so an explicit off is sent as -// {"type":"disabled"} with no budget (design/04 §3.2 fact 2). +// anthropicThinking 是扩展思考指令的线格式。调用方未要求思考 +// (或要求关闭)时整段省略,因为 Anthropic 把字段缺失视为「不思考」。 +// DeepSeek 的 /anthropic 端点恰好相反、默认开启,所以显式关闭会带着 +// 不带预算的 {"type":"disabled"} 发出去 +// (design/04 §3.2 事实 2)。 type anthropicThinking struct { Type string `json:"type"` BudgetTokens int `json:"budget_tokens,omitempty"` } -// anthropicOutputConfig is the Anthropic-format reasoning strength block. Only -// DeepSeek's /anthropic endpoint documents it (and only the effort field), so it -// is never sent to the Anthropic endpoint (design/04 §3.2 fact 6). +// anthropicOutputConfig 是 Anthropic 格式的推理强度块。只有 +// DeepSeek 的 /anthropic 端点记录了它(且只有 effort 字段),所以 +// 它永远不会发给 Anthropic 端点(design/04 §3.2 事实 6)。 type anthropicOutputConfig struct { Effort string `json:"effort"` } -// anthropicRequest is the body sent to /v1/messages. Messages carry Anthropic's -// content-block model and the system prompt lives at the request root. +// anthropicRequest 是发给 /v1/messages 的请求体。Messages 采用 Anthropic +// 的内容块模型,system 提示词位于请求根。 type anthropicRequest struct { Model string `json:"model"` System string `json:"system,omitempty"` @@ -63,21 +63,21 @@ type anthropicRequest struct { Stream bool `json:"stream,omitempty"` } -// anthropicMessage is one conversation turn. Content is an array of content -// blocks (text / image for now; tool_result blocks are added by M2). +// anthropicMessage 是一个会话回合。Content 是内容块数组 +// (目前为文本 / 图片;tool_result 块由 M2 补上)。 type anthropicMessage struct { Role string `json:"role"` Content any `json:"content"` } -// anthropicTool is the wire form of a tool schema. +// anthropicTool 是工具 schema 的线格式。 type anthropicTool struct { Name string `json:"name"` Description string `json:"description,omitempty"` InputSchema any `json:"input_schema"` } -// anthropicResponse is the JSON body returned by /v1/messages. +// anthropicResponse 是 /v1/messages 返回的 JSON 响应体。 type anthropicResponse struct { ID string `json:"id"` Model string `json:"model"` @@ -86,8 +86,8 @@ type anthropicResponse struct { Usage *anthropicUsage `json:"usage,omitempty"` } -// anthropicBlock is a single content block in a response. For tool_use blocks the -// Input field is a fully decoded object (never a JSON string). +// anthropicBlock 是响应里的单个内容块。对 tool_use 块而言, +// Input 字段是已完全解码的对象(绝不是 JSON 字符串)。 type anthropicBlock struct { Type string `json:"type"` Text string `json:"text,omitempty"` @@ -96,17 +96,17 @@ type anthropicBlock struct { Input map[string]any `json:"input,omitempty"` } -// anthropicUsage reports token consumption. Anthropic reports input_tokens and -// output_tokens rather than a single total. +// anthropicUsage 上报 token 消耗。Anthropic 上报的是 input_tokens +// 与 output_tokens,而不是单一总量。 type anthropicUsage struct { InputTokens int `json:"input_tokens"` OutputTokens int `json:"output_tokens"` } -// AnthropicChatAdapter calls an Anthropic Messages (/v1/messages) endpoint. It is -// constructed from a config.LLMConfig and implements Adapter for both -// non-streaming and token-level SSE streaming, speaking the provider's native -// content-block protocol so tool_use.input arrives as a structured object. +// AnthropicChatAdapter 调用 Anthropic Messages(/v1/messages)端点。 +// 它由 config.LLMConfig 构建,同时实现非流式与 token 级 SSE 流式的 +// Adapter,直接说提供商的原生内容块协议, +// 因此 tool_use.input 以结构化对象到达。 type AnthropicChatAdapter struct { name string baseURL string @@ -114,34 +114,34 @@ type AnthropicChatAdapter struct { model string client *http.Client recorder Recorder - // log reports reasoning adjustments and ignored directives. It is never nil: - // a discard logger tagged with this adapter's source is installed when the - // caller supplies none, so a provider decision is always traceable. + // log 上报推理调整与被忽略的指令。它永不为 nil: + // 调用方未提供时,会装入一个带有本适配器来源标记的丢弃式日志器, + // 因此提供商的每个决定都可追溯。 log *slog.Logger } -// AnthropicOption configures an AnthropicChatAdapter. +// AnthropicOption 用于配置 AnthropicChatAdapter。 type AnthropicOption func(*AnthropicChatAdapter) -// WithAnthropicHTTPClient overrides the default HTTP client (used to inject mocks -// or a tuned transport in tests and production). +// WithAnthropicHTTPClient 覆盖默认 HTTP 客户端(用于在测试与生产 +// 中注入 mock 或调优过的 transport)。 func WithAnthropicHTTPClient(c *http.Client) AnthropicOption { return func(a *AnthropicChatAdapter) { a.client = c } } -// WithAnthropicRecorder wires a per-call Recorder for CallRecord accounting. +// WithAnthropicRecorder 装入按调用计账的 Recorder,用于 CallRecord 统计。 func WithAnthropicRecorder(r Recorder) AnthropicOption { return func(a *AnthropicChatAdapter) { a.recorder = r } } -// WithAnthropicModel overrides the adapter's default model name. +// WithAnthropicModel 覆盖适配器的默认模型名。 func WithAnthropicModel(m string) AnthropicOption { return func(a *AnthropicChatAdapter) { a.model = m } } -// WithAnthropicLogger attaches the logger used to report reasoning adjustments -// (budget raised to fit max_tokens, ignored directives). A nil logger is ignored -// so the discard default stays in place. +// WithAnthropicLogger 挂上用于上报推理调整的日志器 +// (为容纳 max_tokens 而抬高的预算、被忽略的指令)。nil 日志器会被忽略, +// 从而保留丢弃式的默认值。 func WithAnthropicLogger(l *slog.Logger) AnthropicOption { return func(a *AnthropicChatAdapter) { if l != nil { @@ -150,8 +150,8 @@ func WithAnthropicLogger(l *slog.Logger) AnthropicOption { } } -// NewAnthropicChat builds an Anthropic adapter from a supplier config. baseURL is -// trimmed of any trailing slash before the /v1/messages path is appended. +// NewAnthropicChat 由供应商配置构建 Anthropic 适配器。baseURL 会先 +// 去掉末尾斜杠,再拼接 /v1/messages 路径。 func NewAnthropicChat(cfg config.LLMConfig, opts ...AnthropicOption) *AnthropicChatAdapter { a := &AnthropicChatAdapter{ name: "anthropic", @@ -165,9 +165,9 @@ func NewAnthropicChat(cfg config.LLMConfig, opts ...AnthropicOption) *AnthropicC log: discardLogger("runtime/model/anthropic"), } if kind := strings.TrimSpace(cfg.ReasoningKind); kind != "" && kind != runtimeconfig.ReasoningKindAuto { - // The anthropic protocol defines its own thinking shape, so an - // openai-shape declaration is inert here. Report it rather than letting a - // configured option be silently ineffective (裁决 03.4.2: 禁止静默无效). + // anthropic 协议自带思考形状,因此这里声明 openai + // 形状是无效的。要上报它,而不是任由一个已配置的选项 + // 静默失效(裁决 03.4.2:禁止静默无效)。 a.log.Warn("reasoning.kind ignored: the anthropic protocol defines its own thinking shape", "provider", ProviderAnthropic, "field", "reasoning.kind", "kind", kind) } @@ -177,14 +177,14 @@ func NewAnthropicChat(cfg config.LLMConfig, opts ...AnthropicOption) *AnthropicC return a } -// Name returns the adapter identifier. +// Name 返回适配器标识。 func (a *AnthropicChatAdapter) Name() string { return a.name } -// Chat performs a non-streaming completion. A response cut off by the token -// budget with no visible output (stop_reason max_tokens and neither text nor a -// tool_use block) is retried once with a doubled budget and, if that still -// yields nothing, replaced by a non-empty placeholder so the caller never sees a -// silent empty reply. +// Chat 执行一次非流式补全。被 token 预算截断且没有任何可见输出 +// (stop_reason 为 max_tokens,且既无文本也无 tool_use 块) +// 的响应,会用翻倍的预算重试一次; +// 若仍无输出,则替换为非空占位文本, +// 让调用方永远不会看到静默的空回复。 func (a *AnthropicChatAdapter) Chat(ctx context.Context, req Request, opts Options) (Response, error) { start := time.Now() if a.baseURL == "" { @@ -210,8 +210,8 @@ func (a *AnthropicChatAdapter) Chat(ctx context.Context, req Request, opts Optio return out, nil } -// chatOnce performs a single non-streaming /v1/messages call and normalizes the -// wire body into a Response. +// chatOnce 执行一次非流式 /v1/messages 调用,并把线格式 +// 响应体归一化为 Response。 func (a *AnthropicChatAdapter) chatOnce(ctx context.Context, req Request) (Response, error) { body, err := a.buildRequest(req, false) if err != nil { @@ -236,13 +236,13 @@ func (a *AnthropicChatAdapter) chatOnce(ctx context.Context, req Request) (Respo return responseFromAnthropicWire(&wire), nil } -// ChatStream streams structured events via onEvent and returns the aggregated -// response with usage when the provider reports it. Text blocks are delivered as -// text_start/text_delta/text_end events; tool_use blocks are delivered as -// tool_call_start/tool_call_delta/tool_call_end so the frontend can render each -// segment independently. A budget-truncated empty stream is retried once with a -// doubled budget (thinking from the retry is suppressed to avoid a duplicate -// block) and, if still empty, closed with a non-empty placeholder text segment. +// ChatStream 通过 onEvent 流出结构化事件,并在提供商上报时 +// 返回带用量的聚合响应。文本块以 text_start/text_delta/text_end +// 事件投递;tool_use 块以 +// tool_call_start/tool_call_delta/tool_call_end 投递, +// 前端因此可以独立渲染各片段。被预算截断的空流会用翻倍预算重试一次 +// (重试产生的思考被抑制,以免出现重复块), +// 若仍为空,则用一段非空占位文本关闭。 func (a *AnthropicChatAdapter) ChatStream(ctx context.Context, req Request, onEvent func(StreamEvent)) (Response, error) { start := time.Now() if a.baseURL == "" { @@ -267,8 +267,8 @@ func (a *AnthropicChatAdapter) ChatStream(ctx context.Context, req Request, onEv return out, nil } -// streamOnce performs a single streaming /v1/messages call and aggregates its -// SSE events into a Response. +// streamOnce 执行一次流式 /v1/messages 调用,并把其 SSE +// 事件聚合为 Response。 func (a *AnthropicChatAdapter) streamOnce(ctx context.Context, req Request, onEvent func(StreamEvent)) (Response, error) { body, err := a.buildRequest(req, true) if err != nil { @@ -289,7 +289,7 @@ func (a *AnthropicChatAdapter) streamOnce(ctx context.Context, req Request, onEv return scanAnthropicSSE(resp.Body, onEvent) } -// resolveModel returns the effective model name. +// resolveModel 返回实际生效的模型名。 func (a *AnthropicChatAdapter) resolveModel(req Request) (string, error) { if req.Model != "" { return req.Model, nil @@ -300,7 +300,7 @@ func (a *AnthropicChatAdapter) resolveModel(req Request) (string, error) { return "", ErrNoModel } -// endpoint returns the full /v1/messages URL. +// endpoint 返回完整的 /v1/messages URL。 func (a *AnthropicChatAdapter) endpoint() string { return a.baseURL + "/v1/messages" } @@ -314,16 +314,16 @@ func (a *AnthropicChatAdapter) buildRequest(req Request, stream bool) ([]byte, e var thinking *anthropicThinking var output *anthropicOutputConfig if deepSeekAnthropicEndpoint(req.Model, a.baseURL) { - // A DeepSeek /anthropic endpoint ignores budget_tokens, so max_tokens is - // deliberately not raised here: raising it would misreport the budget the - // provider actually applies. Strength goes through output_config.effort - // instead (design/04 §3.2 fact 6). + // DeepSeek 的 /anthropic 端点忽略 budget_tokens,因此这里刻意 + // 不抬高 max_tokens:抬高了就会错误地反映提供商实际采用的预算。 + // 强度改走 output_config.effort + // (design/04 §3.2 事实 6)。 thinking, output = anthropicDeepSeekReasoning(a.log, req.Model, req.Thinking, req.ReasoningEffort) reportForcedToolChoiceWithThinking(a.log, string(vendorDeepSeek), req.Model, req.ToolChoice, anthropicThinkingOn(thinking)) } else { - // Extended thinking shares max_tokens with the visible answer, so on the - // Anthropic endpoint the budget is resolved together with the limit it - // requires (design/04 §3.2 red line). + // 扩展思考与可见答案共享 max_tokens,所以在 Anthropic 端点上, + // 预算是与它所需要的限额一起解出的 + // (design/04 §3.2 红线)。 tokenLimit, thinking = anthropicThinkingFor(req.Thinking, tokenLimit, a.log) a.reportAnthropicEffortIgnored(req) } @@ -346,10 +346,10 @@ func (a *AnthropicChatAdapter) buildRequest(req Request, stream bool) ([]byte, e return buf, nil } -// reportAnthropicEffortIgnored reports a reasoning_effort on an endpoint that has -// no field for it: the Anthropic Messages protocol carries thinking strength as -// budget_tokens. It only reports — inventing a budget would change the caller's -// intent. +// reportAnthropicEffortIgnored 上报落在没有对应字段的端点上的 +// reasoning_effort:Anthropic Messages 协议用 budget_tokens +// 承载思考强度。它只上报——凭空编一个预算会改变调用方的 +// 意图。 func (a *AnthropicChatAdapter) reportAnthropicEffortIgnored(req Request) { if strings.TrimSpace(req.ReasoningEffort) == "" { return @@ -393,10 +393,10 @@ func (a *AnthropicChatAdapter) record(req Request, start time.Time, tokens int) }) } -// responseFromAnthropicWire converts a non-streaming /v1/messages response into a -// normalized Response. Each text block contributes to the assistant content; each -// tool_use block becomes a ToolCall whose Args is taken directly from the decoded -// input object. +// responseFromAnthropicWire 把非流式 /v1/messages 响应转换为 +// 归一化的 Response。每个文本块汇入助手内容; +// 每个 tool_use 块变成一个 ToolCall,其 Args 直接取自已解码的 +// input 对象。 func responseFromAnthropicWire(wire *anthropicResponse) Response { out := Response{Model: wire.Model} var b strings.Builder @@ -421,9 +421,9 @@ func responseFromAnthropicWire(wire *anthropicResponse) Response { return out } -// AnthropicToolResult builds the content block used to feed a tool result back to -// Anthropic (role: user, first content element). toolUseID must equal the id of a -// previous tool_use block; isError marks an execution failure. +// AnthropicToolResult 构建把工具结果回喂给 Anthropic 的内容块 +// (role: user,第一个内容元素)。toolUseID 必须等于此前某个 +// tool_use 块的 id;isError 标记执行失败。 func AnthropicToolResult(toolUseID, content string, isError bool) map[string]any { return map[string]any{ "type": "tool_result", diff --git a/internal/runtime/model/anthropic_budget_test.go b/internal/runtime/model/anthropic_budget_test.go index aacdbbf..4dc355b 100644 --- a/internal/runtime/model/anthropic_budget_test.go +++ b/internal/runtime/model/anthropic_budget_test.go @@ -10,11 +10,11 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/config" ) -// TestAnthropicChat_Chat_DefaultMaxTokensLargeEnoughForThinking guards the fix -// for thinking models whose reasoning shares the max_tokens budget with the -// visible answer: the zero-value default must leave room for both. It also -// asserts an explicit Request.MaxTokens is never overridden by that default -// (the existing 64-token case in anthropic_test.go is the compatibility proof). +// TestAnthropicChat_Chat_DefaultMaxTokensLargeEnoughForThinking 守护思考 +// 模型的修复:其推理与可见答案共享 max_tokens 预算时, +// 零值默认必须给两者都留出空间。它还断言显式的 +// Request.MaxTokens 绝不会被该默认覆盖 +// (anthropic_test.go 里既有的 64-token 用例就是兼容性证明)。 func TestAnthropicChat_Chat_DefaultMaxTokensLargeEnoughForThinking(t *testing.T) { t.Parallel() ch := make(chan anthropicCaptured, 1) @@ -33,11 +33,11 @@ func TestAnthropicChat_Chat_DefaultMaxTokensLargeEnoughForThinking(t *testing.T) } } -// TestAnthropicChat_Chat_LongThinkingThenShortText asserts a response whose -// thinking block is far longer than its text block still surfaces the text and -// never leaks the thinking into Content (the thinking-is-not-the-answer red -// line). This is the shape a thinking model produces when the budget is large -// enough, versus the truncation the old 1024 cap caused. +// TestAnthropicChat_Chat_LongThinkingThenShortText 断言思考块 +// 远长于文本块的响应仍会浮出文本,且 +// 绝不把思考泄漏进 Content(思考不是答案的 +// 红线)。这就是预算足够大时思考模型产出的形状, +// 与旧 1024 上限造成的截断形成对照。 func TestAnthropicChat_Chat_LongThinkingThenShortText(t *testing.T) { t.Parallel() thinking := strings.Repeat("推理", 1500) // 3000 runes, far longer than the answer diff --git a/internal/runtime/model/anthropic_deepseek_reasoning_test.go b/internal/runtime/model/anthropic_deepseek_reasoning_test.go index e11c992..fdf4801 100644 --- a/internal/runtime/model/anthropic_deepseek_reasoning_test.go +++ b/internal/runtime/model/anthropic_deepseek_reasoning_test.go @@ -9,10 +9,10 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/config" ) -// TestDeepSeekAnthropicEndpoint_Classification pins the endpoint detection: a -// DeepSeek /anthropic URL must be recognized even when the configured model name -// is not a DeepSeek one (the service silently maps unknown names onto -// deepseek-flash, so the name is not a reliable signal there). +// TestDeepSeekAnthropicEndpoint_Classification 钉住端点检测: +// 即便配置的模型名不是 DeepSeek 的,也必须认出 +// DeepSeek /anthropic URL(该服务会把未知名称静默映射到 +// deepseek-flash,所以那里模型名不是可靠信号)。 func TestDeepSeekAnthropicEndpoint_Classification(t *testing.T) { t.Parallel() cases := []struct { @@ -33,18 +33,18 @@ func TestDeepSeekAnthropicEndpoint_Classification(t *testing.T) { } } -// newDeepSeekAnthropicAdapter builds an Anthropic-protocol adapter whose model -// name identifies DeepSeek, so the DeepSeek /anthropic branch is exercised while -// the request still goes to a local stub. +// newDeepSeekAnthropicAdapter 构建一个 Anthropic 协议适配器,其模型 +// 名标识 DeepSeek,从而走到 DeepSeek /anthropic 分支, +// 同时请求仍发往本地 stub。 func newDeepSeekAnthropicAdapter(t *testing.T, ch chan<- anthropicCaptured, opts ...AnthropicOption) *AnthropicChatAdapter { t.Helper() srv := newAnthropicServer(t, http.StatusOK, "application/json", anthropicTextResponse, ch) return NewAnthropicChat(config.LLMConfig{AnthropicBaseURL: srv.URL, AnthropicModel: deepSeekModel}, opts...) } -// TestAnthropicDeepSeek_BudgetDoesNotRaiseMaxTokens asserts the max_tokens raise -// stays on the Anthropic endpoint: DeepSeek's /anthropic ignores budget_tokens, so -// raising the limit there would misreport the budget the provider applies. +// TestAnthropicDeepSeek_BudgetDoesNotRaiseMaxTokens 断言抬高 max_tokens +// 只留在 Anthropic 端点:DeepSeek 的 /anthropic 忽略 budget_tokens,所以在 +// 那里抬高限额会错误反映提供商采用的预算。 func TestAnthropicDeepSeek_BudgetDoesNotRaiseMaxTokens(t *testing.T) { t.Parallel() ch := make(chan anthropicCaptured, 1) @@ -79,9 +79,9 @@ func TestAnthropicDeepSeek_BudgetDoesNotRaiseMaxTokens(t *testing.T) { } } -// TestAnthropicOfficial_BudgetStillRaisesMaxTokens is the counterpart guard: on -// the Anthropic endpoint the design red line ("budget must not eat the answer") -// still holds. +// TestAnthropicOfficial_BudgetStillRaisesMaxTokens 是对位守护:在 +// Anthropic 端点上,设计红线(「预算不得吃光答案」) +// 依然成立。 func TestAnthropicOfficial_BudgetStillRaisesMaxTokens(t *testing.T) { t.Parallel() ch := make(chan anthropicCaptured, 1) @@ -110,9 +110,9 @@ func TestAnthropicOfficial_BudgetStillRaisesMaxTokens(t *testing.T) { } } -// TestAnthropicDeepSeek_OutputConfigEffort asserts the DeepSeek /anthropic -// strength path exists: the effort becomes output_config.effort, and the aliases -// are normalized to the tiers the service applies. +// TestAnthropicDeepSeek_OutputConfigEffort 断言 DeepSeek /anthropic 的 +// 强度路径存在:effort 变成 output_config.effort,别名 +// 被归一化为服务采用的档位。 func TestAnthropicDeepSeek_OutputConfigEffort(t *testing.T) { t.Parallel() cases := []struct{ sent, want string }{ @@ -148,8 +148,8 @@ func TestAnthropicDeepSeek_OutputConfigEffort(t *testing.T) { } } -// TestAnthropicDeepSeek_OutputConfigEffortInvalidDropped asserts an unrecognized -// effort is dropped and reported rather than approximated. +// TestAnthropicDeepSeek_OutputConfigEffortInvalidDropped 断言无法识别的 +// effort 会被丢弃并上报,而不是做近似处理。 func TestAnthropicDeepSeek_OutputConfigEffortInvalidDropped(t *testing.T) { t.Parallel() ch := make(chan anthropicCaptured, 1) @@ -178,10 +178,10 @@ func TestAnthropicDeepSeek_OutputConfigEffortInvalidDropped(t *testing.T) { } } -// TestAnthropicDeepSeek_EffortNoneDisablesThinking pins the translation of the -// one effort value output_config cannot carry: DeepSeek's Anthropic format has no -// effort "none", so the off intent goes through the thinking switch — mapping it -// onto a tier would turn thinking off into thinking on. +// TestAnthropicDeepSeek_EffortNoneDisablesThinking 钉住 output_config +// 无法承载的那一个 effort 取值的翻译:DeepSeek 的 Anthropic 格式 +// 没有 "none" 档,所以关闭意图经 thinking 开关转达——把它映射 +// 到某个档位会把「关闭思考」变成「开启思考」。 func TestAnthropicDeepSeek_EffortNoneDisablesThinking(t *testing.T) { t.Parallel() ch := make(chan anthropicCaptured, 1) @@ -208,9 +208,9 @@ func TestAnthropicDeepSeek_EffortNoneDisablesThinking(t *testing.T) { } } -// TestAnthropicDeepSeek_ThinkingSwitch asserts an explicit switch is carried as -// the Anthropic-format thinking block, and that a disabled one omits the budget -// (DeepSeek ignores it anyway). +// TestAnthropicDeepSeek_ThinkingSwitch 断言显式开关以 +// Anthropic 格式的 thinking 块携带,且 disabled 时不带预算 +// (反正 DeepSeek 会忽略它)。 func TestAnthropicDeepSeek_ThinkingSwitch(t *testing.T) { t.Parallel() cases := []struct { @@ -253,8 +253,8 @@ func TestAnthropicDeepSeek_ThinkingSwitch(t *testing.T) { } } -// TestAnthropicDeepSeek_EffortDroppedWhenThinkingDisabled keeps the "off wins" -// rule on the Anthropic path too: output_config.effort must not re-open thinking. +// TestAnthropicDeepSeek_EffortDroppedWhenThinkingDisabled 在 Anthropic 路径 +// 上也守住「关闭优先」规则:output_config.effort 不得重新打开思考。 func TestAnthropicDeepSeek_EffortDroppedWhenThinkingDisabled(t *testing.T) { t.Parallel() ch := make(chan anthropicCaptured, 1) @@ -281,9 +281,9 @@ func TestAnthropicDeepSeek_EffortDroppedWhenThinkingDisabled(t *testing.T) { } } -// TestAnthropicOfficial_ReasoningEffortReportedIgnored asserts a reasoning_effort -// aimed at the Anthropic endpoint is reported instead of silently disappearing — -// the Messages protocol has no such field. +// TestAnthropicOfficial_ReasoningEffortReportedIgnored 断言落在 +// Anthropic 端点上的 reasoning_effort 会被上报,而不是静默消失—— +// Messages 协议没有这个字段。 func TestAnthropicOfficial_ReasoningEffortReportedIgnored(t *testing.T) { t.Parallel() ch := make(chan anthropicCaptured, 1) @@ -313,9 +313,9 @@ func TestAnthropicOfficial_ReasoningEffortReportedIgnored(t *testing.T) { } } -// TestAnthropicDeepSeek_ForcedToolChoiceWithThinkingWarns covers the DeepSeek 400 -// trap on the Anthropic path: the adapter warns and still sends the directive — -// it never closes thinking on its own. +// TestAnthropicDeepSeek_ForcedToolChoiceWithThinkingWarns 覆盖 Anthropic 路径 +// 上的 DeepSeek 400 陷阱:适配器会警告但仍发送该指令—— +// 它绝不自行关闭思考。 func TestAnthropicDeepSeek_ForcedToolChoiceWithThinkingWarns(t *testing.T) { t.Parallel() ch := make(chan anthropicCaptured, 1) diff --git a/internal/runtime/model/anthropic_encode.go b/internal/runtime/model/anthropic_encode.go index a997451..1539e79 100644 --- a/internal/runtime/model/anthropic_encode.go +++ b/internal/runtime/model/anthropic_encode.go @@ -5,11 +5,11 @@ import ( "strings" ) -// buildAnthropicMessages splits a normalized message list into a top-level system -// string and the remaining messages in Anthropic's content-block form. All -// RoleSystem messages are concatenated into a single system prompt (Anthropic -// carries it at the request root, not in messages); messages with no content -// parts are dropped because Anthropic rejects empty content. +// buildAnthropicMessages 把归一化消息列表拆成顶层的 system +// 字符串与其余按 Anthropic 内容块形式表示的消息。所有 +// RoleSystem 消息拼接成单个 system 提示词(Anthropic +// 把它放在请求根而非 messages 里);没有内容片段的消息 +// 会被丢弃,因为 Anthropic 拒绝空内容。 func buildAnthropicMessages(msgs []Message) (string, []anthropicMessage, error) { var sys strings.Builder out := make([]anthropicMessage, 0, len(msgs)) @@ -35,9 +35,9 @@ func buildAnthropicMessages(msgs []Message) (string, []anthropicMessage, error) return sys.String(), out, nil } -// messageText concatenates the text-bearing parts of a message (system prompts -// and tool-result bodies). Tool-result parts carry their body in Text, so a -// RoleTool turn's content serializes correctly for OpenAI's tool message. +// messageText 拼接消息中带文本的片段(system 提示词 +// 与工具结果正文)。工具结果片段把正文放在 Text 里, +// 因此 RoleTool 回合的内容能正确序列化为 OpenAI 的 tool message。 func messageText(m Message) string { var b strings.Builder for _, part := range m.Content { @@ -48,23 +48,23 @@ func messageText(m Message) string { return b.String() } -// anthropicRole maps a normalized Role to the Anthropic role vocabulary. system -// messages are extracted to the top-level system field and never reach here. +// anthropicRole 把归一化的 Role 映射到 Anthropic 的角色词汇。 +// system 消息已被抽取到顶层 system 字段,不会走到这里。 func anthropicRole(r Role) string { switch r { case RoleAssistant: return "assistant" default: - // user, tool and any future roles all land on the user turn; tool_result - // blocks (M2) belong to the user role in Anthropic. + // user、tool 以及未来任何角色都落在 user 回合上; + // tool_result 块(M2)在 Anthropic 里属于 user 角色。 return "user" } } -// encodeAnthropicMessage turns a normalized message into Anthropic content -// blocks. A RoleTool turn is ALWAYS rendered as native tool_result blocks (never -// a user plain-text turn), because Anthropic requires a tool result to arrive as -// a tool_result block carrying the tool_use_id and the is_error flag. +// encodeAnthropicMessage 把归一化消息转成 Anthropic 内容块。 +// RoleTool 回合永远渲染为原生 tool_result 块 +// (绝不是 user 纯文本回合),因为 Anthropic 要求工具结果以 +// 携带 tool_use_id 与 is_error 标志的 tool_result 块形式到达。 func encodeAnthropicMessage(m Message) ([]any, error) { if m.Role == RoleTool { return encodeAnthropicToolResult(m) @@ -72,11 +72,11 @@ func encodeAnthropicMessage(m Message) ([]any, error) { return encodeAnthropicContent(m.Content) } -// encodeAnthropicToolResult renders a RoleTool turn as tool_result blocks. Each -// ContentTypeToolResult part becomes one block; legacy text parts are wrapped as -// tool_result blocks too so the turn never degrades into a plain user message. -// The tool_use_id falls back to the message-level ToolCallID when a part does not -// carry its own. +// encodeAnthropicToolResult 把 RoleTool 回合渲染为 tool_result 块。 +// 每个 ContentTypeToolResult 片段变成一个块;遗留的文本片段也会 +// 被包成 tool_result 块,使该回合永远不会退化成普通 user 消息。 +// 当片段自身没有携带 tool_use_id 时,回退到消息级的 +// ToolCallID。 func encodeAnthropicToolResult(m Message) ([]any, error) { blocks := make([]any, 0, len(m.Content)) for _, p := range m.Content { @@ -95,9 +95,9 @@ func encodeAnthropicToolResult(m Message) ([]any, error) { return blocks, nil } -// encodeAnthropicContent turns normalized content parts into Anthropic content -// blocks. Text / image / tool_use (assistant) parts are handled here; tool_result -// parts are rendered at the message level by encodeAnthropicToolResult. +// encodeAnthropicContent 把归一化内容片段转成 Anthropic 内容块。 +// 文本 / 图片 / tool_use(助手)片段在此处理;tool_result +// 片段由 encodeAnthropicToolResult 在消息级渲染。 func encodeAnthropicContent(parts []Content) ([]any, error) { blocks := make([]any, 0, len(parts)) for _, p := range parts { @@ -137,9 +137,9 @@ func encodeAnthropicContent(parts []Content) ([]any, error) { return blocks, nil } -// parseAnthropicImageData extracts the media type and base64 payload from a data: -// URI. Anthropic requires a base64 image source and cannot reference a remote -// URL, so any other URI form is rejected as a bad request. +// parseAnthropicImageData 从 data: URI 中提取媒体类型与 base64 +// 负载。Anthropic 要求 base64 图片源、无法引用远程 +// URL,因此其它任何 URI 形式都按错误请求拒绝。 func parseAnthropicImageData(uri string) (string, string, error) { if !strings.HasPrefix(uri, anthropicDataPrefix) { return "", "", fmt.Errorf("model: %w: anthropic image requires a data: URI, got %q", ErrBadRequest, uri) @@ -161,7 +161,7 @@ func parseAnthropicImageData(uri string) (string, string, error) { return mediaType, data, nil } -// buildAnthropicTools converts ToolSpec schemas into Anthropic tools. +// buildAnthropicTools 把 ToolSpec schema 转换为 Anthropic 工具。 func buildAnthropicTools(tools []ToolSpec) []anthropicTool { if len(tools) == 0 { return nil diff --git a/internal/runtime/model/anthropic_reasoning.go b/internal/runtime/model/anthropic_reasoning.go index 0c63f85..b8065b7 100644 --- a/internal/runtime/model/anthropic_reasoning.go +++ b/internal/runtime/model/anthropic_reasoning.go @@ -5,27 +5,27 @@ import ( "strings" ) -// Anthropic extended-thinking protocol bounds (design/04 §3.2). +// Anthropic 扩展思考协议的边界(design/04 §3.2)。 const ( - // anthropicMinThinkingBudget is the smallest budget_tokens the Messages API - // accepts for thinking.type=enabled; anything below it is rejected with a - // 400, so a lower non-zero budget is raised and reported rather than sent. + // anthropicMinThinkingBudget 是 Messages API 在 thinking.type=enabled 时 + // 接受的最小 budget_tokens;低于它会被 400 拒绝, + // 因此更低但非零的预算会被抬高并上报,而不是照发。 anthropicMinThinkingBudget = 1024 - // anthropicDefaultThinkingBudget is used when thinking is switched on without - // an explicit budget. + // anthropicDefaultThinkingBudget 在开启思考但未给 + // 显式预算时使用。 anthropicDefaultThinkingBudget = 4096 - // anthropicThinkingBodyReserve is the body room kept on top of the thinking - // budget: max_tokens is shared by thinking and the visible answer, so the - // budget must never be allowed to consume it all (design/04 §3.2 red line: + // anthropicThinkingBodyReserve 是在思考预算之上留给正文的空间: + // max_tokens 由思考与可见答案共享,所以 + // 预算绝不允许把它全部吃光(design/04 §3.2 红线: // "预算与正文不得互相吃光"). anthropicThinkingBodyReserve = 2048 ) -// deepSeekAnthropicEndpoint reports whether an Anthropic-protocol request is -// really served by DeepSeek's /anthropic endpoint. The base URL is checked first -// because the endpoint — not the model name — decides whether budget_tokens is -// honoured, and a DeepSeek endpoint is commonly configured with a non-DeepSeek -// model name (the service silently maps unknown names onto deepseek-flash). +// deepSeekAnthropicEndpoint 报告一次 Anthropic 协议请求是否 +// 真的由 DeepSeek 的 /anthropic 端点服务。先检查 base URL, +// 因为是端点——而不是模型名——决定 budget_tokens 是否 +// 被尊重,而 DeepSeek 端点常常配着一个非 DeepSeek 的 +// 模型名(该服务会把未知名称静默映射到 deepseek-flash)。 func deepSeekAnthropicEndpoint(model, baseURL string) bool { if strings.Contains(strings.ToLower(baseURL), "deepseek") { return true @@ -33,16 +33,16 @@ func deepSeekAnthropicEndpoint(model, baseURL string) bool { return reasoningVendorFor(model, baseURL) == vendorDeepSeek } -// anthropicThinkingFor resolves the extended-thinking directive together with the -// max_tokens it requires. It returns the (possibly raised) max_tokens and the -// wire block; a nil block means "do not send thinking" (no directive, or an -// explicit off). Because max_tokens is shared by thinking and the visible answer, -// the returned budget always leaves anthropicThinkingBodyReserve for the body, -// and a request whose budget would eat the answer raises max_tokens instead — the -// adjustment is logged so it is never a silent rewrite. +// anthropicThinkingFor 解出扩展思考指令以及它所需的 +// max_tokens。它返回(可能已被抬高的)max_tokens 与 +// 线格式块;块为 nil 表示「不发送思考」(无指令,或 +// 显式关闭)。由于 max_tokens 由思考与可见答案共享, +// 返回的预算总会为正文留下 anthropicThinkingBodyReserve, +// 而预算会吃光答案的请求则改为抬高 max_tokens——该 +// 调整会被记录,因此绝不是一次静默改写。 // -// This is the Anthropic endpoint's rule only. A DeepSeek /anthropic endpoint -// ignores budget_tokens, so it must not go through here (design/04 §3.2 fact 6). +// 这条规则只适用于 Anthropic 端点。DeepSeek 的 /anthropic 端点 +// 会忽略 budget_tokens,所以它绝不能走这里(design/04 §3.2 事实 6)。 func anthropicThinkingFor(th *Thinking, maxTokens int, log *slog.Logger) (int, *anthropicThinking) { if th == nil || (th.Enable != nil && !*th.Enable) { return maxTokens, nil @@ -67,12 +67,12 @@ func anthropicThinkingFor(th *Thinking, maxTokens int, log *slog.Logger) (int, * return maxTokens, &anthropicThinking{Type: anthropicThinkingTypeEnabled, BudgetTokens: budget} } -// anthropicDeepSeekReasoning resolves the reasoning knobs for a DeepSeek -// /anthropic request. Two differences from the Anthropic endpoint drive it: that -// endpoint ignores budget_tokens, so max_tokens is left untouched (raising it -// would misreport the budget the provider actually applies), and strength is -// controlled by output_config.effort, which has no "none" value — there the "off" -// intent is carried by the thinking switch itself (design/04 §3.2 facts 2 and 6). +// anthropicDeepSeekReasoning 解出 DeepSeek /anthropic 请求的 +// 推理旋钮。与 Anthropic 端点的两点差异决定了它:该 +// 端点忽略 budget_tokens,所以 max_tokens 保持不动(抬高它 +// 会错误反映提供商实际采用的预算),而强度由 +// output_config.effort 控制,它没有 "none" 取值——在那里「关闭」 +// 意图由 thinking 开关自身承载(design/04 §3.2 事实 2 与 6)。 func anthropicDeepSeekReasoning(log *slog.Logger, model string, th *Thinking, effort string) (*anthropicThinking, *anthropicOutputConfig) { var block *anthropicThinking if th != nil { @@ -118,9 +118,9 @@ func anthropicDeepSeekReasoning(log *slog.Logger, model string, th *Thinking, ef return block, &anthropicOutputConfig{Effort: tier} } -// anthropicThinkingOn reports the effective thinking state of a resolved DeepSeek -// /anthropic request: an omitted switch leaves the provider default in place, and -// DeepSeek's default is enabled. +// anthropicThinkingOn 报告一个已解出的 DeepSeek /anthropic 请求的 +// 实际思考状态:省略开关会保留提供商默认值,而 +// DeepSeek 的默认是开启。 func anthropicThinkingOn(th *anthropicThinking) bool { return th == nil || th.Type != thinkingTypeDisabled } diff --git a/internal/runtime/model/anthropic_stream.go b/internal/runtime/model/anthropic_stream.go index 0aa8ae2..e2b316e 100644 --- a/internal/runtime/model/anthropic_stream.go +++ b/internal/runtime/model/anthropic_stream.go @@ -8,9 +8,9 @@ import ( "strings" ) -// anthropicStreamEvent is one SSE payload from /v1/messages with stream: true. -// Anthropic carries the event kind both in the SSE `event:` field and inside the -// JSON `type` field, so the parser keys off the JSON payload type. +// anthropicStreamEvent 是 /v1/messages(stream: true)的一个 SSE 负载。 +// Anthropic 既在 SSE 的 `event:` 字段里、也在 JSON 的 `type` 字段里 +// 携带事件种类,所以解析器按 JSON 负载的 type 取值。 type anthropicStreamEvent struct { Type string `json:"type"` Message *anthropicStartBlock `json:"message,omitempty"` @@ -21,30 +21,30 @@ type anthropicStreamEvent struct { Error *anthropicStreamError `json:"error,omitempty"` } -// anthropicStartBlock is the payload of a message_start event. +// anthropicStartBlock 是 message_start 事件的负载。 type anthropicStartBlock struct { Model string `json:"model"` Usage *anthropicUsage `json:"usage,omitempty"` } -// anthropicDelta is the incremental payload inside a content_block_delta event. -// The message_delta event reuses the same field name for its terminal -// stop_reason (end_turn / max_tokens / tool_use), so it is decoded here too. +// anthropicDelta 是 content_block_delta 事件里的增量负载。 +// Anthropic 把 message_delta 事件的终止 stop_reason 复用了同一字段名 +// (end_turn / max_tokens / tool_use),所以这里也一并解码。 type anthropicDelta struct { - Type string `json:"type"` // text_delta | input_json_delta | thinking_delta + Type string `json:"type"` // 取值:text_delta | input_json_delta | thinking_delta Text string `json:"text,omitempty"` PartialJSON string `json:"partial_json,omitempty"` Thinking string `json:"thinking,omitempty"` StopReason string `json:"stop_reason,omitempty"` } -// anthropicStreamError is the payload of an error event. +// anthropicStreamError 是 error 事件的负载。 type anthropicStreamError struct { Type string `json:"type"` Message string `json:"message"` } -// streamBlock tracks the partial state of one content block during streaming. +// streamBlock 跟踪流式过程中单个内容块的部分状态。 type streamBlock struct { index int typ string @@ -55,13 +55,13 @@ type streamBlock struct { initialArgs map[string]any } -// scanAnthropicSSE incrementally decodes Anthropic's content-block stream. Text -// deltas are forwarded as text_delta events (segment bracketed by -// text_start/text_end); tool_use input_json_delta fragments are forwarded as -// tool_call_delta events (segment bracketed by tool_call_start/tool_call_end) -// and accumulated per block so they re-assemble into a structured Args map when -// the block stops. The stream must terminate with a message_stop event, -// otherwise ErrStream is returned. +// scanAnthropicSSE 增量解码 Anthropic 的内容块流。文本 +// 增量以 text_delta 事件转发(片段由 +// text_start/text_end 包起来);tool_use 的 input_json_delta 片段以 +// tool_call_delta 事件转发(片段由 tool_call_start/tool_call_end 包起来), +// 并按块累积,以便块结束时重新拼装成结构化的 Args map。 +// 流必须以 message_stop 事件终止, +// 否则返回 ErrStream。 func scanAnthropicSSE(r io.Reader, onEvent func(StreamEvent)) (Response, error) { scanner := bufio.NewScanner(r) scanner.Buffer(make([]byte, 0, sseBufInit), maxSSELineBytes) @@ -101,7 +101,7 @@ func scanAnthropicSSE(r io.Reader, onEvent func(StreamEvent)) (Response, error) return agg, nil } -// applyAnthropicEvent folds a single stream event into the running aggregation. +// applyAnthropicEvent 把单个流事件折叠进正在运行的聚合状态。 func applyAnthropicEvent( agg *Response, blocks map[int]*streamBlock, @@ -218,8 +218,8 @@ func applyAnthropicEvent( return nil } -// finalizeAnthropicStream assembles the aggregated Response from the accumulated -// blocks: concatenated text plus any tool_use ToolCalls derived per block. +// finalizeAnthropicStream 由累积的内容块拼装出聚合后的 Response: +// 拼接的文本,加上按块导出的 tool_use ToolCall。 func finalizeAnthropicStream(agg *Response, blocks map[int]*streamBlock, order []int, content string) { agg.Content = content agg.Message = Message{Role: RoleAssistant, Content: []Content{{Type: ContentTypeText, Text: content}}} diff --git a/internal/runtime/model/anthropic_test.go b/internal/runtime/model/anthropic_test.go index c87f5ab..3881786 100644 --- a/internal/runtime/model/anthropic_test.go +++ b/internal/runtime/model/anthropic_test.go @@ -14,7 +14,7 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/config" ) -// anthropicCaptured records what an AnthropicChatAdapter sent to a stub server. +// anthropicCaptured 记录 AnthropicChatAdapter 发往 stub 服务器的内容。 type anthropicCaptured struct { method string path string @@ -510,8 +510,8 @@ func TestAnthropicChat_ToolResult(t *testing.T) { } } -// anthropicBlockContent asserts a message renders a single Anthropic content block -// and returns it as a map for inspection. +// anthropicBlockContent 断言一条消息渲染出单个 Anthropic 内容块, +// 并把它作为 map 返回以供检查。 func anthropicBlockContent(t *testing.T, msg anthropicMessage) (map[string]any, bool) { t.Helper() blocks, ok := msg.Content.([]any) @@ -540,7 +540,7 @@ func TestAnthropicChat_Build_ToolResultBlock(t *testing.T) { t.Fatalf("messages = %d, want 3", len(out)) } - // The assistant turn must carry the native tool_use block, not plain text. + // 助手回合必须携带原生 tool_use 块,而不是纯文本。 if out[1].Role != "assistant" { t.Errorf("assistant role = %q, want assistant", out[1].Role) } @@ -555,8 +555,8 @@ func TestAnthropicChat_Build_ToolResultBlock(t *testing.T) { t.Errorf("tool_use input = %#v, want {city:Paris}", use["input"]) } - // The tool-result turn must be a user turn with a native tool_result block - // (the audit's core fix: never a user plain-text turn). + // 工具结果回合必须是带原生 tool_result 块的 user + // 回合(审计的核心修复:绝不是 user 纯文本回合)。 if out[2].Role != "user" { t.Errorf("tool-result role = %q, want user", out[2].Role) } @@ -580,9 +580,9 @@ func TestAnthropicChat_Build_ToolResultBlock(t *testing.T) { func TestAnthropicChat_Build_ToolResultLegacyText(t *testing.T) { t.Parallel() - // The loop's current form: a RoleTool message carrying a plain text part and - // Message.ToolCallID. The protocol layer must still render a native tool_result - // block (not a user plain-text turn). + // loop 当前的形态:一个携带纯文本片段与 + // Message.ToolCallID 的 RoleTool 消息。协议层仍必须渲染原生 + // tool_result 块(而不是 user 纯文本回合)。 m := Message{Role: RoleTool, ToolCallID: "toolu_9", Content: []Content{{Type: ContentTypeText, Text: "tool weather result: 22"}}} _, out, err := buildAnthropicMessages([]Message{m}) diff --git a/internal/runtime/model/budget_test.go b/internal/runtime/model/budget_test.go index 4965075..635a88f 100644 --- a/internal/runtime/model/budget_test.go +++ b/internal/runtime/model/budget_test.go @@ -13,8 +13,8 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" ) -// newFallbackDispatchServer serves two chain steps from one endpoint: the -// "main" model fails so the chain moves on, every other model succeeds. +// newFallbackDispatchServer 由一个端点服务两个链步骤: +// "main" 模型失败以便链继续前进,其它所有模型成功。 func newFallbackDispatchServer(t *testing.T, ch chan<- capturedRequest) *httptest.Server { t.Helper() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -42,8 +42,8 @@ func chatOnce(t *testing.T, chain *Chain) { } } -// TestChain_Chat_GroupBudgetAppliesToPrimary asserts the active group's own -// maxTokens is applied to a request that carries no explicit budget. +// TestChain_Chat_GroupBudgetAppliesToPrimary 断言生效组自己的 +// maxTokens 会作用于未携带显式预算的请求。 func TestChain_Chat_GroupBudgetAppliesToPrimary(t *testing.T) { t.Parallel() ch := make(chan capturedRequest, 1) @@ -57,8 +57,8 @@ func TestChain_Chat_GroupBudgetAppliesToPrimary(t *testing.T) { } } -// TestChain_Chat_ExplicitRequestWinsOverGroupBudget asserts an explicit -// Request.MaxTokens is never overridden (the small review/judge budgets). +// TestChain_Chat_ExplicitRequestWinsOverGroupBudget 断言显式的 +// Request.MaxTokens 绝不会被覆盖(用于小额的评审/裁判预算)。 func TestChain_Chat_ExplicitRequestWinsOverGroupBudget(t *testing.T) { t.Parallel() ch := make(chan capturedRequest, 1) @@ -75,8 +75,8 @@ func TestChain_Chat_ExplicitRequestWinsOverGroupBudget(t *testing.T) { } } -// TestChain_Chat_FallbackEndAppliesEnvBudget asserts the last-resort budget only -// reaches the final fallback step, never the primary. +// TestChain_Chat_FallbackEndAppliesEnvBudget 断言兜底预算只 +// 到达最后的降级步骤,绝不到达主步骤。 func TestChain_Chat_FallbackEndAppliesEnvBudget(t *testing.T) { t.Parallel() primaryCh := make(chan capturedRequest, 1) @@ -97,8 +97,8 @@ func TestChain_Chat_FallbackEndAppliesEnvBudget(t *testing.T) { } } -// TestChain_Chat_GroupBudgetWinsOverFallbackEnv asserts a group maxTokens beats -// the last-resort env budget even on the final fallback step. +// TestChain_Chat_GroupBudgetWinsOverFallbackEnv 断言组的 maxTokens 胜过 +// 兜底 env 预算,即便在最后的降级步骤上。 func TestChain_Chat_GroupBudgetWinsOverFallbackEnv(t *testing.T) { t.Parallel() primaryCh := make(chan capturedRequest, 1) @@ -116,9 +116,9 @@ func TestChain_Chat_GroupBudgetWinsOverFallbackEnv(t *testing.T) { } } -// TestChain_Chat_SingleStepIgnoresFallbackEnv asserts a chain with no fallback -// model (one step = the primary) never gets the last-resort budget, so the -// adapter default is preserved. +// TestChain_Chat_SingleStepIgnoresFallbackEnv 断言没有降级 +// 模型(单步 = 主模型)的链绝不会拿到兜底预算,因此 +// 适配器默认值得以保留。 func TestChain_Chat_SingleStepIgnoresFallbackEnv(t *testing.T) { t.Parallel() ch := make(chan capturedRequest, 1) @@ -132,8 +132,8 @@ func TestChain_Chat_SingleStepIgnoresFallbackEnv(t *testing.T) { } } -// TestRuntimeLLM_GroupMaxTokensEndToEnd proves the models.json maxTokens field -// reaches the provider request body through the real runtime stack. +// TestRuntimeLLM_GroupMaxTokensEndToEnd 证明 models.json 的 maxTokens 字段 +// 经真实运行时栈到达提供商请求体。 func TestRuntimeLLM_GroupMaxTokensEndToEnd(t *testing.T) { ch := make(chan capturedRequest, 1) srv := newCompletionServer(t, http.StatusOK, "application/json", okCompletion, ch) @@ -156,8 +156,8 @@ func TestRuntimeLLM_GroupMaxTokensEndToEnd(t *testing.T) { } } -// TestRuntimeLLM_FallbackEndAppliesEnvBudget proves LLM_FALLBACK_MAX_TOKENS is -// wired from config through buildSnapshot into the final fallback step. +// TestRuntimeLLM_FallbackEndAppliesEnvBudget 证明 LLM_FALLBACK_MAX_TOKENS +// 由配置经 buildSnapshot 接线到最后的降级步骤。 func TestRuntimeLLM_FallbackEndAppliesEnvBudget(t *testing.T) { ch := make(chan capturedRequest, 2) srv := newFallbackDispatchServer(t, ch) diff --git a/internal/runtime/model/deepseek_reasoning_test.go b/internal/runtime/model/deepseek_reasoning_test.go index 6fc6d46..59279a2 100644 --- a/internal/runtime/model/deepseek_reasoning_test.go +++ b/internal/runtime/model/deepseek_reasoning_test.go @@ -9,21 +9,21 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/config" ) -// newDeepSeekAdapter builds an OpenAI-compatible adapter pointed at a stub server -// but classified as DeepSeek by its model name, so the DeepSeek mapping is -// exercised against the real request body. +// newDeepSeekAdapter 构建一个 OpenAI 兼容适配器,指向 stub 服务器 +// 但按其模型名归类为 DeepSeek,从而让 DeepSeek 映射 +// 针对真实请求体被验证。 func newDeepSeekAdapter(t *testing.T, ch chan<- capturedRequest, opts ...OpenAIOption) *OpenAIChatAdapter { t.Helper() srv := newCompletionServer(t, http.StatusOK, "application/json", okCompletion, ch) return NewOpenAIChat(config.LLMConfig{BaseURL: srv.URL, Model: deepSeekModel}, opts...) } -// deepSeekModel is the current DeepSeek model name (the old deepseek-chat / -// deepseek-reasoner names were discontinued on 2026-07-24). +// deepSeekModel 是当前的 DeepSeek 模型名(旧的 deepseek-chat / +// deepseek-reasoner 名称已于 2026-07-24 停用)。 const deepSeekModel = "deepseek-flash" -// deepSeekThinkingType reads body.thinking.type, reporting whether the switch was -// sent at all. +// deepSeekThinkingType 读取 body.thinking.type,报告该开关是否 +// 被发出。 func deepSeekThinkingType(body map[string]any) (string, bool) { block, ok := body["thinking"].(map[string]any) if !ok { @@ -33,12 +33,12 @@ func deepSeekThinkingType(body map[string]any) (string, bool) { return value, true } -// TestOpenAIChat_DeepSeekReasoningEffortMatrix pins the official value set end to -// end at the request body: none/low/high/max pass through untouched and the four -// compatibility aliases are normalized to the tier DeepSeek applies -// (minimal→low, medium/xhigh→high, ultra→max). The old behavior — grading "none" -// down to "low" — turned thinking off into thinking on, so "none" must arrive as -// "none". +// TestOpenAIChat_DeepSeekReasoningEffortMatrix 钉住官方取值集合,端到 +// 端落到请求体上:none/low/high/max 原样通过,四个 +// 兼容别名被归一化为 DeepSeek 采用的档位 +// (minimal→low、medium/xhigh→high、ultra→max)。旧行为——把 "none" +// 降级到 "low"——把关闭思考变成了开启思考,所以 "none" 必须 +// 以 "none" 到达。 func TestOpenAIChat_DeepSeekReasoningEffortMatrix(t *testing.T) { t.Parallel() cases := []struct{ sent, want string }{ @@ -74,9 +74,9 @@ func TestOpenAIChat_DeepSeekReasoningEffortMatrix(t *testing.T) { } } -// TestOpenAIChat_DeepSeekReasoningEffortInvalidDropped asserts an unrecognized -// value is dropped and reported — never replaced by a nearby tier, which is how -// the original "round down" logic inverted the caller's intent. +// TestOpenAIChat_DeepSeekReasoningEffortInvalidDropped 断言无法识别的 +// 取值会被丢弃并上报——绝不会被替换成邻近档位,正是那套 +// 「向下取整」逻辑颠倒了调用方的意图。 func TestOpenAIChat_DeepSeekReasoningEffortInvalidDropped(t *testing.T) { t.Parallel() ch := make(chan capturedRequest, 1) @@ -105,10 +105,10 @@ func TestOpenAIChat_DeepSeekReasoningEffortInvalidDropped(t *testing.T) { } } -// TestOpenAIChat_DeepSeekThinkingSwitch asserts the DeepSeek thinking switch is -// really sent: an explicit off must reach the wire (DeepSeek defaults to enabled, -// so staying silent would leave thinking ON), and nil must not be turned into -// either value. +// TestOpenAIChat_DeepSeekThinkingSwitch 断言 DeepSeek 思考开关 +// 真的被发出:显式关闭必须到达线上(DeepSeek 默认开启, +// 所以保持沉默会让思考保持开启),而 nil 不得被变成 +// 任何一个取值。 func TestOpenAIChat_DeepSeekThinkingSwitch(t *testing.T) { t.Parallel() cases := []struct { @@ -147,9 +147,9 @@ func TestOpenAIChat_DeepSeekThinkingSwitch(t *testing.T) { } } -// TestOpenAIChat_DeepSeekEffortNoneAloneKeepsTheOfficialOffSwitch asserts the -// official reasoning_effort "none" path is used verbatim when no thinking switch -// was requested — that is the documented way to close thinking. +// TestOpenAIChat_DeepSeekEffortNoneAloneKeepsTheOfficialOffSwitch 断言在未 +// 请求 thinking 开关时,官方 reasoning_effort "none" 路径被原样 +// 使用——那是关闭思考的记录在案的做法。 func TestOpenAIChat_DeepSeekEffortNoneAloneKeepsTheOfficialOffSwitch(t *testing.T) { t.Parallel() ch := make(chan capturedRequest, 1) @@ -171,9 +171,9 @@ func TestOpenAIChat_DeepSeekEffortNoneAloneKeepsTheOfficialOffSwitch(t *testing. } } -// TestOpenAIChat_DeepSeekThinkingOffBeatsEffort is the conflict rule documented in -// resolveOpenAIReasoning: the provider states no precedence, so the off side wins -// and the dropped tier is reported — never silently applied. +// TestOpenAIChat_DeepSeekThinkingOffBeatsEffort 是 resolveOpenAIReasoning 里 +// 记录的冲突规则:提供商未说明优先级,所以关闭一侧获胜, +// 被丢弃的档位会被上报——绝不静默套用。 func TestOpenAIChat_DeepSeekThinkingOffBeatsEffort(t *testing.T) { t.Parallel() ch := make(chan capturedRequest, 1) @@ -200,9 +200,9 @@ func TestOpenAIChat_DeepSeekThinkingOffBeatsEffort(t *testing.T) { } } -// TestOpenAIChat_DeepSeekEffortNoneBeatsThinkingEnabled covers the opposite -// conflict: reasoning_effort "none" and thinking enabled cannot both be honoured, -// so the off side wins and the request carries exactly one switch. +// TestOpenAIChat_DeepSeekEffortNoneBeatsThinkingEnabled 覆盖相反的 +// 冲突:reasoning_effort "none" 与 thinking 开启无法同时被 +// 尊重,所以关闭一侧获胜,请求只携带一个开关。 func TestOpenAIChat_DeepSeekEffortNoneBeatsThinkingEnabled(t *testing.T) { t.Parallel() ch := make(chan capturedRequest, 1) @@ -229,9 +229,9 @@ func TestOpenAIChat_DeepSeekEffortNoneBeatsThinkingEnabled(t *testing.T) { } } -// TestOpenAIChat_DeepSeekThinkingBudgetReportedIgnored asserts a token budget -// (an Anthropic-style directive) is reported as unmappable on the DeepSeek -// chat-completions endpoint instead of opening or closing the switch by guess. +// TestOpenAIChat_DeepSeekThinkingBudgetReportedIgnored 断言 token 预算 +// (Anthropic 风格的指令)在 DeepSeek chat-completions 端点上 +// 会被上报为无法映射,而不是靠猜去开或关开关。 func TestOpenAIChat_DeepSeekThinkingBudgetReportedIgnored(t *testing.T) { t.Parallel() ch := make(chan capturedRequest, 1) @@ -257,10 +257,10 @@ func TestOpenAIChat_DeepSeekThinkingBudgetReportedIgnored(t *testing.T) { } } -// TestOpenAIChat_DeepSeekForcedToolChoiceWithThinkingWarns covers the DeepSeek -// 400 trap: a forcing tool_choice while thinking is on is rejected, and thinking -// is on by default. The adapter only warns — it must never close thinking by -// itself. +// TestOpenAIChat_DeepSeekForcedToolChoiceWithThinkingWarns 覆盖 DeepSeek +// 400 陷阱:思考开启时强制型 tool_choice 会被拒绝,而思考 +// 默认就是开启。适配器只警告——它绝不能自行 +// 关闭思考。 func TestOpenAIChat_DeepSeekForcedToolChoiceWithThinkingWarns(t *testing.T) { t.Parallel() cases := []struct { diff --git a/internal/runtime/model/factory.go b/internal/runtime/model/factory.go index 02ca829..57b300c 100644 --- a/internal/runtime/model/factory.go +++ b/internal/runtime/model/factory.go @@ -9,23 +9,23 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/config" ) -// Provider names accepted by the provider-aware factory. These are the only two -// protocols the factory understands today; adding a vendor means adding a case -// here plus a matching adapter, not touching the rest of the package. +// 提供商感知工厂接受的提供商名。这是工厂目前理解的仅有的 +// 两种协议;新增厂商意味着在这里加一个 case +// 再加一个配套适配器,而不必改动包内其它部分。 const ( - // ProviderOpenAI selects the OpenAI-compatible /chat/completions protocol. + // ProviderOpenAI 选择 OpenAI 兼容的 /chat/completions 协议。 ProviderOpenAI = "openai" - // ProviderAnthropic selects the Anthropic Messages /v1/messages protocol. + // ProviderAnthropic 选择 Anthropic Messages 的 /v1/messages 协议。 ProviderAnthropic = "anthropic" ) -// ErrUnknownProvider is returned by the provider-aware factory when the -// configured provider name is not recognized. +// 配置的提供商名无法识别时,由提供商感知工厂返回 +// ErrUnknownProvider。 var ErrUnknownProvider = errors.New("model: unknown provider") -// NewChatAdapter builds a single provider adapter selected by cfg.Provider. The -// empty provider defaults to openai for backward compatibility. An unrecognized -// provider returns ErrUnknownProvider. +// NewChatAdapter 按 cfg.Provider 构建单个提供商适配器。 +// 提供商为空时为向后兼容默认用 openai。无法识别的 +// 提供商返回 ErrUnknownProvider。 func NewChatAdapter(cfg config.LLMConfig) (Adapter, error) { switch cfg.Provider { case ProviderOpenAI, "": @@ -37,25 +37,25 @@ func NewChatAdapter(cfg config.LLMConfig) (Adapter, error) { } } -// NewChatChain builds a fallback Chain for the configured provider: the primary -// model first, then the provider's fallback model when set and distinct. Each -// step is served by an adapter of the selected protocol. The empty provider -// defaults to openai. An unrecognized provider returns ErrUnknownProvider. +// NewChatChain 为配置的提供商构建降级 Chain:先是主 +// 模型,再是提供商已设置且不同的降级模型。每一步 +// 都由所选协议的适配器服务。提供商为空 +// 时默认用 openai。无法识别的提供商返回 ErrUnknownProvider。 func NewChatChain(cfg config.LLMConfig) (*Chain, error) { return NewChatChainWithLog(cfg, nil) } -// NewChatChainWithLog is NewChatChain with a logger attached to every adapter it -// builds, so a provider-level reasoning decision (a thinking budget raised to fit -// max_tokens, a reasoning_effort value downgraded or ignored) is reported with -// its module source. A nil logger leaves the adapters' discard default in place. +// NewChatChainWithLog 是 NewChatChain,并为它构建的每个适配器 +// 挂上日志器,好让提供商层面的推理决策(为容纳 +// max_tokens 而抬高的思考预算、被降级或忽略的 reasoning_effort 值) +// 带着模块来源上报。nil 日志器则保留适配器的丢弃式默认值。 // -// It is also the only builder that appends the optional local third rung -// (裁决 03.4.3, primary → fallback → local), because that rung may speak a -// different protocol than the active group and therefore needs the raw logger to -// build its own adapter. It is appended only when the local rung is enabled AND -// fully configured; otherwise the chain keeps its two-level shape and the reason -// is reported. +// 它也是唯一会追加可选本地第三级(local rung)的构建器 +// (裁决 03.4.3,primary → fallback → local),因为那一级可能说 +// 与生效组不同的协议,因此需要原始日志器来 +// 构建自己的适配器。只有当本地级已启用且完全 +// 配置好时才会追加;否则链保持两级形状,并 +// 上报原因。 func NewChatChainWithLog(cfg config.LLMConfig, log *slog.Logger) (*Chain, error) { var chain *Chain var err error @@ -76,16 +76,16 @@ func NewChatChainWithLog(cfg config.LLMConfig, log *slog.Logger) (*Chain, error) return chain, nil } -// localChainStep builds the local third degradation rung as a chain step. It -// returns ok=false (and, when the rung was asked for but is incomplete, a WARN -// naming the missing field) when the rung is off, unconfigured or its provider is -// unknown — a half-configured step would only ever fail, which is worse than not -// having it. +// localChainStep 把本地第三级降级构造成一个链步骤。 +// 当该级关闭、未配置或其提供商未知时,返回 ok=false +// (若该级被要求但配置不全,则附一条点出缺失字段的 WARN) +// ——一个半配置的步骤只会失败,那还不如没有 +// 它。 func localChainStep(cfg config.LLMConfig, log *slog.Logger) (Step, bool) { local := cfg.Local - // Configured() already requires Enabled, so the rung is off by default and a - // disabled rung can never become a chain step. The warning is emitted only - // when the rung was actually asked for but is incomplete. + // Configured() 本身已要求 Enabled,所以该级默认关闭, + // 被禁用的级永远不会成为链步骤。只有当该级确实被 + // 要求、但配置不全时才发出该警告。 if !local.Configured() { if local.Enabled { warn(log, "local model rung enabled but not configured; skipping it", @@ -112,8 +112,8 @@ func localChainStep(cfg config.LLMConfig, log *slog.Logger) (Step, bool) { } } -// newOpenAIChain builds a fallback chain from the OpenAI-compatible config block. -// Each step is an OpenAIChatAdapter against the configured endpoint. +// newOpenAIChain 由 OpenAI 兼容配置块构建降级链。 +// 每一步都是针对该端点的 OpenAIChatAdapter。 func newOpenAIChain(cfg config.LLMConfig, opts ...OpenAIOption) (*Chain, error) { if cfg.BaseURL == "" || cfg.Model == "" { return nil, ErrNoModel @@ -125,8 +125,8 @@ func newOpenAIChain(cfg config.LLMConfig, opts ...OpenAIOption) (*Chain, error) return NewChain(steps...), nil } -// newAnthropicChain builds a fallback chain from the Anthropic config block. Each -// step is an AnthropicChatAdapter against the configured /v1/messages endpoint. +// newAnthropicChain 由 Anthropic 配置块构建降级链。每一步 +// 都是针对该 /v1/messages 端点的 AnthropicChatAdapter。 func newAnthropicChain(cfg config.LLMConfig, opts ...AnthropicOption) (*Chain, error) { if cfg.AnthropicBaseURL == "" || cfg.AnthropicModel == "" { return nil, ErrNoModel @@ -138,9 +138,9 @@ func newAnthropicChain(cfg config.LLMConfig, opts ...AnthropicOption) (*Chain, e return NewChain(steps...), nil } -// DefaultModelForProvider returns the primary model name for the configured -// provider: the flat OpenAI Model for "openai", the Anthropic model for -// "anthropic". Used by callers (e.g. cmd/cyrene) to seed a Router's default. +// DefaultModelForProvider 返回配置提供商的主模型名: +// "openai" 取扁平配置里的 OpenAI Model,"anthropic" 取 Anthropic 模型。 +// 调用方(如 cmd/cyrene)用它播种 Router 的默认值。 func DefaultModelForProvider(cfg config.LLMConfig) string { if cfg.Provider == ProviderAnthropic { return cfg.AnthropicModel @@ -148,10 +148,10 @@ func DefaultModelForProvider(cfg config.LLMConfig) string { return cfg.Model } -// warn reports through the injected logger, falling back to a discard logger that -// still carries the module source so an unsourced record can never be emitted -// (project rule §10). It exists because a chain may be built without a logger -// (NewChatChain) while a skipped rung must still be reported. +// warn 通过注入的日志器上报,降级到一个仍带有模块来源的丢弃式 +// 日志器,因此绝不会产生无来源日志 +// (项目铁律 §10)。它之所以存在,是因为链可能在没有日志器 +// 的情况下构建(NewChatChain),而被跳过的级仍须上报。 func warn(log *slog.Logger, msg string, args ...any) { if log == nil { log = discardLogger("model") diff --git a/internal/runtime/model/factory_test.go b/internal/runtime/model/factory_test.go index 2705f52..7776e48 100644 --- a/internal/runtime/model/factory_test.go +++ b/internal/runtime/model/factory_test.go @@ -163,9 +163,9 @@ func TestRouter_RouteProvider(t *testing.T) { } } -// TestChain_AnthropicFallback exercises the fallback chain against two Anthropic -// adapters: the primary step fails and the backup step succeeds, proving the -// adapter composes cleanly inside a Chain. +// TestChain_AnthropicFallback 用两个 Anthropic 适配器验证 +// 降级链:主步骤失败、备用步骤成功,证明该 +// 适配器能在 Chain 内干净地组合。 func TestChain_AnthropicFallback(t *testing.T) { t.Parallel() primary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { diff --git a/internal/runtime/model/fallback.go b/internal/runtime/model/fallback.go index 2a867f2..c43a779 100644 --- a/internal/runtime/model/fallback.go +++ b/internal/runtime/model/fallback.go @@ -7,48 +7,48 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/config" ) -// Step binds a model name to an Adapter for one entry in a fallback chain. An -// empty Model keeps the incoming Request.Model. +// Step 把模型名与 Adapter 绑定成降级链里的一项。 +// Model 为空时沿用传入 Request.Model。 type Step struct { Model string Adapter Adapter } -// Chain is an Adapter that tries its steps in order and returns the first -// successful response. It implements Adapter, so it can be used anywhere a -// single provider is expected. +// Chain 是一个按顺序尝试各步骤、返回第一个 +// 成功响应的 Adapter。它实现了 Adapter,因此任何期望单个 +// 提供商的地方都能使用它。 type Chain struct { steps []Step - // groupMaxTokens is the active model group's own budget (models.json - // maxTokens). When > 0 it applies to every step of the group's chain and - // wins over fallbackMaxTokens. + // groupMaxTokens 是生效模型组自己的预算(models.json + // maxTokens)。大于 0 时作用于该组链的每一步, + // 并优先于 fallbackMaxTokens。 groupMaxTokens int - // fallbackMaxTokens is the last-resort budget (LLM_FALLBACK_MAX_TOKENS). It - // only applies to the final step of a chain that actually has a fallback. + // fallbackMaxTokens 是最后的兜底预算(LLM_FALLBACK_MAX_TOKENS)。 + // 它只作用于确实有降级步骤的链的最后一个步骤。 fallbackMaxTokens int } -// NewChain builds a fallback chain from the given steps. +// NewChain 由给定步骤构建降级链。 func NewChain(steps ...Step) *Chain { return &Chain{steps: steps} } -// WithTokenBudget sets the chain's token budgets: the active group's own budget -// and the last-resort fallback budget. It must be called before the chain is -// published to concurrent callers (the runtime builds a fresh chain per -// snapshot and swaps it in atomically), so the fields are never mutated in -// flight. +// WithTokenBudget 设置链的 token 预算:生效组自己的预算 +// 与最后的兜底预算。必须在链被发布给并发调用方之前调用 +// (运行时为每个快照构建一条新链并原子换入), +// 快照并原子换入),所以这些字段绝不会在运行中 +// 被改写。 func (c *Chain) WithTokenBudget(groupMaxTokens, fallbackMaxTokens int) *Chain { c.groupMaxTokens = groupMaxTokens c.fallbackMaxTokens = fallbackMaxTokens return c } -// stepModel picks the model name for step idx of the chain. The primary step -// prefers the request's routed model — the per-persona / per-request decision the -// Router produced — over the group's configured model, so a routing decision is -// never silently discarded. Every fallback step pins its own configured model, so -// a degradation never re-sends the primary model name to a different endpoint +// stepModel 选出链上第 idx 步的模型名。主步骤 +// 优先用请求中被路由的模型——即 Router 产出的按角色/按请求 +// 决策——而不是组里配置的模型,这样一个路由决策 +// 绝不会被静默丢弃。每个降级步骤都钉住自己配置的模型, +// 因此一次降级绝不会把主模型名重发给另一个端点。 // (裁决 03.4.1: per-persona 路由必须真的落到请求上). func stepModel(req Request, idx int, configured string) string { if idx == 0 && req.Model != "" { @@ -57,12 +57,12 @@ func stepModel(req Request, idx int, configured string) string { return configured } -// applyBudget resolves the effective MaxTokens for step idx, honoring the -// precedence: an explicit Request.MaxTokens > the group's MaxTokens > the -// last-resort LLM_FALLBACK_MAX_TOKENS > the adapter default. The last-resort -// budget only applies when the chain really has a fallback step (len > 1) and -// only to that final step; a single-step chain is a primary model, not a -// fallback, so its adapter default is left untouched. +// applyBudget 解出第 idx 步实际生效的 MaxTokens,遵循 +// 优先级:显式的 Request.MaxTokens > 组自己的 MaxTokens > +// 兜底的 LLM_FALLBACK_MAX_TOKENS > 适配器默认值。兜底 +// 预算只在链确实有降级步骤(len > 1)时生效, +// 且只作用于最后那一步;单步链就是主模型、并非 +// 降级,所以它的适配器默认值保持不动。 func (c *Chain) applyBudget(req Request, idx int) Request { if req.MaxTokens != 0 { return req @@ -77,20 +77,20 @@ func (c *Chain) applyBudget(req Request, idx int) Request { return req } -// NewOpenAIChatChain builds a fallback chain from a single supplier config: the -// primary model first, then cfg.Fallback (when set and distinct), both served -// by an OpenAIChatAdapter against the same OpenAI-compatible endpoint. It -// delegates to the provider-aware builder so the OpenAI path and the factory stay -// in sync. It returns ErrNoModel when no base URL or primary model is configured. +// NewOpenAIChatChain 由单份供应商配置构建降级链:先 +// 主模型,再 cfg.Fallback(已设置且不同时),两者都由 +// 同一个 OpenAI 兼容端点上的 OpenAIChatAdapter 服务。它 +// 委托给提供商感知的构建器,使 OpenAI 路径与工厂保持 +// 一致。未配置 base URL 或主模型时返回 ErrNoModel。 func NewOpenAIChatChain(cfg config.LLMConfig, opts ...OpenAIOption) (*Chain, error) { return newOpenAIChain(cfg, opts...) } -// Name returns the chain identifier. +// Name 返回链标识。 func (c *Chain) Name() string { return "fallback" } -// Chat tries each step in order and returns the first success. If every step -// fails, the last error is returned wrapped as ErrUpstream. +// Chat 按顺序尝试每一步并返回第一个成功的结果。若每一步 +// 都失败,则把最后一个错误包成 ErrUpstream 返回。 func (c *Chain) Chat(ctx context.Context, req Request, opts Options) (Response, error) { if len(c.steps) == 0 { return Response{}, ErrNoSteps @@ -110,10 +110,10 @@ func (c *Chain) Chat(ctx context.Context, req Request, opts Options) (Response, return Response{}, fmt.Errorf("model: %w: %v", ErrUpstream, lastErr) } -// ChatStream tries each step in order. If a step fails before delivering any -// event, the chain moves to the next step; once a step has delivered output it -// is committed, so a mid-stream failure surfaces as an error rather than mixing -// content across providers. +// ChatStream 按顺序尝试每一步。若某步在交付任何事件之前 +// 就失败,链会移到下一步;一旦某步已交付输出, +// 它就被视为已提交,因此流中途的失败会作为错误浮出,而不会把 +// 不同提供商的内容混在一起。 func (c *Chain) ChatStream(ctx context.Context, req Request, onEvent func(StreamEvent)) (Response, error) { if len(c.steps) == 0 { return Response{}, ErrNoSteps diff --git a/internal/runtime/model/local_rung_test.go b/internal/runtime/model/local_rung_test.go index 7dd17d5..5a798fa 100644 --- a/internal/runtime/model/local_rung_test.go +++ b/internal/runtime/model/local_rung_test.go @@ -13,10 +13,10 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/config" ) -// recordingServer is an OpenAI-compatible /chat/completions endpoint that records -// the model name of every request and either fails or succeeds. It backs the -// local-rung tests (裁决 03.4.3): the point of the rung is that a request reaches -// the local endpoint with the local model name, and never when the rung is off. +// recordingServer 是一个 OpenAI 兼容的 /chat/completions 端点,记录 +// 每个请求的模型名,并或失败或成功。它支撑 +// 本地级测试(裁决 03.4.3):该级的意义在于请求以本地模型名 +// 到达本地端点,而该级关闭时绝不如此。 type recordingServer struct { mu sync.Mutex models []string @@ -51,9 +51,9 @@ func newRecordingServer(t *testing.T, fail bool) (*httptest.Server, *recordingSe return srv, rec } -// TestNewChatChainWithLog_LocalRungAppended verifies the third degradation level -// (primary → fallback → local) only joins the chain when it is enabled AND fully -// configured. +// TestNewChatChainWithLog_LocalRungAppended 验证第三级降级 +// (primary → fallback → local)只在启用且完全 +// 配置好时才加入链。 func TestNewChatChainWithLog_LocalRungAppended(t *testing.T) { t.Parallel() cases := []struct { @@ -88,9 +88,9 @@ func TestNewChatChainWithLog_LocalRungAppended(t *testing.T) { } } -// TestChain_Chat_LocalRungServesAfterFallback verifies the local rung really takes -// over the request after the primary and fallback steps fail, with the local model -// name on the wire and the local endpoint's own base URL/API key (not the group's). +// TestChain_Chat_LocalRungServesAfterFallback 验证本地级在主 +// 步骤与降级步骤都失败后真的接管请求,且线上用 +// 本地模型名与本地端点自己的 base URL/API key(而非组的)。 func TestChain_Chat_LocalRungServesAfterFallback(t *testing.T) { t.Parallel() mainSrv, _ := newRecordingServer(t, true) @@ -117,9 +117,9 @@ func TestChain_Chat_LocalRungServesAfterFallback(t *testing.T) { } } -// TestChain_Chat_LocalRungOffNeverCalled pins the default-off red line: with the -// rung disabled the local endpoint must never be touched, even when every other -// step fails. +// TestChain_Chat_LocalRungOffNeverCalled 钉住默认关闭的红线:该级 +// 禁用时绝不能触碰本地端点,即便其它所有 +// 步骤都失败。 func TestChain_Chat_LocalRungOffNeverCalled(t *testing.T) { t.Parallel() mainSrv, _ := newRecordingServer(t, true) @@ -141,14 +141,14 @@ func TestChain_Chat_LocalRungOffNeverCalled(t *testing.T) { } } -// TestChain_Chat_PrimaryStepKeepsRoutedModel pins that the chain does not discard -// the router's decision: the primary step sends the request's routed model, and -// the fallback step still pins its own configured model (裁决 03.4.1). +// TestChain_Chat_PrimaryStepKeepsRoutedModel 钉住链不会丢弃 +// 路由器的决策:主步骤发送请求中被路由的模型,而 +// 降级步骤仍钉住自己配置的模型(裁决 03.4.1)。 func TestChain_Chat_PrimaryStepKeepsRoutedModel(t *testing.T) { t.Parallel() - // One endpoint serves both steps (the factory builds every step against the - // group's base URL): it fails for anything but the fallback model, so the - // recorded model names show which model each step actually sent. + // 一个端点同时服务两个步骤(工厂让每一步都针对 + // 组的 base URL 构建):除降级模型外它都失败,所以 + // 记录下来的模型名显示了每一步实际发送了哪个模型。 rec := &recordingServer{} srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var body struct { diff --git a/internal/runtime/model/model.go b/internal/runtime/model/model.go index 4033e08..6d03ef3 100644 --- a/internal/runtime/model/model.go +++ b/internal/runtime/model/model.go @@ -1,102 +1,102 @@ -// Package model provides a vendor-agnostic LLM adapter layer for Cyrene v2. +// model 包为 Cyrene v2 提供与厂商无关的 LLM 适配层。 // -// It defines a normalized Message/Request/Response vocabulary, a single -// Adapter interface implemented by OpenAIChatAdapter (which speaks the -// OpenAI-compatible /chat/completions protocol used by OpenAI, DashScope/百炼, -// local gateways and proxies), a per-role Router, an automatic fallback Chain, -// multimodal (vision) detection, and per-call CallRecord accounting. +// 它定义了归一化的 Message/Request/Response 词汇、单个 +// 由 OpenAIChatAdapter 实现的 Adapter 接口(该适配器说 +// OpenAI、DashScope/百炼等使用的 OpenAI 兼容 /chat/completions 协议、 +// 本地网关与代理),还有按角色的 Router、自动降级 Chain、 +// 多模态(视觉)检测与按调用的 CallRecord 计账。 // -// The full tool loop (schema registration, argument binding, result re-feed) is -// deferred to M2; this package only carries interface-level placeholders -// (ToolSpec) so the wire contract is ready without executing any loop. +// 完整的工具循环(schema 注册、参数绑定、结果回喂) +// 推迟到 M2;本包只携带接口级占位 +// (ToolSpec),让线格式契约先就绪,而不执行任何循环。 package model import "time" -// Role identifies who authored a message. The values are vendor-agnostic and -// translated to each provider's own role vocabulary by the adapter. +// Role 标识消息的作者。取值与厂商无关, +// 由适配器翻译成各提供商自己的角色词汇。 type Role string const ( - // RoleSystem is a system/instruction message. + // RoleSystem 是系统/指令消息。 RoleSystem Role = "system" - // RoleUser is an end-user message. + // RoleUser 是终端用户消息。 RoleUser Role = "user" - // RoleAssistant is an assistant (model) message. + // RoleAssistant 是助手(模型)消息。 RoleAssistant Role = "assistant" - // RoleTool is a tool-result message (reserved for the M2 tool loop). + // RoleTool 是工具结果消息(保留给 M2 工具循环)。 RoleTool Role = "tool" ) -// ContentType describes the kind of payload inside a Content part. +// ContentType 描述 Content 片段内负载的种类。 type ContentType string const ( - // ContentTypeText is a plain-text content part. + // ContentTypeText 是纯文本内容片段。 ContentTypeText ContentType = "text" - // ContentTypeImage is an image content part. + // ContentTypeImage 是图片内容片段。 ContentTypeImage ContentType = "image_url" - // ContentTypeToolCall is an assistant-originated tool invocation part - // (Anthropic tool_use / OpenAI tool_calls), carried on a RoleAssistant turn. + // ContentTypeToolCall 是助手发起的工具调用片段 + // (Anthropic tool_use / OpenAI tool_calls),挂在 RoleAssistant 回合上。 ContentTypeToolCall ContentType = "tool_call" - // ContentTypeToolResult is a tool execution result part, carried on a RoleTool - // turn and encoded natively (Anthropic tool_result / OpenAI tool message). + // ContentTypeToolResult 是工具执行结果片段,挂在 RoleTool + // 回合上并按原生方式编码(Anthropic tool_result / OpenAI tool message)。 ContentTypeToolResult ContentType = "tool_result" ) -// Content is a single, possibly multimodal, part of a message. +// Content 是消息的单个片段,可能是多模态的一部分。 type Content struct { - // Type distinguishes a text part from an image / tool part. + // Type 区分文本片段与图片 / 工具片段。 Type ContentType - // Text is the text payload when Type == ContentTypeText, or the result body - // when Type == ContentTypeToolResult. + // Text 是 Type == ContentTypeText 时的文本负载, + // 或 Type == ContentTypeToolResult 时的结果正文。 Text string - // ImageURL is the image source when Type == ContentTypeImage. It may be a - // data: URI or an http(s):// URL. + // ImageURL 是 Type == ContentTypeImage 时的图片来源。它可以是 + // data: URI 或 http(s):// URL。 ImageURL string - // ToolCallID correlates the part to a provider tool_use/tool_call id: the call - // id for ContentTypeToolCall, or the tool_use_id being answered for - // ContentTypeToolResult. + // ToolCallID 把片段关联到提供商的 tool_use/tool_call id: + // ContentTypeToolCall 对应调用 id, + // ContentTypeToolResult 对应被应答的 tool_use_id。 ToolCallID string - // ToolCallName is the invoked tool name when Type == ContentTypeToolCall. + // ToolCallName 是 Type == ContentTypeToolCall 时被调用的工具名。 ToolCallName string - // ToolArgs is the structured argument object when Type == ContentTypeToolCall. - // It is never a JSON-encoded string. + // ToolArgs 是 Type == ContentTypeToolCall 时的结构化参数对象。 + // 它绝不是 JSON 编码的字符串。 ToolArgs map[string]any - // IsError marks a ContentTypeToolResult part as an execution failure so the - // provider's tool_result.is_error / error flag is set natively. + // IsError 把 ContentTypeToolResult 片段标记为执行失败, + // 以便原生设置提供商的 tool_result.is_error / error 标志。 IsError bool } -// Message is a single conversation turn, normalized across providers. +// Message 是单个会话回合,已跨提供商归一化。 type Message struct { Role Role - // Content carries one or more parts. A text-only message uses a single - // ContentTypeText part. + // Content 携带一个或多个片段。仅文本的消息使用单个 + // ContentTypeText 片段。 Content []Content - // SenderName is the human-readable display name of the message's sender - // (design/16 §4.6). It is transport metadata used by the context-assembly - // layer to label third-party messages for the model (e.g. "[第三方 · 昵称]"), - // never a substitute for the strongly typed sender identity carried on the - // channel layer. Empty means the sender is unlabeled (the owner, or an - // unknown/unresolved sender), and callers may leave it unset. + // SenderName 是消息发送者可读的显示名 + // (design/16 §4.6)。它是上下文组装层用来给模型 + // 标注第三方消息(如 "[第三方 · 昵称]")的传输元数据, + // 绝不是 channel 层强类型发送者身份的替代品。 + // 为空表示发送者未标注(主人,或未识别/未解析的发送者), + // 调用方可以留着不设置。 SenderName string - // ToolCallID correlates a RoleTool turn to the provider tool_use/tool_call id - // it answers, and ToolCallName is the invoked tool name carried as a - // convenience for callers that render the result. Both are set by the tool - // loop when it feeds a result back, and consumed by the adapters to emit - // native tool_result / tool-message blocks. + // ToolCallID 把 RoleTool 回合关联到它所应答的提供商 + // tool_use/tool_call id,而 ToolCallName 是同时携带的 + // 被调用工具名,方便渲染结果的调用方使用。两者都由工具 + // 循环在回喂结果时设置,并被适配器消费以发出 + // 原生 tool_result / tool-message 块。 ToolCallID string ToolCallName string } -// TextMessage builds a text-only message. +// TextMessage 构建仅文本的消息。 func TextMessage(role Role, text string) Message { return Message{Role: role, Content: []Content{{Type: ContentTypeText, Text: text}}} } -// ImageMessage builds a message carrying optional text plus an image. imageURL -// may be a data: URI or an http(s):// URL. An empty text omits the text part. +// ImageMessage 构建携带可选文本加一张图片的消息。imageURL +// 可以是 data: URI 或 http(s):// URL。文本为空则省略文本片段。 func ImageMessage(role Role, text, imageURL string) Message { parts := make([]Content, 0, 2) if text != "" { @@ -106,17 +106,17 @@ func ImageMessage(role Role, text, imageURL string) Message { return Message{Role: role, Content: parts} } -// ToolResultMessage builds a RoleTool turn carrying the native result of one tool -// call. toolUseID must equal the provider id of the tool_use block this result -// answers; isError marks an execution failure; text is the result body. +// ToolResultMessage 构建一个 RoleTool 回合,携带某次工具调用的 +// 原生结果。toolUseID 必须等于该结果所应答的 tool_use 块的 +// 提供商 id;isError 标记执行失败;text 是结果正文。 func ToolResultMessage(toolUseID, text string, isError bool) Message { return Message{Role: RoleTool, ToolCallID: toolUseID, Content: []Content{{Type: ContentTypeToolResult, Text: text, ToolCallID: toolUseID, IsError: isError}}} } -// AssistantToolCallMessage builds a RoleAssistant turn advertising the tool calls -// the model requested, so a subsequent RoleTool turn can be correlated to them by -// the provider (OpenAI tool_call_id / Anthropic tool_use_id). +// AssistantToolCallMessage 构建一个 RoleAssistant 回合,声明模型 +// 请求过的工具调用,好让随后一个 RoleTool 回合能被提供商 +// 关联回来(OpenAI tool_call_id / Anthropic tool_use_id)。 func AssistantToolCallMessage(calls []ToolCall) Message { parts := make([]Content, 0, len(calls)) for _, c := range calls { @@ -125,148 +125,148 @@ func AssistantToolCallMessage(calls []ToolCall) Message { return Message{Role: RoleAssistant, Content: parts} } -// ToolSpec is an interface-level placeholder for a tool/function schema. The -// full tool loop (registration, argument binding, result re-feed) is M2; here -// it only lets a request advertise tools to the provider in the wire contract. +// ToolSpec 是工具/函数 schema 的接口级占位。完整 +// 工具循环(注册、参数绑定、结果回喂)属于 M2;这里 +// 只让请求能在线格式契约里向提供商声明工具。 type ToolSpec struct { Name string Description string - // Parameters is the JSON Schema for the tool arguments. Populated from M2. + // Parameters 是工具参数的 JSON Schema。M2 起填入。 Parameters any } -// ToolCall is a single assistant-originated tool invocation parsed from a -// provider's tool_use content block. Args is the structured argument object -// (never a serialized JSON string), so a tool loop can bind it directly without -// re-parsing. +// ToolCall 是从提供商的 tool_use 内容块解析出的单个 +// 助手发起的工具调用。Args 是结构化参数对象 +// (绝不是序列化后的 JSON 字符串),因此工具循环可以直接绑定它, +// 无需重新解析。 type ToolCall struct { - // ID is the provider tool_use/tool_call id, so a tool loop can correlate the - // resulting RoleTool message to the exact call (Anthropic tool_use_id / OpenAI - // tool_call_id). Empty when the provider emitted no id. + // ID 是提供商的 tool_use/tool_call id,工具循环因此能把 + // 产生的 RoleTool 消息关联到确切的那次调用(Anthropic tool_use_id / + // OpenAI tool_call_id)。提供商未发出 id 时为空。 ID string - // Name is the tool being invoked. + // Name 是被调用的工具。 Name string - // Args are the tool arguments, decoded from the provider's structured - // tool_use.input object. It is never a JSON-encoded string. + // Args 是工具参数,由提供商的结构化 + // tool_use.input 对象解码而来。它绝不是 JSON 编码的字符串。 Args map[string]any } -// Request is a completion request, normalized across providers. +// Request 是一次补全请求,已跨提供商归一化。 type Request struct { - // Model overrides the adapter's configured default model. Empty means the - // adapter default (or the chain step model) applies. + // Model 覆盖适配器已配置的默认模型。为空表示 + // 使用适配器默认值(或链步骤模型)。 Model string Messages []Message - // Temperature is the sampling temperature. nil means the provider default. + // Temperature 是采样温度。nil 表示提供商默认值。 // - // DeepSeek notes (design/04 §3.2 facts 3 and 4): temperature takes no effect - // while thinking is on. frequency_penalty and presence_penalty are deprecated - // at the API level — "传入该参数将不会产生任何效果" — in every mode, which is - // why Request has no field for them. top_p is deliberately not exposed - // either: it only applies while thinking is on and is clamped to >= 0.95 - // there, is always 1.0 otherwise, so sending a configured value would be - // silently ineffective on one side of the switch and a hard floor on the - // other. + // DeepSeek 说明(design/04 §3.2 事实 3 与 4):思考开启期间 + // temperature 不生效。frequency_penalty 与 presence_penalty 已被弃用 + // ——「传入该参数将不会产生任何效果」——在任何模式下都是如此, + // 这就是 Request 没有对应字段的原因。top_p 同样被刻意不暴露: + // 它只在思考开启时生效,且在那里被夹到 >= 0.95, + // 其它情况下恒为 1.0,所以发送一个配置好的值 + // 会在开关的一侧静默失效, + // 在另一侧则成为硬性下限。 Temperature *float64 - // MaxTokens caps the generated tokens. Zero means the provider default. + // MaxTokens 限制生成的 token 数。为零表示提供商默认值。 MaxTokens int - // Purpose labels the call for CallRecord accounting (e.g. "chat"). + // Purpose 为该次调用打标签,用于 CallRecord 计账(如 "chat")。 Purpose string - // PersonaID is the persona this turn answers as — the persona-level routing - // key of 裁决 03.4.1, as opposed to the LLM message role. RuntimeLLM reads it - // to pick the persona's provider chain (models.json personaProviders, i.e. a - // cross-protocol per-persona route). Empty means "no persona binding": the - // active group's default chain serves the request, so every pre-existing - // caller behaves exactly as before. + // PersonaID 是本回合以哪个角色作答——即 + // 裁决 03.4.1 所说的按角色路由的「角色」,与 LLM 消息角色不同。RuntimeLLM + // 读它来挑出该角色的提供商链(models.json 的 personaProviders,即 + // 跨协议的按角色路由)。为空表示「无角色绑定」: + // 由生效组的默认链服务该请求,因此所有既有 + // 调用方的行为与之前完全一致。 // - // Why a request field and not a context key: the decision happens at the - // adapter boundary that already carries the Request, and the value is - // per-call metadata of the same kind as Model/Purpose. The persona lives in - // the loop's private session-env context key, which the model package cannot - // read (model must not import loop), so a ctx carrier would have needed the - // same write anyway — with the decision hidden behind an invisible key and - // hard to assert across packages. + // 为什么是请求字段而不是 context 键:该决策发生在 + // 本就携带 Request 的适配器边界上,而且这个值是 + // 与 Model/Purpose 同类的按调用元数据。角色存在于 + // loop 私有的会话环境 context 键里,而 model 包读不到 + // 它(model 不得 import loop),所以用 ctx 载体反正也得 + // 做同样的写入——只是决策被藏在一个看不见的键后面, + // 且难以跨包断言。 PersonaID string - // Tools is the interface-level placeholder for M2 function calling. + // Tools 是 M2 函数调用的接口级占位。 Tools []ToolSpec - // ToolChoice optionally directs how the provider selects a tool. It is an - // opaque provider-agnostic directive (e.g. {"type":"auto"} or {"type":"tool", - // "name":"x"}); empty means the provider default. Passed through verbatim to - // protocols that support it (Anthropic's tool_choice). + // ToolChoice 可选地指示提供商如何选择工具。它是一个 + // 提供商无关的不透明指令(如 {"type":"auto"} 或 {"type":"tool", + // "name":"x"});为空表示提供商默认值。会原样传给 + // 支持它的协议(Anthropic 的 tool_choice)。 // - // DeepSeek red line (design/04 §3.2 fact 5): with thinking on — its default — - // a forcing directive ("required" / "any" / a named tool) is rejected with a - // 400, so thinking must be switched off explicitly first. The adapters only - // WARN about that combination; they never switch thinking off on their own. + // DeepSeek 红线(design/04 §3.2 事实 5):思考开启时——即其默认—— + // 强制型指令("required" / "any" / 具名工具)会被 400 + // 拒绝,因此必须先把思考显式关掉。适配器只对这种组合 + // 发 WARN;它们绝不会自行关掉思考。 ToolChoice any - // Thinking controls the provider's reasoning budget/switch (design/04 §3.2). - // nil means "do not intervene" and uses the provider default. A provider - // whose protocol has no mapping for it logs a WARN rather than silently - // ignoring it. + // Thinking 控制提供商的推理预算/开关(design/04 §3.2)。 + // nil 表示「不干预」,使用提供商默认值。协议中 + // 没有对应映射的提供商会记一条 WARN,而不是静默 + // 忽略它。 // - // "Do not intervene" is not the same as "off": DeepSeek's thinking defaults to - // enabled, so Enable=false is what closes it there (sent as - // {"thinking":{"type":"disabled"}}). + // 「不干预」不等于「关闭」:DeepSeek 的思考默认 + // 开启,所以在那里要靠 Enable=false 来关(发送为 + // {"thinking":{"type":"disabled"}})。 Thinking *Thinking - // ReasoningEffort is the OpenAI-compatible reasoning strength knob - // (reasoning_effort). Empty means "do not intervene". The value is validated - // against the detected provider's whitelist; an unrecognized value is dropped - // with a WARN and never replaced by a different tier (design/04 §3.2 fact 1). - // DeepSeek additionally documents compatibility aliases (minimal→low, - // medium/xhigh→high, ultra→max), which are normalized to the tier the service - // applies. + // ReasoningEffort 是 OpenAI 兼容的推理强度旋钮 + // (reasoning_effort)。为空表示「不干预」。该值会针对 + // 已识别提供商的白名单校验;无法识别的值会带着 WARN + // 被丢弃,绝不会被换成别的档位(design/04 §3.2 事实 1)。 + // DeepSeek 另外记录了兼容别名(minimal→low、 + // medium/xhigh→high、ultra→max),它们会被归一化为服务 + // 实际采用的档位。 ReasoningEffort string } -// Thinking carries the reasoning knobs a caller wants applied (design/04 §3.2). -// Providers map the fields they actually support and report the rest; it only -// controls budget/switch, never the thought content (design/01 §2.1 red line). +// Thinking 携带调用方希望生效的推理旋钮(design/04 §3.2)。 +// 提供商映射它们真正支持的字段、并上报其余字段;它只 +// 控制预算/开关,绝不控制思考内容(design/01 §2.1 红线)。 type Thinking struct { - // BudgetTokens is the Anthropic thinking.budget_tokens target (the official - // semantic is a target, not a hard cap). Zero selects the adapter default. + // BudgetTokens 是 Anthropic thinking.budget_tokens 的目标值(官方 + // 语义是目标,不是硬上限)。为零则选用适配器默认值。 BudgetTokens int - // Enable is an explicit on/off switch for providers that expose a boolean - // thinking flag. nil = no explicit switch. + // Enable 是显式的开/关开关,供暴露布尔 + // thinking 标志的提供商使用。nil = 无显式开关。 Enable *bool } -// Response is a normalized completion result. +// Response 是归一化的补全结果。 type Response struct { - // Model is the model that produced the response. + // Model 是产出该响应的模型。 Model string Content string - // Message is the normalized assistant message. + // Message 是归一化的助手消息。 Message Message - // ToolCalls carries any tool_use blocks the assistant emitted. Empty when the - // response is pure text. + // ToolCalls 携带助手发出的任何 tool_use 块。响应是纯文本时为 + // 空。 ToolCalls []ToolCall - // StopReason is the provider's raw stop/finish reason (Anthropic stop_reason: - // end_turn / max_tokens / tool_use; OpenAI finish_reason: stop / length / - // tool_calls), so callers can distinguish a token-budget truncation from a - // normal end. Empty when the provider reports none. + // StopReason 是提供商原始的停止/结束原因(Anthropic stop_reason: + // end_turn / max_tokens / tool_use;OpenAI finish_reason:stop / length / + // tool_calls),因此调用方能区分 token 预算截断与 + // 正常结束。提供商未上报时为空。 StopReason string - // PromptTokens, CompletionTokens and TotalTokens report provider usage when - // available; zero when the provider does not return it. + // PromptTokens、CompletionTokens 与 TotalTokens 在可用时上报 + // 提供商用量;提供商未返回时为零。 PromptTokens int CompletionTokens int TotalTokens int } -// CallRecord describes a single successfully completed LLM call, for the M9 -// monitor/frontend. A failed attempt emits no record. +// CallRecord 描述一次成功完成的 LLM 调用,供 M9 +// 监控/前端使用。失败的尝试不产生记录。 type CallRecord struct { - // Model is the model that served the request. + // Model 是服务该请求的模型。 Model string - // Purpose labels what the call was for. + // Purpose 标注该次调用的用途。 Purpose string - // Tokens is the total token consumption reported by the provider. + // Tokens 是提供商上报的总 token 消耗。 Tokens int - // TookMs is the end-to-end call latency in milliseconds. + // TookMs 是端到端调用延迟,单位毫秒。 TookMs int64 } -// Options carries per-call knobs layered on top of the Request. +// Options 携带叠加在 Request 之上的按调用旋钮。 type Options struct { - // Timeout bounds a single HTTP call. Zero uses the adapter client timeout. + // Timeout 约束单次 HTTP 调用。为零则用适配器客户端的超时。 Timeout time.Duration } diff --git a/internal/runtime/model/openai.go b/internal/runtime/model/openai.go index 71284ac..df5005d 100644 --- a/internal/runtime/model/openai.go +++ b/internal/runtime/model/openai.go @@ -14,25 +14,25 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/config" ) -// Wire constraints for the OpenAI-compatible /chat/completions protocol. +// OpenAI 兼容 /chat/completions 协议的线格式约束。 const ( - // sseDataPrefix marks an SSE event line carrying a JSON payload. + // sseDataPrefix 标记携带 JSON 负载的 SSE 事件行。 sseDataPrefix = "data: " - // sseDone is the terminal SSE sentinel. + // sseDone 是终止用的 SSE 哨兵。 sseDone = "[DONE]" - // sseBufInit is the initial bufio.Scanner read buffer size. + // sseBufInit 是 bufio.Scanner 的初始读缓冲大小。 sseBufInit = 64 * 1024 - // maxSSELineBytes caps a single SSE event line. + // maxSSELineBytes 限制单条 SSE 事件行的长度。 maxSSELineBytes = 1024 * 1024 - // errBodyCap bounds how much of an error body is read for the message. + // errBodyCap 限制为取出消息而读取的错误响应体大小。 errBodyCap = 512 - // defaultHTTPTimeout bounds a single provider call when Options.Timeout is 0. + // defaultHTTPTimeout 在 Options.Timeout 为 0 时约束单次提供商调用。 defaultHTTPTimeout = 180 * time.Second ) -// OpenAIChatAdapter calls an OpenAI-compatible /chat/completions endpoint. It is -// constructed from a config.LLMConfig and implements Adapter for both -// non-streaming and token-level SSE streaming. +// OpenAIChatAdapter 调用 OpenAI 兼容的 /chat/completions 端点。它由 +// config.LLMConfig 构建,同时实现非流式与 token 级 SSE 流式的 +// Adapter。 type OpenAIChatAdapter struct { name string baseURL string @@ -40,39 +40,39 @@ type OpenAIChatAdapter struct { model string client *http.Client recorder Recorder - // reasoningKind is this endpoint's configured reasoning-switch shape - // (models.json group reasoning.kind / LLM_REASONING_KIND, 裁决 03.4.2/08.4.1). - // Empty means "auto": infer the vendor from the model name/base URL and send - // nothing when the shape is ambiguous. + // reasoningKind 是本端点已配置的推理开关形状 + // (models.json 组的 reasoning.kind / LLM_REASONING_KIND,裁决 03.4.2/08.4.1)。 + // 为空表示 "auto":按模型名/base URL 推断厂商, + // 形状含糊时什么都不发。 reasoningKind string - // log reports reasoning_effort validation and ignored thinking directives. It - // is never nil: a discard logger tagged with this adapter's source is - // installed when the caller supplies none, so a decision is always traceable. + // log 上报 reasoning_effort 校验与被忽略的思考指令。它 + // 永不为 nil:调用方未提供时,会装入一个带有本适配器来源 + // 标记的丢弃式日志器,因此每个决定都可追溯。 log *slog.Logger } -// OpenAIOption configures an OpenAIChatAdapter. +// OpenAIOption 用于配置 OpenAIChatAdapter。 type OpenAIOption func(*OpenAIChatAdapter) -// WithHTTPClient overrides the default HTTP client (used to inject mocks or a -// tuned transport in tests and production). +// WithHTTPClient 覆盖默认 HTTP 客户端(用于在测试与生产中 +// 注入 mock 或调优过的 transport)。 func WithHTTPClient(c *http.Client) OpenAIOption { return func(a *OpenAIChatAdapter) { a.client = c } } -// WithRecorder wires a per-call Recorder for CallRecord accounting. +// WithRecorder 装入按调用计账的 Recorder,用于 CallRecord 统计。 func WithRecorder(r Recorder) OpenAIOption { return func(a *OpenAIChatAdapter) { a.recorder = r } } -// WithModel overrides the adapter's default model name. +// WithModel 覆盖适配器的默认模型名。 func WithModel(m string) OpenAIOption { return func(a *OpenAIChatAdapter) { a.model = m } } -// WithOpenAILogger attaches the logger used to report reasoning_effort -// validation and ignored thinking directives. A nil logger is ignored so the -// discard default stays in place. +// WithOpenAILogger 挂上用于上报 reasoning_effort 校验与 +// 被忽略思考指令的日志器。nil 日志器会被忽略, +// 从而保留丢弃式的默认值。 func WithOpenAILogger(l *slog.Logger) OpenAIOption { return func(a *OpenAIChatAdapter) { if l != nil { @@ -81,12 +81,12 @@ func WithOpenAILogger(l *slog.Logger) OpenAIOption { } } -// NewOpenAIChat builds an adapter from a supplier config. baseURL is trimmed of -// any trailing slash before the /chat/completions path is appended. The -// endpoint's configured reasoning shape (cfg.ReasoningKind) travels with the -// adapter so every chain built from the same config — the group's chain, its -// per-persona chain and the local rung — resolves its reasoning switches the -// same way. +// NewOpenAIChat 由供应商配置构建适配器。baseURL 会先去掉 +// 末尾斜杠,再拼接 /chat/completions 路径。 +// 端点已配置的推理形状(cfg.ReasoningKind)随 +// 适配器一起走,因此由同一份配置构建的每条链——组链、 +// 它的按角色链以及本地级——都以 +// 相同方式解出自己的推理开关。 func NewOpenAIChat(cfg config.LLMConfig, opts ...OpenAIOption) *OpenAIChatAdapter { a := &OpenAIChatAdapter{ name: "openai", @@ -106,10 +106,10 @@ func NewOpenAIChat(cfg config.LLMConfig, opts ...OpenAIOption) *OpenAIChatAdapte return a } -// Name returns the adapter identifier. +// Name 返回适配器标识。 func (a *OpenAIChatAdapter) Name() string { return a.name } -// Chat performs a non-streaming completion. +// Chat 执行一次非流式补全。 func (a *OpenAIChatAdapter) Chat(ctx context.Context, req Request, opts Options) (Response, error) { start := time.Now() if a.baseURL == "" { @@ -147,8 +147,8 @@ func (a *OpenAIChatAdapter) Chat(ctx context.Context, req Request, opts Options) return out, nil } -// ChatStream streams structured events via onEvent and returns the aggregated -// response with usage when the provider reports it in the final chunk. +// ChatStream 通过 onEvent 流出结构化事件,并在提供商于 +// 末块上报用量时返回带用量的聚合响应。 func (a *OpenAIChatAdapter) ChatStream(ctx context.Context, req Request, onEvent func(StreamEvent)) (Response, error) { start := time.Now() if a.baseURL == "" { @@ -184,7 +184,7 @@ func (a *OpenAIChatAdapter) ChatStream(ctx context.Context, req Request, onEvent return out, nil } -// withCallTimeout wraps ctx with opts.Timeout when it is positive. +// withCallTimeout 在 opts.Timeout 为正时用该超时包裹 ctx。 func withCallTimeout(parent context.Context, opts Options) (context.Context, context.CancelFunc) { if opts.Timeout > 0 { return context.WithTimeout(parent, opts.Timeout) @@ -192,7 +192,7 @@ func withCallTimeout(parent context.Context, opts Options) (context.Context, con return parent, func() {} } -// resolveModel returns the effective model name. +// resolveModel 返回实际生效的模型名。 func (a *OpenAIChatAdapter) resolveModel(req Request) (string, error) { if req.Model != "" { return req.Model, nil @@ -203,7 +203,7 @@ func (a *OpenAIChatAdapter) resolveModel(req Request) (string, error) { return "", ErrNoModel } -// endpoint returns the full /chat/completions URL. +// endpoint 返回完整的 /chat/completions URL。 func (a *OpenAIChatAdapter) endpoint() string { return a.baseURL + "/chat/completions" } diff --git a/internal/runtime/model/openai_encode.go b/internal/runtime/model/openai_encode.go index 9f9f664..0e2557f 100644 --- a/internal/runtime/model/openai_encode.go +++ b/internal/runtime/model/openai_encode.go @@ -5,8 +5,8 @@ import ( "fmt" ) -// openAIChatRequest is the JSON body sent to /chat/completions. Field names -// match the provider protocol so the struct marshals directly. +// openAIChatRequest 是发给 /chat/completions 的 JSON 请求体。字段名 +// 与提供商协议一致,因此该结构体可以直接序列化。 type openAIChatRequest struct { Model string `json:"model"` Messages []openAIMessage `json:"messages"` @@ -15,49 +15,49 @@ type openAIChatRequest struct { Stream bool `json:"stream,omitempty"` StreamOptions *openAIStreamOpts `json:"stream_options,omitempty"` Tools []openAITool `json:"tools,omitempty"` - // Thinking is DeepSeek's top-level thinking switch. It is sent only for - // DeepSeek and only from an explicit switch (or an explicit off that must be - // honoured): leaving it out keeps the provider default, which is ENABLED, so - // an omitted field is not a way to switch thinking off (design/04 §3.2 - // fact 2). + // Thinking 是 DeepSeek 的顶层思考开关。它只对 + // DeepSeek 发送,且只在有显式开关(或必须被 + // 尊重的显式关闭)时发送:省略它会保留提供商默认值,而该默认值 + // 是「开启」,所以省略字段并不是关闭思考的办法(design/04 §3.2 + // 事实 2)。 Thinking *openAIThinking `json:"thinking,omitempty"` - // ReasoningEffort is the OpenAI-compatible reasoning strength knob. It is set - // only after validation against the detected vendor's whitelist (design/04 - // §3.2); an unrecognized value is dropped, never turned into a different tier. + // ReasoningEffort 是 OpenAI 兼容的推理强度旋钮。它只在 + // 对已识别厂商的白名单校验通过后才设置(design/04 + // §3.2);无法识别的值会被丢弃,绝不会被换成别的档位。 ReasoningEffort string `json:"reasoning_effort,omitempty"` - // ChatTemplateKwargs carries the vLLM-style thinking switch - // (chat_template_kwargs.enable_thinking, design/04 §3.2 Qwen3 行). It is sent - // only to an endpoint whose provider declared reasoning.kind=vllm. + // ChatTemplateKwargs 承载 vLLM 风格的思考开关 + // (chat_template_kwargs.enable_thinking,design/04 §3.2 Qwen3 行)。它只 + // 发送给提供商声明了 reasoning.kind=vllm 的端点。 ChatTemplateKwargs *openAIChatTemplateKwargs `json:"chat_template_kwargs,omitempty"` - // EnableThinking carries the DashScope compatible-mode thinking switch (the - // top-level enable_thinking). It is sent only to an endpoint whose provider - // declared reasoning.kind=dashscope — the shape is never inferred, because a - // wrong shape 400s the request. + // EnableThinking 承载 DashScope 兼容模式的思考开关( + // 顶层 enable_thinking)。它只发送给提供商 + // 声明了 reasoning.kind=dashscope 的端点——该形状永不推断,因为 + // 形状错了会让请求 400。 EnableThinking *bool `json:"enable_thinking,omitempty"` } -// openAIThinking is the wire form of DeepSeek's thinking switch: enabled or -// disabled. It is only ever sent to a DeepSeek endpoint. +// openAIThinking 是 DeepSeek 思考开关的线格式:enabled 或 +// disabled。它只会发给 DeepSeek 端点。 type openAIThinking struct { Type string `json:"type"` } -// openAIChatTemplateKwargs is the wire form of the vLLM (Qwen3) thinking switch: -// the boolean lives nested under chat_template_kwargs. +// openAIChatTemplateKwargs 是 vLLM(Qwen3)思考开关的线格式: +// 该布尔值嵌在 chat_template_kwargs 之下。 type openAIChatTemplateKwargs struct { EnableThinking *bool `json:"enable_thinking,omitempty"` } -// openAIStreamOpts asks the provider to include usage in the final stream chunk. +// openAIStreamOpts 请求提供商在最后一个流块里带上用量。 type openAIStreamOpts struct { IncludeUsage bool `json:"include_usage"` } -// openAIMessage is one request message in the wire body. Content is either a -// plain string (text-only) or an array of {type,text|image_url} parts. Tool -// messages carry ToolCallID (required by OpenAI); assistant messages that -// requested functions carry ToolCalls so the following tool messages can be -// correlated. +// openAIMessage 是线格式请求体里的一条消息。Content 要么是 +// 纯字符串(仅文本),要么是 {type,text|image_url} 片段数组。Tool +// 消息携带 ToolCallID(OpenAI 要求);请求过函数的助手消息 +// 携带 ToolCalls,以便随后的 tool 消息能够 +// 被关联。 type openAIMessage struct { Role string `json:"role"` Content any `json:"content"` @@ -65,28 +65,28 @@ type openAIMessage struct { ToolCalls []openAIToolCall `json:"tool_calls,omitempty"` } -// openAIToolCall is the wire form of one assistant tool_call in a request. OpenAI -// requires each subsequent tool message to reference the id back to these calls. +// openAIToolCall 是请求里单个助手 tool_call 的线格式。OpenAI +// 要求每条后续 tool 消息都把这些调用的 id 引用回来。 type openAIToolCall struct { ID string `json:"id"` Type string `json:"type"` Function openAIToolCallFn `json:"function"` } -// openAITool is the wire form of a function tool schema (M2 placeholder). +// openAITool 是函数工具 schema 的线格式(M2 占位)。 type openAITool struct { Type string `json:"type"` Function openAIToolFn `json:"function"` } -// openAIToolFn carries the function definition. +// openAIToolFn 承载函数定义。 type openAIToolFn struct { Name string `json:"name"` Description string `json:"description,omitempty"` Parameters any `json:"parameters,omitempty"` } -// openAIToolCallFn is the function part of a streaming tool-call fragment. +// openAIToolCallFn 是流式工具调用片段的函数部分。 type openAIToolCallFn struct { Name string `json:"name"` Arguments string `json:"arguments"` @@ -97,12 +97,12 @@ func (a *OpenAIChatAdapter) buildRequest(req Request, stream bool) ([]byte, erro if err != nil { return nil, err } - // The vendor behind an OpenAI-compatible endpoint decides which - // reasoning_effort tiers are legal and whether it has a thinking switch; - // classify it from the resolved model (and the base URL as a fallback) before - // touching the request. The endpoint's configured shape (models.json - // reasoning.kind, 裁决 03.4.2/08.4.1) wins over that classification, because - // the same wire protocol carries different switch shapes (vLLM vs DashScope). + // OpenAI 兼容端点背后的厂商决定哪些 + // reasoning_effort 档位合法、以及它是否有思考开关; + // 在改动请求之前,先按已解析出的模型(并以 base URL 作降级) + // 分类。端点已声明的形状(models.json + // 的 reasoning.kind,裁决 03.4.2/08.4.1)优先于该分类,因为 + // 同一线协议承载着不同的开关形状(vLLM 对 DashScope)。 vendor := reasoningVendorFor(req.Model, a.baseURL) plan := resolveOpenAIReasoning(a.log, a.reasoningKind, vendor, req.Model, req.Thinking, req.ReasoningEffort) reportForcedToolChoiceWithThinking(a.log, plan.provider, req.Model, req.ToolChoice, plan.thinkingOn()) @@ -140,10 +140,10 @@ func buildMessages(msgs []Message) ([]openAIMessage, error) { return out, nil } -// encodeOpenAIMessage renders one normalized message in OpenAI wire form. A -// RoleTool turn becomes a tool message carrying tool_call_id (required by OpenAI, -// and the audit's gap); an assistant turn that advertised tool calls carries the -// corresponding tool_calls array so the provider can correlate the tool messages. +// encodeOpenAIMessage 把一条归一化消息渲染成 OpenAI 线格式。 +// RoleTool 回合变成携带 tool_call_id 的 tool 消息(OpenAI 要求, +// 也是审计指出的缺口);声明过工具调用的助手回合携带 +// 对应的 tool_calls 数组,好让提供商能关联 tool 消息。 func encodeOpenAIMessage(m Message) (openAIMessage, error) { if m.Role == RoleTool { return openAIMessage{ @@ -159,8 +159,8 @@ func encodeOpenAIMessage(m Message) (openAIMessage, error) { return openAIMessage{Role: string(m.Role), Content: content, ToolCalls: encodeToolCalls(m.Content)}, nil } -// toolMessageCallID resolves the tool_call_id for a RoleTool turn: the -// message-level id, else the id carried on the first tool-result part. +// toolMessageCallID 解出 RoleTool 回合的 tool_call_id: +// 先是消息级 id,否则取第一个工具结果片段上的 id。 func toolMessageCallID(m Message) string { if m.ToolCallID != "" { return m.ToolCallID @@ -173,8 +173,8 @@ func toolMessageCallID(m Message) string { return "" } -// encodeToolCalls builds the assistant tool_calls array from a turn's tool-call -// parts. Arguments are serialized to JSON because OpenAI expects a string. +// encodeToolCalls 由回合的工具调用片段构建助手的 tool_calls +// 数组。参数序列化为 JSON,因为 OpenAI 期望字符串。 func encodeToolCalls(parts []Content) []openAIToolCall { calls := make([]openAIToolCall, 0, len(parts)) for _, p := range parts { @@ -211,8 +211,8 @@ func encodeContent(parts []Content) (any, error) { "image_url": map[string]any{"url": p.ImageURL}, }) case ContentTypeToolCall, ContentTypeToolResult: - // Tool-call and tool-result parts are encoded at the message level - // (tool_calls array / tool_call_id), never as text-image content. + // 工具调用与工具结果片段在消息级编码 + // (tool_calls 数组 / tool_call_id),绝不作为文本-图片内容。 continue default: return nil, fmt.Errorf("model: %w: unsupported content type %q", ErrBadRequest, p.Type) diff --git a/internal/runtime/model/openai_response.go b/internal/runtime/model/openai_response.go index fed20e2..0adbadd 100644 --- a/internal/runtime/model/openai_response.go +++ b/internal/runtime/model/openai_response.go @@ -4,8 +4,8 @@ import ( "encoding/json" ) -// openAIChatResponse is the JSON body returned by /chat/completions, for both -// the non-streaming payload and each SSE stream chunk. +// openAIChatResponse 是 /chat/completions 返回的 JSON 体,既用于 +// 非流式负载,也用于每个 SSE 流块。 type openAIChatResponse struct { ID string `json:"id"` Model string `json:"model"` @@ -13,7 +13,7 @@ type openAIChatResponse struct { Usage *openAIUsage `json:"usage,omitempty"` } -// openAIChoice is one candidate completion. +// openAIChoice 是一个候选补全。 type openAIChoice struct { Index int `json:"index"` Message openAIResponseMessage `json:"message"` @@ -21,16 +21,16 @@ type openAIChoice struct { FinishReason string `json:"finish_reason,omitempty"` } -// openAIResponseMessage is the assistant message inside a non-streaming choice. +// openAIResponseMessage 是非流式 choice 里的助手消息。 type openAIResponseMessage struct { Role string `json:"role"` Content string `json:"content"` ToolCalls []openAIToolCall `json:"tool_calls,omitempty"` } -// openAIDelta is the incremental payload inside a stream chunk. Reasoning content -// (OpenAI o-series / DeepSeek / DashScope) arrives in ReasoningContent; streaming -// function calls arrive in ToolCalls, accumulated per fragment index. +// openAIDelta 是流块里的增量负载。推理内容 +// (OpenAI o 系 / DeepSeek / DashScope)到达 ReasoningContent;流式 +// 函数调用到达 ToolCalls,按片段索引累积。 type openAIDelta struct { Role string `json:"role"` Content string `json:"content"` @@ -38,17 +38,17 @@ type openAIDelta struct { ToolCalls []openAIToolCallDelta `json:"tool_calls"` } -// openAIToolCallDelta is one streaming function-call fragment. OpenAI emits a -// sequence of these sharing the same Index: the ID and Function.Name usually -// arrive once (in the first fragment) while Function.Arguments streams across -// fragments. +// openAIToolCallDelta 是一个流式函数调用片段。OpenAI 会发出一串 +// 共享同一 Index 的此类片段:ID 与 Function.Name 通常 +// 只在第一个片段里出现一次,而 Function.Arguments 跨 +// 多个片段流式到达。 type openAIToolCallDelta struct { Index int `json:"index"` ID string `json:"id"` Function openAIToolCallFn `json:"function"` } -// openAIUsage reports token consumption. +// openAIUsage 上报 token 消耗。 type openAIUsage struct { PromptTokens int `json:"prompt_tokens"` CompletionTokens int `json:"completion_tokens"` diff --git a/internal/runtime/model/openai_stream.go b/internal/runtime/model/openai_stream.go index c8ad62c..49b123c 100644 --- a/internal/runtime/model/openai_stream.go +++ b/internal/runtime/model/openai_stream.go @@ -8,9 +8,9 @@ import ( "strings" ) -// openAIToolAcc accumulates the partial state of one streaming tool call (keyed -// by the provider's tool_calls[].index) so its arguments re-assemble across the -// delta fragments. started guards a single ToolCallStart emission per block. +// openAIToolAcc 累积单个流式工具调用的部分状态(按 +// 提供商的 tool_calls[].index 作键),以便其参数跨 +// 增量片段重新拼装。started 保证每块只发出一次 ToolCallStart。 type openAIToolAcc struct { id string name string @@ -18,8 +18,8 @@ type openAIToolAcc struct { started bool } -// idOrName resolves the block identifier used to correlate start/delta/end: the -// provider tool-call id when present, else the function name. +// idOrName 解出用于关联 start/delta/end 的块标识: +// 有提供商工具调用 id 时用它,否则用函数名。 func (a *openAIToolAcc) idOrName() string { if a.id != "" { return a.id @@ -27,12 +27,12 @@ func (a *openAIToolAcc) idOrName() string { return a.name } -// openAIStreamState is the running aggregation of one streamed -// /chat/completions response: the assembled Response, the bracketing flags of the -// currently open segment, and the per-index tool-call accumulators. It is the -// OpenAI counterpart of anthropic's streamBlock bookkeeping, so both adapters -// share one parse → apply → finalize shape. emit is the nil-tolerant event sink -// (a nil onEvent simply drops every event). +// openAIStreamState 是一次流式 /chat/completions 响应的 +// 运行中聚合状态:拼装好的 Response、当前打开片段的 +// 包裹标志,以及按索引的工具调用累加器。它是 +// Anthropic 的 streamBlock 记账的对位实现,因此两个适配器 +// 共用同一套「解析 → 应用 → 收尾」形态。emit 是容忍 nil 的事件槽 +// (onEvent 为 nil 时只是丢弃每个事件)。 type openAIStreamState struct { agg Response text strings.Builder @@ -43,13 +43,13 @@ type openAIStreamState struct { onEvent func(StreamEvent) } -// scanSSE incrementally decodes stream chunks, accumulates the full body text -// with a strings.Builder (never reading the whole body at once) and captures -// usage from the final chunk. It splits each delta into its segment and brackets -// them: reasoning_content as thinking_start/delta/end, tool_calls fragments as -// tool_call_start/delta/end, and body text as text_start/delta/end. The -// accumulated tool-call arguments re-assemble across fragments into a structured -// Args map, and are attached to the returned Response alongside the text. +// scanSSE 增量解码流块,用 strings.Builder 累积完整正文 +// 文本(绝不一次性读完整个响应体),并从最后一个块 +// 捕获用量。它把每个增量拆到其片段并按对包裹: +// reasoning_content 作为 thinking_start/delta/end,tool_calls 片段作为 +// tool_call_start/delta/end,正文文本作为 text_start/delta/end。 +// 累积的工具调用参数会跨片段重新拼装成结构化的 +// Args map,并与文本一起挂到返回的 Response 上。 func scanSSE(r io.Reader, onEvent func(StreamEvent)) (Response, error) { scanner := bufio.NewScanner(r) scanner.Buffer(make([]byte, 0, sseBufInit), maxSSELineBytes) @@ -76,16 +76,16 @@ func scanSSE(r io.Reader, onEvent func(StreamEvent)) (Response, error) { return finalizeOpenAIStream(st), nil } -// emit forwards one structured event, tolerating a nil sink. +// emit 转发一个结构化事件,容忍 nil 事件槽。 func (st *openAIStreamState) emit(ev StreamEvent) { if st.onEvent != nil { st.onEvent(ev) } } -// applyChunk folds a single decoded stream chunk into the running aggregation: -// usage and stop reason are recorded even on a choice-less terminal chunk, and -// the segment delta (if any) is routed to its handler. +// applyChunk 把单个已解码的流块折叠进运行中的聚合状态: +// 用量与停止原因即使在无 choice 的终止块上也会被记录, +// 片段增量(若有)则被路由到其处理器。 func (st *openAIStreamState) applyChunk(chunk openAIChatResponse) { if u := chunk.Usage; u != nil { st.agg.PromptTokens = u.PromptTokens @@ -104,9 +104,9 @@ func (st *openAIStreamState) applyChunk(chunk openAIChatResponse) { st.applyDelta(chunk.Choices[0].Delta) } -// applyDelta routes one delta fragment to its segment handler. The precedence is -// the provider's: a reasoning fragment wins over a tool-call fragment, which wins -// over body text, so a delta never contributes to two segments at once. +// applyDelta 把一个增量片段路由到其片段处理器。优先级 +// 取自提供商:推理片段压过工具调用片段,后者又压过 +// 正文文本,因此一个增量绝不会同时进入两个片段。 func (st *openAIStreamState) applyDelta(delta *openAIDelta) { switch { case delta.ReasoningContent != "": @@ -122,8 +122,8 @@ func (st *openAIStreamState) applyDelta(delta *openAIDelta) { } } -// applyThinking forwards one reasoning fragment, opening the thinking segment on -// the first fragment only. +// applyThinking 转发一个推理片段,只在第一个 +// 片段上打开 thinking 片段。 func (st *openAIStreamState) applyThinking(fragment string) { if !st.thinkingStarted { st.thinkingStarted = true @@ -132,8 +132,8 @@ func (st *openAIStreamState) applyThinking(fragment string) { st.emit(StreamEvent{Kind: StreamKindThinkingDelta, Delta: fragment}) } -// endThinking closes the open thinking segment (no-op when none is open), so a -// following tool-call or text segment starts a fresh one. +// endThinking 关闭已打开的 thinking 片段(未打开时是空操作), +// 这样随后的工具调用或文本片段会开一个新的。 func (st *openAIStreamState) endThinking() { if !st.thinkingStarted { return @@ -142,9 +142,9 @@ func (st *openAIStreamState) endThinking() { st.emit(StreamEvent{Kind: StreamKindThinkingEnd}) } -// applyToolCall folds one streaming tool-call fragment into its per-index -// accumulator, opening the block's start event once its id/name is known and -// forwarding the argument fragments as they arrive. +// applyToolCall 把一个流式工具调用片段折叠进它按索引的 +// 累加器,在 id/name 已知时打开该块的 start 事件, +// 并随着参数片段到达而转发它们。 func (st *openAIStreamState) applyToolCall(tc openAIToolCallDelta) { acc := st.toolCalls[tc.Index] if acc == nil { @@ -169,8 +169,8 @@ func (st *openAIStreamState) applyToolCall(tc openAIToolCallDelta) { st.emit(StreamEvent{Kind: StreamKindToolCallDelta, Index: tc.Index, ID: acc.idOrName(), Delta: tc.Function.Arguments}) } -// applyText appends one body-text fragment, opening the text segment on the first -// fragment only. +// applyText 追加一个正文文本片段,只在第一个 +// 片段上打开文本片段。 func (st *openAIStreamState) applyText(fragment string) { st.text.WriteString(fragment) if !st.textStarted { @@ -180,9 +180,9 @@ func (st *openAIStreamState) applyText(fragment string) { st.emit(StreamEvent{Kind: StreamKindTextDelta, Delta: fragment}) } -// closeSegments emits the end event of every segment still open at stream -// termination. OpenAI has no per-block stop event, so each started tool_call -// block is closed here. +// closeSegments 在流终止时发出每个仍打开片段的结束 +// 事件。OpenAI 没有按块的停止事件,所以每个已开始的 tool_call +// 块都在此关闭。 func (st *openAIStreamState) closeSegments() { if st.thinkingStarted { st.emit(StreamEvent{Kind: StreamKindThinkingEnd}) @@ -199,9 +199,9 @@ func (st *openAIStreamState) closeSegments() { } } -// assembleToolCalls decodes each accumulated tool-call argument buffer into its -// structured Args map, in provider order. Unparseable fragments degrade to an -// empty map (the call is still surfaced). +// assembleToolCalls 按提供商顺序把每个累积的工具调用参数缓冲 +// 解码成结构化的 Args map。无法解析的片段降级为 +// 空 map(该调用仍会浮出)。 func (st *openAIStreamState) assembleToolCalls() { for _, idx := range st.toolOrder { acc := st.toolCalls[idx] @@ -218,9 +218,9 @@ func (st *openAIStreamState) assembleToolCalls() { } } -// finalizeOpenAIStream closes the open segments and assembles the aggregated -// Response: the concatenated body text (reasoning never leaks into it) plus the -// re-assembled tool calls. +// finalizeOpenAIStream 关闭打开的片段并拼装聚合后的 +// Response:拼接的正文文本(推理永不泄漏进去)加上 +// 重新拼装的工具调用。 func finalizeOpenAIStream(st *openAIStreamState) Response { st.closeSegments() st.assembleToolCalls() diff --git a/internal/runtime/model/openai_test.go b/internal/runtime/model/openai_test.go index c9a22bc..40dd960 100644 --- a/internal/runtime/model/openai_test.go +++ b/internal/runtime/model/openai_test.go @@ -23,7 +23,7 @@ const okCompletion = `{ "usage": {"prompt_tokens": 5, "completion_tokens": 2, "total_tokens": 7} }` -// capturedRequest records what an OpenAIChatAdapter sent to a stub server. +// capturedRequest 记录 OpenAIChatAdapter 发往 stub 服务器的内容。 type capturedRequest struct { method string path string @@ -374,8 +374,8 @@ func TestOpenAIChat_Chat_ToolRoundTrip(t *testing.T) { t.Fatalf("body.messages = %v, want 3 messages", got.body["messages"]) } - // The assistant turn must carry tool_calls (so the tool message can be - // correlated by the provider) — this was previously dropped entirely. + // 助手回合必须携带 tool_calls(好让 tool 消息能被 + // 提供商关联)——这一点此前被完全丢弃。 assistant, _ := msgs[1].(map[string]any) if assistant["role"] != "assistant" { t.Errorf("assistant role = %v, want assistant", assistant["role"]) @@ -393,7 +393,7 @@ func TestOpenAIChat_Chat_ToolRoundTrip(t *testing.T) { t.Errorf("function = %#v, want name=weather arguments={\"city\":\"Paris\"}", fn) } - // The tool message must carry tool_call_id (the audit's gap) and the result. + // tool 消息必须携带 tool_call_id(审计指出的缺口)与结果。 tool, _ := msgs[2].(map[string]any) if tool["role"] != "tool" { t.Errorf("tool role = %v, want tool", tool["role"]) @@ -408,8 +408,8 @@ func TestOpenAIChat_Chat_ToolRoundTrip(t *testing.T) { func TestOpenAIChat_Build_ToolMessageLegacy(t *testing.T) { t.Parallel() - // The loop's current form: a RoleTool message carrying a plain text part and - // Message.ToolCallID. buildMessages must emit a tool message with tool_call_id. + // loop 当前的形态:一个携带纯文本片段与 + // Message.ToolCallID 的 RoleTool 消息。buildMessages 必须发出带 tool_call_id 的 tool 消息。 m := Message{Role: RoleTool, ToolCallID: "call_9", Content: []Content{{Type: ContentTypeText, Text: "tool weather result: 22"}}} msgs, err := buildMessages([]Message{m}) diff --git a/internal/runtime/model/persona_provider_test.go b/internal/runtime/model/persona_provider_test.go index 6bc1ce9..b373f8f 100644 --- a/internal/runtime/model/persona_provider_test.go +++ b/internal/runtime/model/persona_provider_test.go @@ -89,10 +89,10 @@ func TestRuntimeLLM_PersonaProviderSelectsTheOtherProtocol(t *testing.T) { } } -// TestRuntimeLLM_PersonaProviderAppliesToStreamingToo asserts the streaming path -// (the main chat path: loop.streamStep → adapter.ChatStream) resolves the same -// persona chain as the non-streaming one — a persona route that only worked on -// one of the two paths would be a silent half-fix. +// TestRuntimeLLM_PersonaProviderAppliesToStreamingToo 断言流式路径 +// (主聊天路径:loop.streamStep → adapter.ChatStream)解析出与非流式 +// 相同的那条 persona 链——只在两条路径之一生效的 persona +// 路由只能算一次静默的半修。 func TestRuntimeLLM_PersonaProviderAppliesToStreamingToo(t *testing.T) { t.Parallel() anthropicCh := make(chan anthropicCaptured, 1) @@ -123,9 +123,9 @@ func TestRuntimeLLM_PersonaProviderAppliesToStreamingToo(t *testing.T) { } } -// TestPersonaStack_StampsPersonaOnEveryRequest pins the carrier that lets a -// per-persona agent wire the route without the loop handing over its ctx: the -// scoped adapter view stamps PersonaID and routes the model by that persona. +// TestPersonaStack_StampsPersonaOnEveryRequest 钉住那个载体:它让 +// 按 persona 的 agent 能在 loop 不交出自身 ctx 的情况下接好路由, +// 即作用域化的适配器视图打上 PersonaID 并按该 persona 路由模型。 func TestPersonaStack_StampsPersonaOnEveryRequest(t *testing.T) { t.Parallel() f := newPersonaProviderFixture(t, nil) @@ -151,8 +151,8 @@ func TestPersonaStack_StampsPersonaOnEveryRequest(t *testing.T) { } } -// TestPersonaStack_BlankPersonaKeepsDefaultRoute asserts an unbound view is a -// pass-through: an empty persona must not turn into a binding of its own. +// TestPersonaStack_BlankPersonaKeepsDefaultRoute 断言未绑定的视图是 +// 直通:空的 persona 不得变成它自己的一个绑定。 func TestPersonaStack_BlankPersonaKeepsDefaultRoute(t *testing.T) { t.Parallel() f := newPersonaProviderFixture(t, nil) @@ -177,10 +177,10 @@ func TestPersonaStack_BlankPersonaKeepsDefaultRoute(t *testing.T) { } } -// TestRuntimeLLM_PersonaProviderWithoutOwnModelKeepsDefaultChain covers the -// half-declared config: a personaProviders entry alone says nothing about which -// model to send, so switching protocol would push the group's default model name -// to another vendor's endpoint. The default chain is kept and the reason logged. +// TestRuntimeLLM_PersonaProviderWithoutOwnModelKeepsDefaultChain 覆盖 +// 半声明的配置:只有一条 personaProviders 条目并不能说明该发 +// 哪个模型,所以切换协议会把组的默认模型名推到 +// 另一厂商的端点。此时保留默认链并记录原因。 func TestRuntimeLLM_PersonaProviderWithoutOwnModelKeepsDefaultChain(t *testing.T) { t.Parallel() f := newPersonaProviderFixture(t, func(g *runtimeconfig.Group) { @@ -200,9 +200,9 @@ func TestRuntimeLLM_PersonaProviderWithoutOwnModelKeepsDefaultChain(t *testing.T } } -// TestRuntimeLLM_PersonaProviderWithoutAGroupKeepsDefaultChain covers a declared -// protocol that models.json has no group for: the persona keeps the default chain -// instead of failing the turn, and the skip is reported. +// TestRuntimeLLM_PersonaProviderWithoutAGroupKeepsDefaultChain 覆盖已声明 +// 但 models.json 没有对应组的协议:该 persona 保留默认链 +// 而不是让回合失败,并上报这次跳过。 func TestRuntimeLLM_PersonaProviderWithoutAGroupKeepsDefaultChain(t *testing.T) { t.Parallel() openaiCh := make(chan capturedRequest, 1) @@ -231,9 +231,9 @@ func TestRuntimeLLM_PersonaProviderWithoutAGroupKeepsDefaultChain(t *testing.T) } } -// TestRuntimeLLM_PersonaProviderSameProtocolReusesDefaultChain asserts a persona -// naming the active group's own protocol builds no second chain: the default one -// already speaks it, and the per-persona model name reaches it via the router. +// TestRuntimeLLM_PersonaProviderSameProtocolReusesDefaultChain 断言声明了 +// 生效组自身协议的 persona 不会构建第二条链:默认链 +// 本就说那个协议,按 persona 的模型名经路由器到达它。 func TestRuntimeLLM_PersonaProviderSameProtocolReusesDefaultChain(t *testing.T) { t.Parallel() f := newPersonaProviderFixture(t, func(g *runtimeconfig.Group) { diff --git a/internal/runtime/model/persona_router_test.go b/internal/runtime/model/persona_router_test.go index c4b3a51..28722c2 100644 --- a/internal/runtime/model/persona_router_test.go +++ b/internal/runtime/model/persona_router_test.go @@ -6,9 +6,9 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" ) -// TestRouter_RouteRequestForPersona pins 裁决 03.4.1: the "role" of per-role -// routing is the persona a turn answers as, and a persona without a binding keeps -// the message-role behavior. +// TestRouter_RouteRequestForPersona 钉住裁决 03.4.1:按角色路由里的 +// 「角色」是本回合作答所依据的 persona,而未绑定的 persona 保持 +// 消息角色行为。 func TestRouter_RouteRequestForPersona(t *testing.T) { t.Parallel() r := NewRouter("default", @@ -39,10 +39,10 @@ func TestRouter_RouteRequestForPersona(t *testing.T) { } } -// TestRouter_PersonaBindingIgnoresBlankAndImageDoesNotSwitch pins two edges: a -// blank persona/model cannot create a binding, and a persona-routed image request -// is not silently re-routed to a vision model (that decision belongs to -// vision.Router, 裁决 14.4.1/14.4.5). +// TestRouter_PersonaBindingIgnoresBlankAndImageDoesNotSwitch 钉住两个边界:空 +// persona/模型无法创建绑定,而按 persona 路由的图像请求 +// 不会被静默改道到视觉模型(那个决策属于 +// vision.Router,裁决 14.4.1/14.4.5)。 func TestRouter_PersonaBindingIgnoresBlankAndImageDoesNotSwitch(t *testing.T) { t.Parallel() r := NewRouter("default", @@ -63,9 +63,9 @@ func TestRouter_PersonaBindingIgnoresBlankAndImageDoesNotSwitch(t *testing.T) { } } -// TestRuntimeLLM_PersonaRoutesFromModelsJSON pins the carrier: a per-persona -// binding written on the models.json group reaches the router through the hot -// snapshot, and a reload picks up a changed binding (裁决 03.4.1 载体 = models.json)。 +// TestRuntimeLLM_PersonaRoutesFromModelsJSON 钉住载体:写在 +// models.json 组上的按 persona 绑定经热 +// 快照到达路由器,且一次重载会拾取改过的绑定(裁决 03.4.1 载体 = models.json)。 func TestRuntimeLLM_PersonaRoutesFromModelsJSON(t *testing.T) { path := writeRTFile(t, []runtimeconfig.Group{{ ID: "openai", Provider: ProviderOpenAI, BaseURL: "https://x", Model: "group-model", @@ -96,10 +96,10 @@ func TestRuntimeLLM_PersonaRoutesFromModelsJSON(t *testing.T) { } } -// TestRuntimeLLM_NoVisionPreemptionOnImage pins that the runtime snapshot no longer -// carries the env vision model: a configured LLM_VISION_MODEL must not switch an -// image-bearing request to another model, because image routing is owned by -// vision.Router (裁决 14.4.1/14.4.5). +// TestRuntimeLLM_NoVisionPreemptionOnImage 钉住运行时快照不再 +// 携带 env 视觉模型:已配置的 LLM_VISION_MODEL 不得把 +// 携带图片的请求切到另一个模型,因为图像路由归 +// vision.Router 所有(裁决 14.4.1/14.4.5)。 func TestRuntimeLLM_NoVisionPreemptionOnImage(t *testing.T) { path := writeRTFile(t, []runtimeconfig.Group{{ ID: "openai", Provider: ProviderOpenAI, BaseURL: "https://x", Model: "group-model", diff --git a/internal/runtime/model/reasoning.go b/internal/runtime/model/reasoning.go index 226da01..6b0db5e 100644 --- a/internal/runtime/model/reasoning.go +++ b/internal/runtime/model/reasoning.go @@ -8,32 +8,32 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" ) -// discardLogger returns a logger that drops every record but still carries the -// adapter's module source, so an adapter built without an injected logger can -// never emit an unsourced record. +// discardLogger 返回一个丢弃所有记录的日志器,但仍带有 +// 适配器的模块来源,因此未注入日志器而构建的适配器 +// 绝不会产生无来源记录。 func discardLogger(source string) *slog.Logger { return logger.WithSource(slog.New(slog.NewTextHandler(io.Discard, nil)), source) } -// Reasoning wire values shared by the OpenAI-compatible and Anthropic adapters -// (design/04 §3.2, checked against the provider documents on 2026-10-03). +// OpenAI 兼容与 Anthropic 适配器共用的推理线取值 +// (design/04 §3.2,已于 2026-10-03 对照各提供商文档核对)。 const ( - // thinkingTypeEnabled and thinkingTypeDisabled are the values of the - // {"thinking":{"type":...}} switch. DeepSeek's default is enabled, so omitting - // the field leaves thinking ON — "do not intervene" is not a way to switch it - // off; only an explicit disabled (or reasoning_effort "none") is. + // thinkingTypeEnabled 与 thinkingTypeDisabled 是 + // {"thinking":{"type":...}} 开关的取值。DeepSeek 默认开启,所以省略 + // 该字段会让思考保持开启——「不干预」不是关闭它的办法; + // 只有显式 disabled(或 reasoning_effort "none")才是。 thinkingTypeEnabled = "enabled" thinkingTypeDisabled = "disabled" - // effortNone is the reasoning_effort value that switches thinking off. It is a - // switch, not a strength tier, so it must never be replaced by a tier: mapping - // it onto "low" would invert the caller's intent (thinking off → thinking on). + // effortNone 是关闭思考的 reasoning_effort 取值。它是 + // 开关而非强度档位,所以绝不能被替换成某个档位:把它 + // 映射到 "low" 会颠倒调用方的意图(关闭思考 → 开启思考)。 effortNone = "none" ) -// reasoningVendor identifies the vendor behind an OpenAI-compatible endpoint. -// The wire protocol is the same for all of them, but the accepted -// reasoning_effort values are not, so a value must be checked against the right -// whitelist instead of being forwarded blindly. +// reasoningVendor 标识 OpenAI 兼容端点背后的厂商。 +// 它们的线协议相同,但可接受的 +// reasoning_effort 取值不同,所以必须按正确的 +// 白名单校验,而不是盲目转发。 type reasoningVendor string const ( @@ -43,10 +43,10 @@ const ( vendorUnknown reasoningVendor = "unknown" ) -// reasoningEffortAliases is DeepSeek's documented compatibility mapping: the -// service accepts these aliases and applies the mapped tier. The mapping is -// applied on our side too, so the request body always carries the tier the -// provider documents as the effective one (design/04 §3.2 fact 1). +// reasoningEffortAliases 是 DeepSeek 记录的兼容映射: +// 服务接受这些别名并采用映射后的档位。我们在自己这一侧 +// 也做同一映射,因此请求体总是携带提供商 +// 记录为有效值的那一档(design/04 §3.2 事实 1)。 var reasoningEffortAliases = map[string]string{ "minimal": "low", "medium": "high", @@ -54,20 +54,20 @@ var reasoningEffortAliases = map[string]string{ "ultra": "max", } -// reasoningEffortAllowed is the per-vendor set of accepted reasoning_effort -// values. A vendor absent from this table has no reasoning_effort support at all, -// which is reported rather than guessed. +// reasoningEffortAllowed 是按厂商划分的可接受 reasoning_effort +// 取值集合。不在本表里的厂商完全没有 reasoning_effort 支持, +// 这一点会被上报,而不是靠猜。 // -// There is deliberately no "round down to the nearest tier" fallback: an -// unsupported value is dropped and reported instead, because silently changing it -// can invert the caller's intent — the original bug mapped DeepSeek's "none" -// (thinking off) onto "low" (thinking on) exactly that way. +// 这里刻意没有「向下取到最近档位」的降级:不支持的取值 +// 会被丢弃并上报,因为静默改写它可能会颠倒调用方的 +// 意图——早先的 bug 正是这样把 DeepSeek 的 "none"(关闭思考) +// 映射到了 "low"(开启思考)。 var reasoningEffortAllowed = map[reasoningVendor]map[string]bool{ vendorOpenAI: tierSet("none", "minimal", "low", "medium", "high", "xhigh", "max"), vendorDeepSeek: tierSet("none", "low", "high", "max"), } -// tierSet builds a membership set from the given tiers. +// tierSet 由给定档位构建一个成员集合。 func tierSet(tiers ...string) map[string]bool { set := make(map[string]bool, len(tiers)) for _, t := range tiers { @@ -76,12 +76,12 @@ func tierSet(tiers ...string) map[string]bool { return set } -// reasoningVendorFor classifies the vendor of an OpenAI-compatible request from -// its model name, falling back to the base URL when the name does not identify -// one. An unrecognized pair yields vendorUnknown so the caller reports "not -// supported" instead of assuming OpenAI semantics. The Anthropic adapter uses the -// same classification to tell a DeepSeek /anthropic endpoint (which ignores -// budget_tokens) from the Anthropic endpoint. +// reasoningVendorFor 按模型名分类 OpenAI 兼容请求的厂商, +// 模型名无法识别出厂商时退回到 base URL。 +// 无法识别的组合得到 vendorUnknown,好让调用方上报「不 +// 支持」而不是假定 OpenAI 语义。Anthropic 适配器用 +// 同一套分类来区分 DeepSeek 的 /anthropic 端点(它忽略 +// budget_tokens)与 Anthropic 端点。 func reasoningVendorFor(model, baseURL string) reasoningVendor { m := strings.ToLower(strings.TrimSpace(model)) switch { @@ -105,7 +105,7 @@ func reasoningVendorFor(model, baseURL string) reasoningVendor { return vendorUnknown } -// thinkingSwitchValue maps an explicit on/off switch onto its wire value. +// thinkingSwitchValue 把显式开/关开关映射到它的线取值。 func thinkingSwitchValue(enable bool) string { if enable { return thinkingTypeEnabled @@ -113,10 +113,10 @@ func thinkingSwitchValue(enable bool) string { return thinkingTypeDisabled } -// normalizeDeepSeekEffort maps an effort onto the tier DeepSeek applies and -// reports whether the value is one the service documents: the documented set -// (none/low/high/max) passes through, the compatibility aliases map to their -// official tier. +// normalizeDeepSeekEffort 把 effort 映射到 DeepSeek 实际采用的档位, +// 并报告该值是否为服务记录的取值:记录在案的集合 +// (none/low/high/max)原样通过,兼容别名映射到其 +// 官方档位。 func normalizeDeepSeekEffort(effort string) (string, bool) { if tier, ok := reasoningEffortAliases[effort]; ok { return tier, true @@ -127,16 +127,16 @@ func normalizeDeepSeekEffort(effort string) (string, bool) { return "", false } -// resolveReasoningEffort validates effort against the vendor's whitelist and -// returns the value to send; "" drops the field. An unrecognized value is never -// turned into a different tier — it is dropped and reported with provider, field, -// value and model, because changing it can invert the caller's intent. A DeepSeek -// compatibility alias is normalized to the tier the service applies and reported. +// resolveReasoningEffort 针对厂商白名单校验 effort 并 +// 返回要发送的值;"" 表示丢弃该字段。无法识别的值绝不会 +// 被换成别的档位——它会被丢弃,并把 provider、field、 +// value 与 model 一并上报,因为改动它会颠倒调用方的意图。DeepSeek +// 的兼容别名会被归一化为服务采用的档位并上报。 // -// vendor must be a vendor with a reasoning_effort whitelist (DeepSeek / OpenAI): -// the shapes that carry no such field (vLLM / DashScope) and the endpoints whose -// shape is unknown drop the effort themselves and report it, so their callers -// never reach this function (see reasoning_shape.go, 裁决 03.4.2). +// vendor 必须是带 reasoning_effort 白名单的厂商(DeepSeek / OpenAI): +// 不带该字段的形状(vLLM / DashScope)以及形状未知的 +// 端点会自行丢弃 effort 并上报,因此它们的调用方 +// 永远不会走到本函数(见 reasoning_shape.go,裁决 03.4.2)。 func resolveReasoningEffort(log *slog.Logger, vendor reasoningVendor, effort, model string) string { effort = strings.TrimSpace(effort) if effort == "" { @@ -159,8 +159,8 @@ func resolveReasoningEffort(log *slog.Logger, vendor reasoningVendor, effort, mo return "" } -// toolChoiceForces reports whether a tool_choice directive obliges the model to -// call a tool ("required", Anthropic's "any", or a named tool). auto/none do not. +// toolChoiceForces 报告 tool_choice 指令是否强制模型 +// 调用工具("required"、Anthropic 的 "any",或具名工具)。auto/none 不会。 func toolChoiceForces(toolChoice any) bool { switch v := toolChoice.(type) { case nil: @@ -183,7 +183,7 @@ func toolChoiceForces(toolChoice any) bool { return false } -// forcedToolChoiceType reports whether a tool_choice "type" value forces a call. +// forcedToolChoiceType 报告 tool_choice 的 "type" 取值是否强制调用。 func forcedToolChoiceType(value any) bool { t, _ := value.(string) switch strings.ToLower(strings.TrimSpace(t)) { @@ -193,14 +193,14 @@ func forcedToolChoiceType(value any) bool { return false } -// reportForcedToolChoiceWithThinking warns about the DeepSeek red line that a -// forced tool call — tool_choice "required"/"any"/a named tool — and thinking are -// not compatible: while thinking is on (its default) the API rejects the forcing -// directive with a 400, so the caller must switch thinking off explicitly first -// (design/04 §3.2 fact 5). It only reports: resolution must never switch thinking -// off on its own. On the OpenAI-compatible adapter the directive is not on the -// wire yet (M2), so the warning is prospective there — it still records that the -// requested combination can never be honoured. +// reportForcedToolChoiceWithThinking 就 DeepSeek 红线报警: +// 强制型工具调用——tool_choice "required"/"any"/具名工具——与思考 +// 不兼容:思考开启(其默认)时 API 会用 400 拒绝该强制 +// 指令,所以调用方必须先把思考显式关掉 +// (design/04 §3.2 事实 5)。它只上报:解算过程绝不能自行 +// 关掉思考。在 OpenAI 兼容适配器上该指令尚未上线 +// (M2),所以那里只是前瞻性警告——它仍然记录了 +// 所请求的组合永远无法被满足。 func reportForcedToolChoiceWithThinking(log *slog.Logger, provider, model string, toolChoice any, thinkingOn bool) { if !thinkingOn || !toolChoiceForces(toolChoice) { return diff --git a/internal/runtime/model/reasoning_shape_test.go b/internal/runtime/model/reasoning_shape_test.go index 8861aa5..88d96c8 100644 --- a/internal/runtime/model/reasoning_shape_test.go +++ b/internal/runtime/model/reasoning_shape_test.go @@ -110,8 +110,8 @@ func TestOpenAIChat_DeclaredShapeSendsItsOwnSwitch(t *testing.T) { } } -// TestOpenAIChat_DeclaredShapeEnablesThinking pins the on direction as well, so the -// switch cannot be hard-wired off by a refactor. +// TestOpenAIChat_DeclaredShapeEnablesThinking 也钉住「开启」方向, +// 以免开关被一次重构硬接成关闭。 func TestOpenAIChat_DeclaredShapeEnablesThinking(t *testing.T) { t.Parallel() for _, tc := range declaredShapeCases { @@ -226,9 +226,9 @@ func TestOpenAIChat_ReasoningKindOpenAIKeepsOpenAITiers(t *testing.T) { } } -// TestOpenAIChat_ReasoningKindOpenAIReportsBooleanSwitch asserts the effort-only -// shape does not silently swallow a boolean thinking switch: it reports it, and -// points at the documented way to disable reasoning. +// TestOpenAIChat_ReasoningKindOpenAIReportsBooleanSwitch 断言「仅 effort」 +// 形状不会静默吞掉布尔 thinking 开关:它会报告该情况,并 +// 指出文档记录在案的关闭推理的做法。 func TestOpenAIChat_ReasoningKindOpenAIReportsBooleanSwitch(t *testing.T) { t.Parallel() ch := make(chan capturedRequest, 1) @@ -256,10 +256,10 @@ func TestOpenAIChat_ReasoningKindOpenAIReportsBooleanSwitch(t *testing.T) { } } -// TestOpenAIChat_UnknownReasoningKindSendsNothing asserts a shape this build does -// not implement is reported and sends nothing — even when the model name would -// otherwise look like a vendor we do understand. Guessing here is exactly the -// "fake adaptation" the ruling forbids. +// TestOpenAIChat_UnknownReasoningKindSendsNothing 断言本次构建 +// 未实现的形状会被上报、且什么都不发——即便模型名 +// 看上去像我们确实理解的厂商也一样。在这里靠猜正是 +// 裁决所禁止的「假适配」。 func TestOpenAIChat_UnknownReasoningKindSendsNothing(t *testing.T) { t.Parallel() ch := make(chan capturedRequest, 1) @@ -288,9 +288,9 @@ func TestOpenAIChat_UnknownReasoningKindSendsNothing(t *testing.T) { } } -// TestOpenAIChat_AutoQwenShapeStaysConservative pins the un-declared default: a -// Qwen endpoint may be vLLM or DashScope, whose field shapes differ, so auto mode -// sends nothing and asks for an explicit declaration instead of guessing one. +// TestOpenAIChat_AutoQwenShapeStaysConservative 钉住未声明的默认: +// Qwen 端点可能是 vLLM 或 DashScope,两者字段形状不同,所以 auto 模式 +// 什么都不发,并改为要求显式声明,而不是替它猜一个。 func TestOpenAIChat_AutoQwenShapeStaysConservative(t *testing.T) { t.Parallel() ch := make(chan capturedRequest, 1) diff --git a/internal/runtime/model/reasoning_test.go b/internal/runtime/model/reasoning_test.go index a5f387a..ac61cfa 100644 --- a/internal/runtime/model/reasoning_test.go +++ b/internal/runtime/model/reasoning_test.go @@ -11,20 +11,20 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/config" ) -// captureLog returns a logger writing text records into a buffer, plus the -// buffer, so a test can assert the WARN an adapter emits. +// captureLog 返回一个把文本记录写进缓冲的日志器,外加那个 +// 缓冲,好让测试能断言适配器发出的 WARN。 func captureLog() (*slog.Logger, *bytes.Buffer) { buf := &bytes.Buffer{} return slog.New(slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug})), buf } -// boolPtr is a convenience for the tri-state Thinking.Enable. +// boolPtr 是三态 Thinking.Enable 的便利构造。 func boolPtr(v bool) *bool { return &v } -// TestAnthropicChat_ThinkingBudgetLeavesRoomForTheAnswer is the design/04 §3.2 -// red line: a budget large enough to eat max_tokens must not be sent as-is — -// the adapter raises max_tokens so thinking and the visible answer both fit, and -// it logs the adjustment (never a silent rewrite). +// TestAnthropicChat_ThinkingBudgetLeavesRoomForTheAnswer 就是 design/04 §3.2 +// 的红线:大到能吃光 max_tokens 的预算不得照原样发送—— +// 适配器会抬高 max_tokens,让思考与可见答案都放得下, +// 并记录这次调整(绝不是一次静默改写)。 func TestAnthropicChat_ThinkingBudgetLeavesRoomForTheAnswer(t *testing.T) { t.Parallel() ch := make(chan anthropicCaptured, 1) @@ -35,8 +35,8 @@ func TestAnthropicChat_ThinkingBudgetLeavesRoomForTheAnswer(t *testing.T) { WithAnthropicLogger(log)) const budget = 4096 - // max_tokens equals the budget: without the guard the whole budget would be - // consumed by thinking and the answer would come back empty. + // max_tokens 恰好等于预算:没有这道守护,整个预算会被 + // 思考吃光,答案将空着返回。 if _, err := ad.Chat(context.Background(), Request{ Model: "claude-test", Messages: []Message{TextMessage(RoleUser, "hi")}, @@ -69,9 +69,9 @@ func TestAnthropicChat_ThinkingBudgetLeavesRoomForTheAnswer(t *testing.T) { } } -// TestAnthropicChat_ThinkingBudgetBelowMaxTokensUnchanged pins the non-saturated -// case: when the caller's max_tokens already leaves room, it is sent untouched -// and the budget stays strictly below it. +// TestAnthropicChat_ThinkingBudgetBelowMaxTokensUnchanged 钉住未饱和 +// 的情形:调用方的 max_tokens 本已留出空间时,它原样发送, +// 而预算严格低于它。 func TestAnthropicChat_ThinkingBudgetBelowMaxTokensUnchanged(t *testing.T) { t.Parallel() ch := make(chan anthropicCaptured, 1) @@ -104,8 +104,8 @@ func TestAnthropicChat_ThinkingBudgetBelowMaxTokensUnchanged(t *testing.T) { } } -// TestAnthropicChat_ThinkingDisabledOmitsField asserts the explicit off switch -// sends no thinking directive at all (Anthropic treats a missing field as off). +// TestAnthropicChat_ThinkingDisabledOmitsField 断言显式关闭开关 +// 完全不发送 thinking 指令(Anthropic 把字段缺失视为关闭)。 func TestAnthropicChat_ThinkingDisabledOmitsField(t *testing.T) { t.Parallel() ch := make(chan anthropicCaptured, 1) @@ -124,8 +124,8 @@ func TestAnthropicChat_ThinkingDisabledOmitsField(t *testing.T) { } } -// TestAnthropicChat_ThinkingBudgetRaisedToProtocolMinimum asserts a budget below -// the API minimum is raised (sending it as-is would 400) and reported. +// TestAnthropicChat_ThinkingBudgetRaisedToProtocolMinimum 断言低于 +// API 最小值的预算会被抬高(照发会 400)并上报。 func TestAnthropicChat_ThinkingBudgetRaisedToProtocolMinimum(t *testing.T) { t.Parallel() ch := make(chan anthropicCaptured, 1) @@ -151,8 +151,8 @@ func TestAnthropicChat_ThinkingBudgetRaisedToProtocolMinimum(t *testing.T) { } } -// TestOpenAIChat_ReasoningEffortPassthrough asserts a supported tier on a known -// provider is forwarded verbatim, with no warning. +// TestOpenAIChat_ReasoningEffortPassthrough 断言已知提供商上的 +// 受支持档位被原样转发,且不发出警告。 func TestOpenAIChat_ReasoningEffortPassthrough(t *testing.T) { t.Parallel() ch := make(chan capturedRequest, 1) @@ -175,10 +175,10 @@ func TestOpenAIChat_ReasoningEffortPassthrough(t *testing.T) { } } -// TestOpenAIChat_ReasoningEffortAliasMappedForDeepSeek asserts a DeepSeek -// compatibility alias is normalized to the tier the service applies (medium → -// high per the official mapping table) and reported, never graded down to a -// weaker tier of our own choosing. +// TestOpenAIChat_ReasoningEffortAliasMappedForDeepSeek 断言 DeepSeek 的 +// 兼容别名被归一化为服务采用的档位(按官方映射表 medium → +// high)并上报,绝不按我们自己挑的 +// 更弱档位降级。 func TestOpenAIChat_ReasoningEffortAliasMappedForDeepSeek(t *testing.T) { t.Parallel() ch := make(chan capturedRequest, 1) @@ -204,9 +204,9 @@ func TestOpenAIChat_ReasoningEffortAliasMappedForDeepSeek(t *testing.T) { } } -// TestOpenAIChat_ReasoningEffortDroppedForUnknownProvider asserts an -// unrecognized provider/model leaves the request untouched and reports the -// ignored field (design/04 §3.2: never silently ineffective). +// TestOpenAIChat_ReasoningEffortDroppedForUnknownProvider 断言无法识别的 +// 提供商/模型会让请求保持不动,并上报被 +// 忽略的字段(design/04 §3.2:绝不静默失效)。 func TestOpenAIChat_ReasoningEffortDroppedForUnknownProvider(t *testing.T) { t.Parallel() ch := make(chan capturedRequest, 1) @@ -233,10 +233,10 @@ func TestOpenAIChat_ReasoningEffortDroppedForUnknownProvider(t *testing.T) { } } -// TestOpenAIChat_QwenEnableThinkingReportedNotForwarded documents the deliberate -// choice for the Qwen3 hard switch: the OpenAI-compatible protocol has no -// portable wire shape for enable_thinking, so this version reports it and leaves -// the request byte-identical instead of guessing a shape that could 400. +// TestOpenAIChat_QwenEnableThinkingReportedNotForwarded 记录了针对 Qwen3 +// 硬开关的刻意选择:OpenAI 兼容协议没有 enable_thinking 的 +// 可移植线格式,所以本版本上报它,并让请求 +// 逐字节保持原样,而不是去猜一个可能导致 400 的形状。 func TestOpenAIChat_QwenEnableThinkingReportedNotForwarded(t *testing.T) { t.Parallel() ch := make(chan capturedRequest, 1) @@ -277,9 +277,9 @@ func TestOpenAIChat_QwenEnableThinkingReportedNotForwarded(t *testing.T) { } } -// TestOpenAIChat_ThinkingBudgetReportedIgnored asserts a token budget (an -// Anthropic-style directive) is reported as unmappable on an OpenAI-compatible -// endpoint rather than silently dropped. +// TestOpenAIChat_ThinkingBudgetReportedIgnored 断言 token 预算 +// (Anthropic 风格的指令)在 OpenAI 兼容端点上会被上报为 +// 无法映射,而不是被静默丢弃。 func TestOpenAIChat_ThinkingBudgetReportedIgnored(t *testing.T) { t.Parallel() ch := make(chan capturedRequest, 1) @@ -305,8 +305,8 @@ func TestOpenAIChat_ThinkingBudgetReportedIgnored(t *testing.T) { } } -// TestAnthropicChatStream_ThinkingBudgetLeavesRoomForTheAnswer runs the same -// red-line check on the streaming path, which builds its own request body. +// TestAnthropicChatStream_ThinkingBudgetLeavesRoomForTheAnswer 在流式 +// 路径上跑同一条红线检查,该路径会自建请求体。 func TestAnthropicChatStream_ThinkingBudgetLeavesRoomForTheAnswer(t *testing.T) { t.Parallel() ch := make(chan anthropicCaptured, 1) @@ -337,8 +337,8 @@ func TestAnthropicChatStream_ThinkingBudgetLeavesRoomForTheAnswer(t *testing.T) } } -// TestReasoningVendorFor_Classification locks the vendor detection the whitelist -// depends on, including the fallback to the base URL. +// TestReasoningVendorFor_Classification 锁住白名单所依赖的厂商 +// 检测,包括回退到 base URL 的那条路径。 func TestReasoningVendorFor_Classification(t *testing.T) { t.Parallel() diff --git a/internal/runtime/model/router.go b/internal/runtime/model/router.go index 5c2fd21..7fca439 100644 --- a/internal/runtime/model/router.go +++ b/internal/runtime/model/router.go @@ -2,22 +2,22 @@ package model import "strings" -// Router maps roles to model names. An unknown role resolves to the configured -// default model. A Router also carries a provider per role (defaulting to the -// global provider) so a multi-vendor deployment can pair a role's model with the -// correct protocol; the provider selection is surfaced via RouteProvider. +// Router 把角色映射到模型名。未知角色解析为已配置的 +// 默认模型。Router 还按角色携带一个提供商(默认用 +// 全局提供商),好让多厂商部署能把角色的模型与 +// 正确协议配对;提供商选择通过 RouteProvider 暴露。 // -// Per 裁决 03.4.1 the "role" of per-role routing is the **persona** a turn -// answers as, not the LLM message role: a persona binding wins over the -// message-role binding, and a persona without a binding keeps the previous -// behavior. The binding carrier is the active models.json group -// (runtimeconfig.Group.PersonaModels / PersonaProviders). +// 按裁决 03.4.1,按角色路由里的「角色」是本回合以之 +// 作答的 **persona**,而不是 LLM 消息角色:persona 绑定优先于 +// 消息角色绑定,而未绑定的 persona 保持原有 +// 行为。绑定的载体是生效的 models.json 组 +// (runtimeconfig.Group.PersonaModels / PersonaProviders)。 // -// Image routing is deliberately NOT this router's job any more (裁决 -// 14.4.1/14.4.5): the single authority for layered image routing is -// runtime/multimodal/vision.Router, which the loop calls with the main model's -// real image-input capability. A "has image → switch to the vision model" rule -// here used to bypass that decision. +// 图像路由刻意不再是本 Router 的职责(裁决 +// 14.4.1/14.4.5):分层图像路由的唯一权威是 +// runtime/multimodal/vision.Router,由 loop 带着主模型真实的 +// 图像输入能力去调用它。这里曾有的「带图 → 切到视觉模型」规则 +// 绕过了那个决策。 type Router struct { defaultModel string byRole map[Role]string @@ -28,25 +28,25 @@ type Router struct { personaProvider map[string]string } -// RouterOption configures a Router. +// RouterOption 用于配置 Router。 type RouterOption func(*Router) -// WithRoleModel binds a model to a role. +// WithRoleModel 把模型绑定到角色。 func WithRoleModel(role Role, model string) RouterOption { return func(r *Router) { r.byRole[role] = model } } -// WithVisionModel configures the dedicated vision model. It is kept for -// backwards compatibility (tests and callers that route a pure image request by -// model name) but production no longer sets it: image routing belongs to -// vision.Router (裁决 14.4.1/14.4.5). +// WithVisionModel 配置专用视觉模型。它保留下来是为了 +// 向后兼容(测试与按模型名路由纯图像请求的调用方), +// 但生产路径不再设置它:图像路由属于 +// vision.Router(裁决 14.4.1/14.4.5)。 func WithVisionModel(model string) RouterOption { return func(r *Router) { r.visionModel = model } } -// WithPersonaModel binds a model to a persona (裁决 03.4.1). A blank persona or -// model is ignored so a partially configured group cannot create a binding that -// routes a persona to an empty model. +// WithPersonaModel 把模型绑定到 persona(裁决 03.4.1)。空的 persona 或 +// 模型会被忽略,因此半配置的组无法造出一个把 persona 路由到 +// 空模型的绑定。 func WithPersonaModel(persona, model string) RouterOption { return func(r *Router) { if p := trimKey(persona); p != "" && model != "" { @@ -55,9 +55,9 @@ func WithPersonaModel(persona, model string) RouterOption { } } -// WithPersonaProvider binds a vendor protocol to a persona, so one persona can -// answer through a different vendor than the group default (裁决 03.4.1). Pair it -// with WithPersonaModel. +// WithPersonaProvider 把厂商协议绑定到 persona,这样一个 persona 可以 +// 走与组默认不同的厂商作答(裁决 03.4.1)。请与 +// WithPersonaModel 配对使用。 func WithPersonaProvider(persona, provider string) RouterOption { return func(r *Router) { if p := trimKey(persona); p != "" && provider != "" { @@ -66,21 +66,21 @@ func WithPersonaProvider(persona, provider string) RouterOption { } } -// WithDefaultProvider sets the provider returned by RouteProvider for roles with -// no explicit binding. It defaults to openai. +// WithDefaultProvider 设置 RouteProvider 对没有显式绑定的角色 +// 返回的提供商。它默认是 openai。 func WithDefaultProvider(provider string) RouterOption { return func(r *Router) { r.defaultProvider = provider } } -// WithRoleProvider binds a provider to a role, so a multi-vendor deployment can -// route a role to a different vendor's protocol (e.g. one role on anthropic, one -// on openai). Pair it with WithRoleModel for the role's model name. +// WithRoleProvider 把提供商绑定到角色,好让多厂商部署 +// 能把角色路由到另一厂商的协议(例如一个角色走 anthropic、一个 +// 走 openai)。请与 WithRoleModel 配对以给出该角色的模型名。 func WithRoleProvider(role Role, provider string) RouterOption { return func(r *Router) { r.providerByRole[role] = provider } } -// NewRouter builds a Router with a default model applied to any role that has -// no explicit binding. +// NewRouter 构建 Router,并对任何没有显式绑定的角色 +// 套用默认模型。 func NewRouter(defaultModel string, opts ...RouterOption) *Router { r := &Router{ defaultModel: defaultModel, @@ -96,11 +96,11 @@ func NewRouter(defaultModel string, opts ...RouterOption) *Router { return r } -// trimKey normalizes a persona id or provider name before it is stored, so -// " cyrene" and "cyrene" cannot become two different bindings. +// trimKey 在存入之前归一化 persona id 或提供商名,因此 +// " cyrene" 与 "cyrene" 不会变成两个不同的绑定。 func trimKey(k string) string { return strings.TrimSpace(k) } -// Route returns the model for a role, falling back to the default model. +// Route 返回角色的模型,回退到默认模型。 func (r *Router) Route(role Role) string { if m, ok := r.byRole[role]; ok && m != "" { return m @@ -108,11 +108,11 @@ func (r *Router) Route(role Role) string { return r.defaultModel } -// RouteProvider returns the provider for a role, falling back to the router's -// default provider. It is the routing hook for a multi-vendor deployment: each -// role can carry its own provider+model pairing, and roles without a binding fall -// back to the global provider. The runtime/loop is expected to build a per-role -// adapter or chain from this decision (the provider-aware factory is the builder). +// RouteProvider 返回角色的提供商,回退到 Router 的 +// 默认提供商。它是多厂商部署的路由钩子:每个 +// 角色可以携带自己的提供商+模型配对,而未绑定的角色回 +// 退到全局提供商。runtime/loop 应据该决策构建按角色的 +// 适配器或链(提供商感知工厂就是构建器)。 func (r *Router) RouteProvider(role Role) string { if p, ok := r.providerByRole[role]; ok && p != "" { return p @@ -120,16 +120,16 @@ func (r *Router) RouteProvider(role Role) string { return r.defaultProvider } -// RouteRequest picks the model for a request and reports whether the request -// carries image content. When the request carries image content and a vision -// model is configured, the vision model is returned with needsVision set to -// true. Otherwise the role of the last message drives routing (user by -// default). +// RouteRequest 为请求挑出模型,并报告该请求 +// 是否携带图像内容。当请求携带图像内容且已配置 +// 视觉模型时,返回视觉模型并把 needsVision 置为 +// true。否则由最后一条消息的角色驱动路由(默认 +// user)。 // -// Deprecated in the production path: the loop routes by persona via -// RouteRequestForPersona, and image routing belongs to vision.Router (裁决 -// 14.4.1/14.4.5). This method stays because it is the ModelRouter contract the -// agent package and the existing tests are built on. +// 在生产路径中已弃用:loop 改经 +// RouteRequestForPersona 按 persona 路由,图像路由属于 vision.Router(裁决 +// 14.4.1/14.4.5)。本方法保留是因为 agent 包与 +// 既有测试都建立在 ModelRouter 契约之上。 func (r *Router) RouteRequest(req Request) (model string, needsVision bool) { role := RoleUser if n := len(req.Messages); n > 0 { @@ -142,18 +142,18 @@ func (r *Router) RouteRequest(req Request) (model string, needsVision bool) { return r.Route(role), needsVision } -// PersonaRouter is the optional persona-aware routing extension of ModelRouter -// (裁决 03.4.1). The loop detects it on the agent's router and prefers it over -// RouteRequest, so a per-persona model/provider binding actually reaches the -// request. It is a separate interface so a router that only knows message roles -// keeps working unchanged. +// PersonaRouter 是 ModelRouter 可选的 persona 感知路由扩展 +// (裁决 03.4.1)。loop 会在 agent 的 router 上探测它,并优先于 +// RouteRequest 使用,从而让按 persona 的模型/提供商绑定真正到达 +// 请求。它单列为一个接口,好让只知道消息角色的 router +// 保持可用、不被改动。 type PersonaRouter interface { RouteRequestForPersona(personaID string, req Request) (model string, needsVision bool) } -// RouteForPersona returns the model bound to persona, falling back to the -// router's default model. Unlike Route (message role) this is the persona-level -// binding: 裁决 03.4.1 defines the "role" of per-role routing as the persona. +// RouteForPersona 返回绑定到 persona 的模型,回退到 +// Router 的默认模型。与 Route(消息角色)不同,这是 persona 级 +// 绑定:裁决 03.4.1 把按角色路由的「角色」定义为 persona。 func (r *Router) RouteForPersona(persona string) string { if m, ok := r.personaModel[trimKey(persona)]; ok && m != "" { return m @@ -161,8 +161,8 @@ func (r *Router) RouteForPersona(persona string) string { return r.defaultModel } -// RouteProviderForPersona returns the provider bound to persona, falling back to -// the router's default provider (itself the active group's protocol). +// RouteProviderForPersona 返回绑定到 persona 的提供商,回退到 +// Router 的默认提供商(其本身即生效组的协议)。 func (r *Router) RouteProviderForPersona(persona string) string { if p, ok := r.personaProvider[trimKey(persona)]; ok && p != "" { return p @@ -170,11 +170,11 @@ func (r *Router) RouteProviderForPersona(persona string) string { return r.defaultProvider } -// RouteRequestForPersona routes a request as the given persona: a persona -// binding wins over the message-role decision, and a persona without a binding -// falls back to RouteRequest so nothing changes for the unconfigured case. It -// reports whether the request carries an image, but never switches the model to -// the vision model — that decision belongs to vision.Router (裁决 14.4.1/14.4.5). +// RouteRequestForPersona 以给定 persona 路由请求:persona +// 绑定优先于消息角色决策,而没有绑定的 persona +// 回退到 RouteRequest,因此未配置的情形毫无变化。它 +// 报告请求是否携带图片,但绝不会把模型切到 +// 视觉模型——那个决策属于 vision.Router(裁决 14.4.1/14.4.5)。 func (r *Router) RouteRequestForPersona(personaID string, req Request) (model string, needsVision bool) { needsVision = HasImage(req.Messages) if m, ok := r.personaModel[trimKey(personaID)]; ok && m != "" { @@ -187,7 +187,7 @@ func (r *Router) RouteRequestForPersona(personaID string, req Request) (model st return r.Route(role), needsVision } -// HasImage reports whether any message carries an image content part. +// HasImage 报告是否有任何消息携带图片内容片段。 func HasImage(msgs []Message) bool { for _, m := range msgs { for _, part := range m.Content { diff --git a/internal/runtime/model/runtime.go b/internal/runtime/model/runtime.go index 89bac07..3b89d59 100644 --- a/internal/runtime/model/runtime.go +++ b/internal/runtime/model/runtime.go @@ -12,45 +12,45 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" ) -// ModelRouter is the routing contract the agent (and loop) depend on. *Router -// implements it; RuntimeLLM implements it too, so a hot-swappable provider can -// serve as both the adapter and the router without a separate router reference -// to keep in sync. +// ModelRouter 是 agent(与 loop)依赖的路由契约。*Router +// 实现它;RuntimeLLM 也实现它,因此一个可热切换的提供商 +// 可以同时充当适配器与路由器,无需另存一份 router 引用 +// 去保持同步。 type ModelRouter interface { RouteRequest(req Request) (model string, needsVision bool) } -// runtimeChainSnapshot is an immutable, self-contained LLM stack snapshot. It is -// stored and swapped atomically: a request that already captured a snapshot -// keeps using that exact chain+router for its whole call, so a reload never -// interrupts an in-flight request. +// runtimeChainSnapshot 是不可变、自包含的 LLM 栈快照。它 +// 被原子存储与换入:已经捕获某个快照的请求 +// 整次调用都用那条确切的链+路由器,所以一次重载绝不会 +// 打断进行中的请求。 type runtimeChainSnapshot struct { chain *Chain router *Router group runtimeconfig.Group groups []runtimeconfig.Group - // providerChains holds one chain per *other* protocol declared by the active - // group's personaProviders (裁决 03.4.1 的 per-persona provider:跨协议换 - // adapter). A persona whose provider binding resolves to one of these keys is - // served by that chain; every other request keeps the default chain above. + // providerChains 为生效组的 personaProviders 声明的每个 + // *其它* 协议各存一条链(裁决 03.4.1 的 per-persona provider:跨协议换 + // adapter)。提供商绑定解析到这些键之一的 persona 由 + // 那条链服务;其余所有请求仍走上面的默认链。 providerChains map[string]*Chain } -// RuntimeLLM is the hot-swappable LLM provider stack for the runtime. It -// implements Adapter (delegating to the current chain) and ModelRouter -// (delegating to the current router). A panel save reloads models.json, rebuilds -// a fresh chain+router and atomically swaps it in: new calls use the new stack -// while calls already in flight finish on the old one. +// RuntimeLLM 是运行时的可热切换 LLM 提供商栈。它 +// 实现 Adapter(委托给当前链)与 ModelRouter +// (委托给当前路由器)。面板保存会重载 models.json、重建 +// 一份新的链+路由器并原子换入:新调用使用新栈, +// 而进行中的调用在旧栈上跑完。 type RuntimeLLM struct { path string env config.LLMConfig log *slog.Logger - current atomic.Value // *runtimeChainSnapshot + current atomic.Value // 类型:*runtimeChainSnapshot } -// NewRuntimeLLM builds the provider stack from models.json at path, falling back -// to env. It loads the initial snapshot so the runtime is usable immediately. -// log may be nil (a discard logger is used). +// NewRuntimeLLM 由 path 处的 models.json 构建提供商栈,并回退 +// 到 env。它会加载初始快照,因此运行时立即可用。 +// log 可为 nil(此时使用丢弃式日志器)。 func NewRuntimeLLM(path string, env config.LLMConfig, log *slog.Logger) *RuntimeLLM { if log == nil { log = slog.New(slog.NewTextHandler(io.Discard, nil)) @@ -60,11 +60,11 @@ func NewRuntimeLLM(path string, env config.LLMConfig, log *slog.Logger) *Runtime return m } -// Path returns the models.json path this manager reads and reloads from. +// Path 返回本管理器读取并重载的 models.json 路径。 func (m *RuntimeLLM) Path() string { return m.path } -// Groups returns a copy of the current loaded groups; the active group (the one -// a new request uses by default) is index 0. +// Groups 返回当前已加载组的一份拷贝;生效组(新请求 +// 默认使用的那个)是下标 0。 func (m *RuntimeLLM) Groups() []runtimeconfig.Group { src := m.load().groups out := make([]runtimeconfig.Group, len(src)) @@ -72,18 +72,18 @@ func (m *RuntimeLLM) Groups() []runtimeconfig.Group { return out } -// Group returns the active group (index 0). +// Group 返回生效组(下标 0)。 func (m *RuntimeLLM) Group() runtimeconfig.Group { return m.load().group } -// Current returns the current chain and router. The returned chain stays valid -// for calls already in flight; new calls pick up the latest swap. +// Current 返回当前链与路由器。返回的链对进行中的 +// 调用仍然有效;新调用会拿到最新的换入结果。 func (m *RuntimeLLM) Current() (*Chain, *Router) { snap := m.load() return snap.chain, snap.router } -// Reload re-reads models.json (falling back to env) and atomically rebuilds the -// in-use chain+router. In-flight calls keep the previous snapshot. +// Reload 重新读取 models.json(回退到 env)并原子重建 +// 在用的链+路由器。进行中的调用保持原快照。 func (m *RuntimeLLM) Reload() error { groups, warn := runtimeconfig.LoadModels(m.path, m.env) if warn != nil { @@ -92,19 +92,19 @@ func (m *RuntimeLLM) Reload() error { return m.swap(groups) } -// Provider returns the active group's provider protocol. +// Provider 返回生效组的提供商协议。 func (m *RuntimeLLM) Provider() string { return m.load().group.Provider } -// Model returns the active group's primary model name. +// Model 返回生效组的主模型名。 func (m *RuntimeLLM) Model() string { return m.load().group.Model } -// Name implements Adapter, returning the active group id. +// Name 实现 Adapter,返回生效组 id。 func (m *RuntimeLLM) Name() string { return m.load().group.ID } -// Chat implements Adapter, delegating to the chain the request's persona -// resolves to. A request that began before a reload completes on the snapshot it -// captured. When no model is configured (nil chain) it returns ErrNoModel rather -// than panicking. +// Chat 实现 Adapter,委托给请求的 persona 所解析到的 +// 链。在重载完成前开始的请求,会在它已捕获的快照上 +// 跑完。未配置模型(链为 nil)时返回 ErrNoModel,而不是 +// panic。 func (m *RuntimeLLM) Chat(ctx context.Context, req Request, opts Options) (Response, error) { chain := m.chainFor(req.PersonaID) if chain == nil { @@ -113,7 +113,7 @@ func (m *RuntimeLLM) Chat(ctx context.Context, req Request, opts Options) (Respo return chain.Chat(ctx, req, opts) } -// ChatStream implements Adapter. +// ChatStream 实现 Adapter。 func (m *RuntimeLLM) ChatStream(ctx context.Context, req Request, onEvent func(StreamEvent)) (Response, error) { chain := m.chainFor(req.PersonaID) if chain == nil { @@ -122,20 +122,20 @@ func (m *RuntimeLLM) ChatStream(ctx context.Context, req Request, onEvent func(S return chain.ChatStream(ctx, req, onEvent) } -// chainFor resolves the chain that serves this persona (裁决 03.4.1 的 -// per-persona provider). It consumes Router.RouteProviderForPersona — the -// provider decision the router already made for this persona — and only switches -// when BOTH hold: +// chainFor 解出服务该 persona 的链(裁决 03.4.1 的 +// per-persona provider)。它消费 Router.RouteProviderForPersona —— +// 路由器已为该 persona 做出的提供商决策 —— 且只在 +// 二者同时成立时才切换: // -// - the persona has its own model binding (an entry in personaProviders alone -// would send the group's default model name to another vendor's endpoint), -// - and a chain exists for that provider (a declared protocol with no matching -// group in models.json is reported when the snapshot is built, and the -// persona then keeps the default chain rather than failing the turn). +// - 该 persona 有自己的模型绑定(只写 personaProviders 一项 +// 会把组的默认模型名发到另一厂商的端点), +// - 且该提供商存在一条链(声明了协议但 models.json 里没有 +// 对应组的情况会在构建快照时上报, +// 该 persona 随后保留默认链,而不是让回合失败)。 // -// Everything else — no persona, no binding, same protocol as the active group — -// returns the default chain, so a deployment without per-persona providers -// behaves exactly as before. +// 其余情形 —— 无 persona、无绑定、与生效组同协议 —— +// 返回默认链,因此没有按 persona 提供商的部署 +// 行为与之前完全一致。 func (m *RuntimeLLM) chainFor(persona string) *Chain { snap := m.load() if p := trimKey(persona); p != "" && snap.router != nil { @@ -148,7 +148,7 @@ func (m *RuntimeLLM) chainFor(persona string) *Chain { return snap.chain } -// RouteRequest implements ModelRouter, delegating to the current router. +// RouteRequest 实现 ModelRouter,委托给当前路由器。 func (m *RuntimeLLM) RouteRequest(req Request) (string, bool) { r := m.load().router if r == nil { @@ -157,9 +157,9 @@ func (m *RuntimeLLM) RouteRequest(req Request) (string, bool) { return r.RouteRequest(req) } -// RouteRequestForPersona implements PersonaRouter (裁决 03.4.1): the loop routes -// a turn as the persona it answers as, and a per-persona binding in the active -// models.json group reaches the request through here. +// RouteRequestForPersona 实现 PersonaRouter(裁决 03.4.1):loop 以 +// 本回合作答所依据的 persona 来路由,生效 models.json 组里的 +// 按 persona 绑定经由这里到达请求。 func (m *RuntimeLLM) RouteRequestForPersona(personaID string, req Request) (string, bool) { r := m.load().router if r == nil { @@ -168,8 +168,8 @@ func (m *RuntimeLLM) RouteRequestForPersona(personaID string, req Request) (stri return r.RouteRequestForPersona(personaID, req) } -// loadInitial builds the first snapshot; a build failure leaves a nil chain so -// calls report a clear no-model error instead of panicking. +// loadInitial 构建首个快照;构建失败会留下 nil 链, +// 使调用报告清晰的「无模型」错误而不是 panic。 func (m *RuntimeLLM) loadInitial() { groups, warn := runtimeconfig.LoadModels(m.path, m.env) if warn != nil { @@ -178,8 +178,8 @@ func (m *RuntimeLLM) loadInitial() { _ = m.swap(groups) } -// swap rebuilds and atomically stores a snapshot for groups. A blank slice falls -// back to the env-synthesized group so there is always at least one. +// swap 为 groups 重建快照并原子存储。空切片会回 +// 退到 env 合成的组,因此始终至少有一个组。 func (m *RuntimeLLM) swap(groups []runtimeconfig.Group) error { if len(groups) == 0 { groups = runtimeconfig.GroupFromEnv(m.env) @@ -192,8 +192,8 @@ func (m *RuntimeLLM) swap(groups []runtimeconfig.Group) error { return nil } -// load returns the current snapshot, or an empty (nil chain) one when the -// manager was never initialized. +// load 返回当前快照;管理器从未初始化时返回一个空 +// (nil 链)快照。 func (m *RuntimeLLM) load() *runtimeChainSnapshot { if v := m.current.Load(); v != nil { return v.(*runtimeChainSnapshot) @@ -201,18 +201,18 @@ func (m *RuntimeLLM) load() *runtimeChainSnapshot { return &runtimeChainSnapshot{} } -// buildSnapshot turns the active group (index 0) into a chain+router. A group -// without a base URL/model yields a nil chain (no model configured), surfaced as -// ErrNoModel by the adapter methods. +// buildSnapshot 把生效组(下标 0)变成链+路由器。没有 +// base URL/模型的组会产出 nil 链(未配置模型),由适配器 +// 方法表现为 ErrNoModel。 // -// The env-local rung is copied onto the group's flat config because a models.json -// group does not carry it: without that copy the third degradation level -// (裁决 03.4.3) would silently disappear as soon as models.json exists. +// env 本地级会被复制到组的扁平配置上,因为 models.json +// 的组不携带它:没有这次复制,第三级降级 +// (裁决 03.4.3)只要 models.json 一存在就会静默消失。 // -// The router is seeded with the active group's per-persona bindings -// (裁决 03.4.1). It is deliberately NOT given the env vision model: image routing -// is owned by vision.Router, and a "has image → switch model" rule here bypassed -// that decision (裁决 14.4.1/14.4.5). +// 路由器以生效组的按 persona 绑定播种 +// (裁决 03.4.1)。它刻意不给 env 的视觉模型:图像路由 +// 归 vision.Router 所有,而这里曾有的「带图 → 切换模型」规则绕过了 +// 那个决策(裁决 14.4.1/14.4.5)。 func buildSnapshot(groups []runtimeconfig.Group, env config.LLMConfig, log *slog.Logger) (*runtimeChainSnapshot, error) { active := groups[0] cfg := active.ToLLMConfig() @@ -222,9 +222,9 @@ func buildSnapshot(groups []runtimeconfig.Group, env config.LLMConfig, log *slog return nil, err } if chain != nil { - // Budgets are resolved inside the chain: the active group's own - // maxTokens (models.json) wins, and env.FallbackMaxTokens only caps the - // last step of a chain that really has a fallback (design/04 §3.1). + // 预算在链内部解出:生效组自己的 + // maxTokens(models.json)优先,env.FallbackMaxTokens 只约束 + // 确实有降级步骤的链的最后一步(design/04 §3.1)。 chain = chain.WithTokenBudget(active.MaxTokens, env.FallbackMaxTokens) } router := NewRouter(DefaultModelForProvider(cfg), personaRouterOptions(active, cfg)...) @@ -237,17 +237,17 @@ func buildSnapshot(groups []runtimeconfig.Group, env config.LLMConfig, log *slog }, nil } -// personaProviderChains builds one chain per protocol that the active group's -// personaProviders declares and that models.json actually provides a group for -// (裁决 03.4.1 的 per-persona provider:跨协议换 adapter). The persona's own -// model name is pinned as the chain's primary model, so a caller that stamps the -// persona without a routed model name still reaches the right endpoint. +// personaProviderChains 为生效组 personaProviders 声明的、且 +// models.json 确实提供了对应组的每种协议各构建一条链 +// (裁决 03.4.1 的 per-persona provider:跨协议换 adapter)。persona 自己的 +// 模型名被钉为该链的主模型,因此即便调用方打上的 +// persona 没带被路由的模型名,也仍能到达正确端点。 // -// A persona that names the same protocol as the active group needs no chain of -// its own: the default chain already speaks that protocol, and the per-persona -// model name reaches it through the router. A personaProviders entry whose -// protocol has no group is reported and skipped — a half-built chain could only -// fail, and the persona keeps the default chain instead. +// 与生效组同协议的 persona 不需要自己 +// 的链:默认链本就说那个协议,而按 persona 的 +// 模型名经路由器到达它。personaProviders 中协议 +// 没有对应组的条目会被上报并跳过——半构建的链只会 +// 失败,该 persona 改为保留默认链。 func personaProviderChains(groups []runtimeconfig.Group, active runtimeconfig.Group, env config.LLMConfig, log *slog.Logger) map[string]*Chain { if len(active.PersonaProviders) == 0 { return nil @@ -283,12 +283,12 @@ func personaProviderChains(groups []runtimeconfig.Group, active runtimeconfig.Gr return out } -// personaChain builds and budgets the chain serving one persona through another -// vendor's group: the group's flat config with the persona's own model name as -// the primary model, plus the shared local last rung. The reasoning shape -// (reasoning.kind) travels with the group's config, so a persona routed to -// another vendor keeps that vendor's switch shape. It reports the reason and -// returns nil when the group cannot serve a chain. +// personaChain 构建并通过另一厂商的组服务某个 persona 的链,并 +// 为其定预算:组的扁平配置,persona 自己的模型名作为 +// 主模型,加上共享的本地最后一级。推理形状 +// (reasoning.kind)随组的配置一起走,因此被路由到 +// 另一厂商的 persona 仍保持该厂商的开关形状。它会报告原因, +// 并在该组无法服务一条链时返回 nil。 func personaChain(g runtimeconfig.Group, model string, env config.LLMConfig, log *slog.Logger) *Chain { cfg := g.ToLLMConfig() if cfg.Provider == ProviderAnthropic { @@ -310,10 +310,10 @@ func personaChain(g runtimeconfig.Group, model string, env config.LLMConfig, log return chain.WithTokenBudget(g.MaxTokens, env.FallbackMaxTokens) } -// groupForProvider returns the first group speaking the given protocol. The group -// order in models.json is the operator's choice, so "first match" is a stable, -// documentable rule rather than a silent pick — a duplicate protocol is reported -// by the builder when it matters. +// groupForProvider 返回第一条说指定协议的组。models.json 里 +// 组的顺序由运维者决定,所以「第一个匹配」是一条稳定、 +// 可记录的规则,而不是一次静默挑选——重复协议会在 +// 要紧的时候由构建器上报。 func groupForProvider(groups []runtimeconfig.Group, provider string) (runtimeconfig.Group, bool) { for _, g := range groups { if g.Provider == provider { @@ -323,9 +323,9 @@ func groupForProvider(groups []runtimeconfig.Group, provider string) (runtimecon return runtimeconfig.Group{}, false } -// personaRouterOptions turns the active group's per-persona model/provider -// bindings into router options (裁决 03.4.1). It is a helper so buildSnapshot -// stays inside the 80-line function budget. +// personaRouterOptions 把生效组的按 persona 模型/提供商 +// 绑定转换成 router 选项(裁决 03.4.1)。它是一个辅助函数,好让 buildSnapshot +// 留在 80 行函数预算之内。 func personaRouterOptions(active runtimeconfig.Group, cfg config.LLMConfig) []RouterOption { opts := []RouterOption{WithDefaultProvider(cfg.Provider)} for persona, model := range active.PersonaModels { diff --git a/internal/runtime/model/runtime_test.go b/internal/runtime/model/runtime_test.go index 653fc88..6589519 100644 --- a/internal/runtime/model/runtime_test.go +++ b/internal/runtime/model/runtime_test.go @@ -11,7 +11,7 @@ import ( ) func rtEnv() config.LLMConfig { - // Env fallback matches "openai default" (provider not anthropic). + // env 降级符合「openai 默认」(提供商不是 anthropic)。 return config.LLMConfig{ Provider: ProviderOpenAI, BaseURL: "https://api.openai.com/v1", @@ -30,7 +30,7 @@ func writeRTFile(t *testing.T, groups []runtimeconfig.Group) string { } func TestRuntimeLLM_EnvFallback(t *testing.T) { - // Missing file → env fallback provider/model. + // 文件缺失 → env 降级的提供商/模型。 m := NewRuntimeLLM(filepath.Join(t.TempDir(), "nope.json"), rtEnv(), nil) if m.Provider() != ProviderOpenAI || m.Model() != "gpt-4o" { t.Fatalf("fallback Provider=%q Model=%q, want openai/gpt-4o", m.Provider(), m.Model()) @@ -40,7 +40,7 @@ func TestRuntimeLLM_EnvFallback(t *testing.T) { t.Fatal("router is nil after env fallback") } if chain == nil { - // No real endpoint is fine; the adapter should report ErrNoModel, not panic. + // 没有真实端点没关系;适配器应报 ErrNoModel,而不是 panic。 if _, err := m.Chat(context.Background(), Request{Messages: nil}, Options{}); err != ErrNoModel { t.Fatalf("Chat on empty config err = %v, want ErrNoModel", err) } @@ -68,7 +68,7 @@ func TestRuntimeLLM_ReloadReflectsNewProviderModel(t *testing.T) { if m.Model() != "gpt-4o" { t.Fatalf("before reload Model = %q, want gpt-4o", m.Model()) } - // Rewrite the file with an anthropic provider/model, then reload. + // 用 anthropic 提供商/模型重写该文件,然后重载。 if err := runtimeconfig.SaveModels(path, []runtimeconfig.Group{ {ID: "claude", Provider: ProviderAnthropic, BaseURL: "https://api.anthropic.com", APIKey: "sk-ant", Model: "claude-sonnet-4"}, }); err != nil { @@ -129,15 +129,15 @@ func TestRuntimeLLM_ConcurrentReadDuringReloadNoPanic(t *testing.T) { return } if chain == nil { - // A nil chain means the config had no base/model; our test file has one, - // so a swap must always produce a chain. Guard anyway. + // nil 链意味着配置里没有 base/model;我们的测试文件里有, + // 所以一次换入必须总能产出链。仍然加一道守护。 t.Error("chain is nil during concurrent read") return } } }() } - // Reload a number of times while the readers spin. + // 在读方自旋期间重载若干次。 for i := 0; i < 200; i++ { if err := m.Reload(); err != nil { t.Fatalf("reload %d: %v", i, err) diff --git a/internal/runtime/model/stream.go b/internal/runtime/model/stream.go index f80cbdb..475e1b7 100644 --- a/internal/runtime/model/stream.go +++ b/internal/runtime/model/stream.go @@ -1,59 +1,59 @@ package model -// StreamKind enumerates the kinds of structured events produced while a -// completion streams. It mirrors OpenClaw's AssistantMessageEvent segments -// (text / thinking / tool_call) plus a tool-execution progress event, so the -// frontend can render each segment into its own surface (body, collapsed -// thinking block, tool card) rather than accumulating a single opaque token -// stream. +// StreamKind 枚举补全流式过程中产生的结构化事件种类。 +// 它对应 OpenClaw 的 AssistantMessageEvent 片段 +// (文本 / 思考 / tool_call)外加一个工具执行进度事件, +// 因此前端可以把每个片段渲染到自己的界面元素(正文、折叠 +// 思考块、工具卡片),而不是累积成一条不透明的 token +// 流。 type StreamKind string const ( - // StreamKindTextStart opens the body-text segment. The single text segment - // of a response is bracketed by TextStart/TextEnd; deltas fill it. + // StreamKindTextStart 打开正文文本片段。一次响应的单个文本 + // 片段由 TextStart/TextEnd 包起来;增量填充其中。 StreamKindTextStart StreamKind = "text_start" - // StreamKindTextDelta is one body-text token fragment. + // StreamKindTextDelta 是一个正文文本 token 片段。 StreamKindTextDelta StreamKind = "text_delta" - // StreamKindTextEnd closes the body-text segment. + // StreamKindTextEnd 关闭正文文本片段。 StreamKindTextEnd StreamKind = "text_end" - // StreamKindThinkingStart opens a chain-of-thought segment (frontend - // collapsed block). Reserved: adapters that report reasoning emit it. + // StreamKindThinkingStart 打开一个思维链片段(前端 + // 折叠块)。保留:上报推理的适配器会发出它。 StreamKindThinkingStart StreamKind = "thinking_start" - // StreamKindThinkingDelta is one reasoning token fragment. + // StreamKindThinkingDelta 是一个推理 token 片段。 StreamKindThinkingDelta StreamKind = "thinking_delta" - // StreamKindThinkingEnd closes a reasoning segment. + // StreamKindThinkingEnd 关闭一个推理片段。 StreamKindThinkingEnd StreamKind = "thinking_end" - // StreamKindToolCallStart opens a tool invocation's streaming form. + // StreamKindToolCallStart 打开一次工具调用的流式形态。 StreamKindToolCallStart StreamKind = "tool_call_start" - // StreamKindToolCallDelta is a fragment of the tool arguments (e.g. the - // incremental input_json of an Anthropic tool_use block). + // StreamKindToolCallDelta 是工具参数的一个片段(例如 + // Anthropic tool_use 块增量式的 input_json)。 StreamKindToolCallDelta StreamKind = "tool_call_delta" - // StreamKindToolCallEnd closes a tool invocation block. + // StreamKindToolCallEnd 关闭一个工具调用块。 StreamKindToolCallEnd StreamKind = "tool_call_end" - // StreamKindToolExecutionStart opens the execution of a tool the model - // requested (design/04 §1.1: entering execution). It is emitted by the tool - // loop, not the model adapter; Phase carries the running label. + // StreamKindToolExecutionStart 打开模型所请求工具的 + // 执行(design/04 §1.1:进入执行)。它由工具循环 + // 发出,而不是模型适配器;Phase 携带 running 标签。 StreamKindToolExecutionStart StreamKind = "tool_execution_start" - // StreamKindToolExecutionUpdate reports intermediate progress or partial - // output while a requested tool runs (design/04 §1.1). Multiple frames per - // execution are allowed; the wire mapping is defined, and a producer appears - // once tools can report mid-execution progress. + // StreamKindToolExecutionUpdate 报告所请求工具运行期间的 + // 中间进度或部分输出(design/04 §1.1)。每次执行允许 + // 多帧;线格式映射已定义,等工具能上报执行中进度时 + // 就会有生产者。 StreamKindToolExecutionUpdate StreamKind = "tool_execution_update" - // StreamKindToolExecutionEnd closes a tool execution with its terminal - // phase (done or error). It is emitted by the tool loop after Invoke - // returns. + // StreamKindToolExecutionEnd 以终态阶段(done 或 error) + // 关闭一次工具执行。它由工具循环在 Invoke + // 返回后发出。 StreamKindToolExecutionEnd StreamKind = "tool_execution_end" - // StreamKindTurnStart opens an assistant turn at the loop's run boundary - // (design/04 §1.1 turn.start). It carries no payload: the frame shell's - // session/turn identity brackets the turn on the client. + // StreamKindTurnStart 在 loop 的运行边界打开一个助手回合 + // (design/04 §1.1 turn.start)。它不携带负载:帧外壳的 + // 会话/回合标识在客户端一侧把该回合包起来。 StreamKindTurnStart StreamKind = "turn_start" - // StreamKindTurnEnd closes an assistant turn at the loop's run boundary, - // however the run settled (final answer, guardrail stop, terminated tool - // loop, exhausted rounds or an error). + // StreamKindTurnEnd 在 loop 的运行边界关闭一个助手回合, + // 无论运行如何收场(最终答复、护栏停止、被终止的工具 + // 循环、轮次耗尽或错误)。 StreamKindTurnEnd StreamKind = "turn_end" ) @@ -69,48 +69,48 @@ const ( ToolPhaseError = "error" ) -// StreamEvent is a single structured event in a streamed completion. It replaces -// the former plain text delta as the unit delivered by Adapter.ChatStream, so a -// consumer can distinguish body text from tool calls (and, in time, reasoning -// segments) and track each by Index/ID. +// StreamEvent 是流式补全里的单个结构化事件。它取代 +// 原先的纯文本增量,成为 Adapter.ChatStream 投递的单位, +// 因此消费者能区分正文文本、工具调用(以及将来还有推理 +// 片段),并按 Index/ID 跟踪各自。 type StreamEvent struct { - // Kind classifies the event (see StreamKind). + // Kind 对事件分类(见 StreamKind)。 Kind StreamKind - // Delta carries the incremental payload: body text for TextDelta, reasoning - // text for ThinkingDelta, or a partial_argument fragment for ToolCallDelta. - // Tool-execution events never pack their phase or tool name in here — - // those live in the structured Phase/Tool fields. + // Delta 携带增量负载:TextDelta 时为正文文本,ThinkingDelta + // 时为推理文本,ToolCallDelta 时为 partial_argument 片段。 + // 工具执行事件绝不把阶段或工具名塞进这里—— + // 那些住在结构化的 Phase/Tool 字段里。 Delta string - // Index is the zero-based content-block index the event belongs to, when the - // provider reports one. The frontend uses it to correlate a segment's - // start/delta/end events. + // Index 是事件所属内容块的零基索引(提供商上报时)。 + // 前端用它关联一个片段的 + // start/delta/end 事件。 Index int - // ID is the provider block/index identifier for a tool call (e.g. Anthropic's - // tool_use block id). Empty for text segments. Tool-execution events reuse it - // so the client can correlate an execution with its tool_call.* frames. + // ID 是某次工具调用的提供商块/索引标识(例如 Anthropic 的 + // tool_use 块 id)。文本片段为空。工具执行事件复用它, + // 好让客户端能把一次执行与它的 tool_call.* 帧关联起来。 ID string - // Tool is the invoked tool's name on a tool_execution.* event. Empty on - // every other kind. + // Tool 是 tool_execution.* 事件上被调用工具的名字。 + // 其它所有种类为空。 Tool string - // Phase is the fine-grained execution label (ToolPhase*) on a - // tool_execution.* event: running on start/update, done or error on end. - // Empty on every other kind. + // Phase 是 tool_execution.* 事件上的细粒度执行标签 + // (ToolPhase*):start/update 时为 running,end 时为 done 或 error。 + // 其它所有种类为空。 Phase string - // StreamID identifies the single model stream (one ChatStream call) a frame - // belongs to. The loop stamps it at the stream boundary so every frame of - // the same streamed message shares it, while a multi-step turn's later - // model calls get fresh ids; consumers distinguish it from TurnID, which - // stays constant across the whole turn. + // StreamID 标识某一帧所属的那一次模型流(一次 ChatStream 调用)。 + // loop 在流边界打上它,使同一条流式消息的每一帧 + // 共享它,而多步回合后续的模型调用会拿到新 id; + // 消费者据此把它与 TurnID 区分开——后者 + // 在整个回合中保持不变。 StreamID string - // SessionID is the conversation the streamed event belongs to. It is stamped - // by the loop at the turn boundary (loop.streamStep), which reads the session - // the Run was started for; a model adapter, running below the loop and with no - // notion of a session, leaves it zero. A consumer that routes the event (the - // WS hub) reads it to target the right conversation. + // SessionID 是该流式事件所属的会话。它由 loop 在回合 + // 边界打上(loop.streamStep),那里读取 Run 启动时所用的 + // 会话;模型适配器跑在 loop 之下、没有 + // 会话概念,所以留零值。路由该事件的消费者(WS hub) + // 读它来定位正确的会话。 SessionID string - // TurnID is the assistant turn the event belongs to. Like SessionID it is - // stamped by the loop, which reads the turn identity the caller attached with - // loop.WithTurnID; it is zero when no turn id was attached, so a consumer must - // treat both fields as best-effort correlation rather than a hard guarantee. + // TurnID 是事件所属的助手回合。与 SessionID 一样,它由 + // loop 打上,那里读取调用方用 + // loop.WithTurnID 附上的回合标识;未附回合 id 时为零,所以消费者必须 + // 把这两个字段当作尽力而为的关联,而不是硬保证。 TurnID string } diff --git a/internal/runtime/model/truncation.go b/internal/runtime/model/truncation.go index 3ba2f42..4baf05e 100644 --- a/internal/runtime/model/truncation.go +++ b/internal/runtime/model/truncation.go @@ -1,27 +1,27 @@ package model -// Provider stop/finish reasons that mean the token budget cut the generation -// off: Anthropic reports stop_reason "max_tokens", OpenAI-compatible providers -// report finish_reason "length". Both are normalized into Response.StopReason -// verbatim so a trace keeps the provider's own vocabulary. +// 表示 token 预算把生成切断的提供商停止/结束原因: +// Anthropic 上报 stop_reason "max_tokens",OpenAI 兼容提供商 +// 上报 finish_reason "length"。两者都被原样归一化进 Response.StopReason, +// 因此链路追踪保留提供商自己的词汇。 const ( stopReasonMaxTokens = "max_tokens" stopReasonLength = "length" ) -// Truncation-recovery bounds. At most one retry with a doubled budget is -// allowed; the cap keeps a pathological budget from growing without bound. The -// hint is the non-empty placeholder returned when even the retry produced no -// visible text, so a truncated turn never surfaces as a silent empty reply. -// It never carries thinking: reasoning is not the answer. +// 截断恢复的边界。最多允许一次带翻倍预算的重试; +// 上限防止病态预算无界增长。hint 是连重试 +// 也没产出可见文本时返回的非空占位, +// 因此被截断的回合绝不会以静默空回复的形式浮出。 +// 它绝不携带思考:推理不是答案。 const ( truncationRetryCap = 32768 truncationEmptyHint = "(思考过长,未能生成回复)" ) -// effectiveMaxTokens resolves the budget an attempt actually sends: the explicit -// Request.MaxTokens, else the adapter default. def may be zero when the protocol -// has no adapter default, in which case the provider default applies. +// effectiveMaxTokens 解出一次尝试实际发送的预算:显式的 +// Request.MaxTokens,否则用适配器默认值。当协议没有适配器 +// 默认值时 def 可以为零,此时适用提供商默认值。 func effectiveMaxTokens(n, def int) int { if n > 0 { return n @@ -29,10 +29,10 @@ func effectiveMaxTokens(n, def int) int { return def } -// retryMaxTokens doubles the effective budget for the one-shot truncation retry, -// capped at truncationRetryCap. It returns the effective budget unchanged when -// there is nothing to double (both n and def are zero, i.e. the provider default -// applies), letting the caller skip a retry that would send an identical request. +// retryMaxTokens 为一次性的截断重试翻倍实际生效预算, +// 上限为 truncationRetryCap。当没有什么可翻倍时(n 与 def 都是零, +// 即适用提供商默认值),它原样返回实际生效预算,从而让 +// 调用方跳过那次会发出完全相同请求的重试。 func retryMaxTokens(n, def int) int { eff := effectiveMaxTokens(n, def) if eff <= 0 { @@ -44,31 +44,31 @@ func retryMaxTokens(n, def int) int { return truncationRetryCap } -// isTruncatedStopReason reports whether a stop reason means the token budget cut -// the generation off. +// isTruncatedStopReason 报告某个停止原因是否意味着 token 预算 +// 切断了生成。 func isTruncatedStopReason(reason string) bool { return reason == stopReasonMaxTokens || reason == stopReasonLength } -// isEmptyBody reports whether a response carries neither visible text nor tool -// calls. A tool-only round is NOT empty: its empty body is normal and belongs to -// the tool loop. +// isEmptyBody 报告响应是否既无可见文本也无工具 +// 调用。仅工具的一轮并不是空:它的空正文是正常的、属于 +// 工具循环。 func isEmptyBody(r Response) bool { return r.Content == "" && len(r.ToolCalls) == 0 } -// isTruncatedEmpty reports whether an attempt was cut off by the token budget -// before producing any visible output (no text, no tool call). Only this shape -// triggers the truncation retry/fallback; a normal tool_use round, whose body is -// legitimately empty, does not. +// isTruncatedEmpty 报告某次尝试是否在产出任何可见输出之前 +// 就被 token 预算切断(无文本、无工具调用)。只有这种形状 +// 才触发截断重试/兜底;正常的 tool_use 回合,其正文 +// 合法地为空,不会触发。 func isTruncatedEmpty(r Response) bool { return isEmptyBody(r) && isTruncatedStopReason(r.StopReason) } -// withEmptyBodyHint fills a non-empty placeholder body when a response has -// neither text nor tool calls, so a truncated turn never surfaces as a silent -// empty reply. A response that already carries text or tool calls is returned -// unchanged. +// withEmptyBodyHint 在响应既无文本也无工具调用时填入一段 +// 非空占位正文,因此被截断的回合绝不会以静默 +// 空回复的形式浮出。已携带文本或工具调用的响应 +// 原样返回。 func withEmptyBodyHint(r Response) Response { if !isEmptyBody(r) { return r @@ -78,12 +78,12 @@ func withEmptyBodyHint(r Response) Response { return r } -// callWithTruncationRetry runs a non-streaming attempt, retrying once with a -// doubled token budget when the first attempt was cut off with no visible -// output, then returning a non-empty placeholder when the retry still produced -// nothing. attempt receives the budget to send. An upstream error on the first -// attempt is returned unchanged (no retry); a failed retry falls back to the -// placeholder rather than surfacing an empty reply. +// callWithTruncationRetry 执行一次非流式尝试,当第一次尝试 +// 在无可见输出下被切断时,用翻倍 token 预算重试一次, +// 随后在重试仍无产出时返回非空占位。attempt 接收要发送 +// 的预算。第一次尝试的上游错误会被原样返回(不重试); +// 重试失败则退回占位, +// 而不是浮出一个空回复。 func callWithTruncationRetry(maxTokens, def int, attempt func(limit int) (Response, error)) (Response, error) { out, err := attempt(maxTokens) if err != nil { @@ -100,13 +100,13 @@ func callWithTruncationRetry(maxTokens, def int, attempt func(limit int) (Respon return withEmptyBodyHint(out), nil } -// callStreamWithTruncationRetry is the streaming counterpart of -// callWithTruncationRetry. attempt receives the budget and the event callback -// for that attempt. The retry drops thinking events (the first attempt already -// streamed its reasoning, so re-streaming it would duplicate the block) while -// forwarding text/tool events live; when even the retry yields no visible -// output, a non-empty placeholder is emitted as a text segment and returned as -// the response body, so the stream never ends on a silent truncation. +// callStreamWithTruncationRetry 是 callWithTruncationRetry 的 +// 流式对位实现。attempt 接收该次尝试的预算与事件回调。 +// 重试会丢弃思考事件(第一次尝试已经 +// 流过它的推理,再流一次会产生重复块), +// 同时实时转发文本/工具事件;当连重试也没有可见 +// 输出时,会以文本片段发出非空占位并把它作为 +// 响应正文返回,因此流绝不会以静默截断收尾。 func callStreamWithTruncationRetry( maxTokens, def int, attempt func(limit int, onEvent func(StreamEvent)) (Response, error), @@ -131,8 +131,8 @@ func callStreamWithTruncationRetry( return out, nil } -// dropThinking wraps onEvent so a retry attempt does not re-emit thinking -// segments. Text and tool_call events pass through unchanged. +// dropThinking 包裹 onEvent,使重试尝试不会再次发出思考 +// 片段。文本与 tool_call 事件原样通过。 func dropThinking(onEvent func(StreamEvent)) func(StreamEvent) { if onEvent == nil { return nil @@ -147,8 +147,8 @@ func dropThinking(onEvent func(StreamEvent)) func(StreamEvent) { } } -// emitTextSegment emits a single-delta text segment so a synthesized placeholder -// reaches streaming consumers that render only message.* events. +// emitTextSegment 发出一个单增量的文本片段,使合成的占位 +// 能到达只渲染 message.* 事件的流式消费者。 func emitTextSegment(onEvent func(StreamEvent), text string) { if onEvent == nil || text == "" { return diff --git a/internal/runtime/model/truncation_test.go b/internal/runtime/model/truncation_test.go index 3c6c9e4..07748c9 100644 --- a/internal/runtime/model/truncation_test.go +++ b/internal/runtime/model/truncation_test.go @@ -14,9 +14,9 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/config" ) -// anthropicSeqServer answers successive /v1/messages calls with a fixed script -// of bodies (the last body repeats) and records every decoded request body, so a -// test can assert both the retry count and the retried max_tokens. +// anthropicSeqServer 用一份固定的响应体脚本回答连续的 /v1/messages +// 调用(最后一个响应体重复),并记录每个已解码的请求体,因此 +// 测试既能断言重试次数、也能断言重试后的 max_tokens。 type anthropicSeqServer struct { *httptest.Server mu sync.Mutex @@ -61,8 +61,8 @@ func (s *anthropicSeqServer) body(i int) map[string]any { return s.bodies[i] } -// anthropicThinkingOnlyResponse is a thinking-only, budget-truncated reply: the -// reasoning consumed the whole max_tokens budget, so no text block was emitted. +// anthropicThinkingOnlyResponse 是一份只有思考、被预算截断的回复: +// 推理吃光了整个 max_tokens 预算,所以没有文本块被发出。 const anthropicThinkingOnlyResponse = `{ "id":"msg_think","model":"claude-test", "content":[{"type":"thinking","thinking":"SECRET-REASONING long long long"}], @@ -84,8 +84,8 @@ const anthropicToolUseResponse = `{ "usage":{"input_tokens":5,"output_tokens":8} }` -// anthropicTruncatedThinkingSSE is the streaming form of a thinking-only -// truncation: a thinking block, then message_delta stop_reason max_tokens. +// anthropicTruncatedThinkingSSE 是「只有思考」截断的流式形态: +// 一个 thinking 块,随后是 message_delta 的 stop_reason max_tokens。 const anthropicTruncatedThinkingSSE = `data: {"type":"message_start","message":{"model":"claude-test","usage":{"input_tokens":5}}} data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}} @@ -114,9 +114,9 @@ data: {"type":"message_stop"} ` -// TestAnthropicChat_TruncatedThinking_RetriesWithDoubledBudget proves the -// primary recovery: a max_tokens truncation with only a thinking block retries -// exactly once with the token budget doubled and surfaces the retry's text. +// TestAnthropicChat_TruncatedThinking_RetriesWithDoubledBudget 证明 +// 主恢复路径:只有 thinking 块的 max_tokens 截断会 +// 恰好重试一次、token 预算翻倍,并浮出重试的文本。 func TestAnthropicChat_TruncatedThinking_RetriesWithDoubledBudget(t *testing.T) { t.Parallel() srv := newAnthropicSeqServer(t, "application/json", anthropicThinkingOnlyResponse, anthropicEndTurnTextResponse) @@ -149,9 +149,9 @@ func TestAnthropicChat_TruncatedThinking_RetriesWithDoubledBudget(t *testing.T) } } -// TestAnthropicChat_TruncatedThinking_RetryStillEmpty_FallsBackToHint proves the -// safety net: when the retry is truncated too, the caller gets a non-empty -// placeholder instead of a silent empty reply, and the thinking never leaks. +// TestAnthropicChat_TruncatedThinking_RetryStillEmpty_FallsBackToHint 证明 +// 兜底网:连重试也被截断时,调用方拿到非空 +// 占位,而不是静默空回复,且思考永不泄漏。 func TestAnthropicChat_TruncatedThinking_RetryStillEmpty_FallsBackToHint(t *testing.T) { t.Parallel() srv := newAnthropicSeqServer(t, "application/json", anthropicThinkingOnlyResponse, anthropicThinkingOnlyResponse) @@ -184,9 +184,9 @@ func TestAnthropicChat_TruncatedThinking_RetryStillEmpty_FallsBackToHint(t *test } } -// TestAnthropicChat_NormalStop_NoRetry proves a normal end_turn answer and a -// native tool_use round (whose body is legitimately empty) are left untouched: -// no retry, no fallback hint. +// TestAnthropicChat_NormalStop_NoRetry 证明正常的 end_turn 答复与 +// 原生 tool_use 回合(其正文合法地为空)保持不动: +// 不重试、不补兜底占位。 func TestAnthropicChat_NormalStop_NoRetry(t *testing.T) { t.Parallel() t.Run("end_turn", func(t *testing.T) { @@ -224,9 +224,9 @@ func TestAnthropicChat_NormalStop_NoRetry(t *testing.T) { }) } -// TestAnthropicChatStream_TruncatedThinking_RetriesWithDoubledBudget proves the -// streaming retry: the retry's thinking is suppressed (no duplicate block) while -// its text streams through, and the doubled budget is sent. +// TestAnthropicChatStream_TruncatedThinking_RetriesWithDoubledBudget 证明 +// 流式重试:重试产生的思考被抑制(无重复块), +// 而其文本流过,且发送的是翻倍预算。 func TestAnthropicChatStream_TruncatedThinking_RetriesWithDoubledBudget(t *testing.T) { t.Parallel() srv := newAnthropicSeqServer(t, "text/event-stream", anthropicTruncatedThinkingSSE, anthropicEndTurnTextSSE) @@ -265,10 +265,10 @@ func TestAnthropicChatStream_TruncatedThinking_RetriesWithDoubledBudget(t *testi } } -// TestAnthropicChatStream_TruncatedThinking_RetryStillEmpty_EmitsHint proves the -// streaming safety net: when the retry is truncated too, a non-empty placeholder -// is emitted as a text segment (and returned as the body) so the frontend is not -// left with a silent truncated turn. +// TestAnthropicChatStream_TruncatedThinking_RetryStillEmpty_EmitsHint 证明 +// 流式兜底网:连重试也被截断时,会以文本片段发出非空 +// 占位(并把它作为正文返回),因此前端不会 +// 停在一个静默截断的回合上。 func TestAnthropicChatStream_TruncatedThinking_RetryStillEmpty_EmitsHint(t *testing.T) { t.Parallel() srv := newAnthropicSeqServer(t, "text/event-stream", anthropicTruncatedThinkingSSE, anthropicTruncatedThinkingSSE) @@ -301,7 +301,7 @@ func TestAnthropicChatStream_TruncatedThinking_RetryStillEmpty_EmitsHint(t *test } } -// TestTruncationBudget covers the budget arithmetic that bounds the retry. +// TestTruncationBudget 覆盖约束重试的预算算术。 func TestTruncationBudget(t *testing.T) { t.Parallel() cases := []struct { @@ -328,9 +328,9 @@ func TestTruncationBudget(t *testing.T) { } } -// TestOpenAIChat_StopReasonMapping asserts the OpenAI adapter fills the unified -// Response.StopReason from finish_reason on both the non-streaming and streaming -// paths, so a "length" truncation is identifiable across protocols. +// TestOpenAIChat_StopReasonMapping 断言 OpenAI 适配器在非流式与 +// 流式两条路径上都由 finish_reason 填充统一的 +// Response.StopReason,因此 "length" 截断在各协议间都可识别。 func TestOpenAIChat_StopReasonMapping(t *testing.T) { t.Parallel() t.Run("non_streaming", func(t *testing.T) { diff --git a/internal/services/backthink/backthink.go b/internal/services/backthink/backthink.go index cd88a38..e973210 100644 --- a/internal/services/backthink/backthink.go +++ b/internal/services/backthink/backthink.go @@ -1,31 +1,31 @@ -// Package backthink implements the M8 autonomous background-think service -// (DevDocs/design/12-后台思考与主动.md §0-§3). +// backthink 包实现 M8 自主后台思考服务 +// (DevDocs/design/12-后台思考与主动.md §0-§3)。 // -// It runs unattended thinking passes on two triggers: +// 它在两类触发下运行无人值守的思考轮: // -// - A periodic heartbeat (config.Background.IntervalSec) that asks the injected -// runtime to reflect and is allowed to call tools (IoT, calendar, reminders) -// but must not emit chat-style messages by default. -// - Asynchronous events on the in-process bus (iot.device_update, -// reminder.trigger, system.event) that wake a thinking pass. +// - 周期性心跳(config.Background.IntervalSec),请求注入的运行时 +// 反思,并允许调用工具(IoT、日历、提醒), +// 但默认不得发出 chat 风格的消息。 +// - 进程内总线上的异步事件(iot.device_update、 +// reminder.trigger、system.event),它们唤醒一轮思考。 // -// Each pass produces a conclusion whose structured tail decides whether she -// wants to speak at all (design/20 §2 P2), then consults Decide — the importance -// cascade restored as the production decision path — to judge whether now is an -// acceptable moment (event importance / time-of-day / user state such as heart -// rate, sleep, work, busy). A critical safety event punches through work/busy -// and quiet hours (裁决 07.4.2), and a heartbeat pass is never held by a -// long-silent activity gap (裁决 07.4.3). When it is worth interrupting the -// push goes to the active client first and falls back to the IM bridge. The -// proactive-push rate gate of DevDocs/design/11 §6 governs the periodic heartbeat -// only; an event-driven push bypasses it (owner mandate: "该发就发") subject to -// same-event dedup (see event_dedup.go). Every pass is persisted to SQLite via -// Store so the M9/M10 monitor panel can render the background-thinking page -// (design/12 §4). +// 每轮产出一段结论,其结构化尾段决定她 +// 到底想不想说话(design/20 §2 P2),随后咨询 Decide—— +// 作为生产决策路径恢复的重要度级联——来判断此刻是否 +// 合适(事件重要度 / 时段 / 用户状态,如心率、 +// 睡眠、工作、忙碌)。critical 安全事件穿透工作/忙碌 +// 与安静时段(裁决 07.4.2),而心跳轮永远不会被长期 +// 静默的活动间隔拦下(裁决 07.4.3)。当值得打扰时, +// 推送先走活跃客户端,再降级到 IM 桥。 +// DevDocs/design/11 §6 的主动推送频率门只约束 +// 周期性心跳;事件驱动的推送绕过它(主人要求:"该发就发"), +// 但受同一事件去重约束(见 event_dedup.go)。每轮都经 +// Store 持久化到 SQLite,好让 M9/M10 监控面板能渲染后台思考页 +// (design/12 §4)。 // -// The package is concurrency-safe: a mutex guards the lifecycle state, the -// injected store/pusher/thinker are required to be concurrency-safe by contract, -// and the internal publish path never waits on a slow consumer. +// 本包并发安全:一把互斥锁守护生命周期状态, +// 注入的 store/pusher/thinker 按契约要求并发安全, +// 且内部发布路径绝不会等待慢消费者。 package backthink import ( @@ -43,68 +43,68 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" ) -// Sentinel errors returned by this package. Use errors.Is to test for them; they -// are returned directly or wrapped with %w so the underlying cause stays intact. +// 本包返回的哨兵错误。用 errors.Is 判定;它们被直接 +// 返回或用 %w 包装,因此底层原因保持完整。 var ( - // ErrNilDB is returned when Backthink or Store is built with a nil database. + // 用 nil 数据库构建 Backthink 或 Store 时返回 ErrNilDB。 ErrNilDB = errors.New("backthink: nil database handle") - // ErrNoThinker is returned when New is given no Thinker. + // New 未收到 Thinker 时返回 ErrNoThinker。 ErrNoThinker = errors.New("backthink: nil thinker") - // ErrNoLoop is returned by LoopThinker when it wraps a nil loop. + // LoopThinker 包装了 nil loop 时由它返回 ErrNoLoop。 ErrNoLoop = errors.New("backthink: nil loop") ) -// Thinker runs one unattended thinking pass over the injected runtime. It is the -// seam between the service and the loop/agent: the production wiring hands back a -// LoopThinker over a *loop.Loop, while tests inject a mock. Implementations may -// call tools (IoT, calendar, reminders) and must be concurrency-safe. +// Thinker 在注入的运行时上跑一轮无人值守的思考。它是 +// 服务与 loop/agent 之间的接缝:生产接线交回一个基于 +// *loop.Loop 的 LoopThinker,而测试注入 mock。实现可以 +// 调用工具(IoT、日历、提醒),且必须并发安全。 type Thinker interface { - // Think invokes one autonomous thinking pass for the given session/trigger - // and returns the concluded text. + // Think 为给定的会话/触发调用一轮自主思考, + // 并返回结论文本。 Think(ctx context.Context, sessionID, trigger string) (string, error) } -// Deps is the injected dependency set for a Backthink. Only Thinker and DB are -// required; the rest degrade gracefully (a nil Bus disables event triggers, a nil -// State yields a neutral user state, and a nil Pusher is built from Active/IM). +// Deps 是注入给 Backthink 的依赖集合。只有 Thinker 与 DB 是 +// 必需的;其余优雅降级(Bus 为 nil 则关闭事件触发,State 为 +// nil 则给出中性用户状态,Pusher 为 nil 则由 Active/IM 构建)。 type Deps struct { - // Bus is the event-trigger source. Nil disables event-driven thinking. + // Bus 是事件触发源。nil 则关闭事件驱动思考。 Bus bus.Bus - // Thinker runs one unattended thinking pass. Required. + // Thinker 跑一轮无人值守的思考。必需。 Thinker Thinker - // State supplies the user's current condition for the interrupt decision. - // Nil yields a neutral state. + // State 提供用户当前状况,用于打扰决策。 + // nil 则给出中性状态。 State StateProvider - // Recent supplies the most recent main-session messages so the activity gate - // can sense whether the owner just chatted (recent-conversation cooldown and - // idle-window suppression). Nil disables activity sensing. + // Recent 提供主会话最近的消息,好让活动门 + // 感知主人是否刚聊过(最近对话冷却与 + // 静默窗口抑制)。nil 则关闭活动感知。 Recent recentProvider - // Pusher delivers a proactive message. When nil it is built from Active (the - // active-client sink) and IM (the IM bridge sink) plus Gate. + // Pusher 投递主动消息。为 nil 时由 Active(活跃客户端槽) + // 与 IM(IM 桥槽)加上 Gate 构建。 Pusher Pusher - // Active is the active web/native client sink, preferred for pushes. + // Active 是活跃 Web/原生客户端槽,推送首选。 Active ActiveClient - // IM is the IM bridge sink, used as the fallback when no active client is up. + // IM 是 IM 桥槽,在没有活跃客户端时用作降级。 IM IMAdapter - // Exporter mirrors a proactive message to the external push outlet (the - // owner's self-hosted unified push service, then Getui). Optional: nil - // disables the hook. It never blocks or breaks the Active/IM delivery. + // Exporter 把主动消息镜像到外部推送出口(主人的自托管 + // 统一推送服务,其次是个推)。可选:nil 则 + // 关闭该钩子。它绝不阻塞或破坏 Active/IM 投递。 Exporter PushExporter - // Gate is the proactive-push rate limiter. Nil builds a default from Config. + // Gate 是主动推送限流器。nil 则由 Config 构建默认值。 Gate PushGate - // DB is the SQLite handle for the think store. Required. + // DB 是思考存储所用的 SQLite 句柄。必需。 DB *sql.DB - // SessionID scopes a think. Empty uses defaultSessionID. + // SessionID 限定一次思考的作用域。为空则用 defaultSessionID。 SessionID string - // Now is the wall-clock source for decision/record timestamps. Nil uses - // time.Now, which makes time-dependent behaviour injectable in tests. + // Now 是决策/记录时间戳的墙钟来源。nil 则用 + // time.Now,从而让依赖时间的行为在测试中可注入。 Now func() time.Time - // Log is the base logger, tagged with source "services/backthink". + // Log 是基础日志器,来源标记为 "services/backthink"。 Log *slog.Logger } -// Backthink is the autonomous background-think service. Build it with New, start -// the heartbeat and subscriptions with Start, and stop them with Stop. +// Backthink 是自主后台思考服务。用 New 构建,用 Start 启动 +// 心跳与订阅,用 Stop 停止它们。 type Backthink struct { cfg Config deps Deps @@ -121,17 +121,17 @@ type Backthink struct { subs []func() wg sync.WaitGroup - // eventMu guards the same-event dedup guard (event_dedup.go). It is separate - // from the lifecycle lock so a dedup decision never blocks a sink swap. + // eventMu 守护同一事件去重器(event_dedup.go)。它与 + // 生命周期锁分开,因此去重决策绝不会阻塞槽位换入。 eventMu sync.Mutex lastEventKey string } -// New builds a Backthink from cfg and deps, applying defaults and validating the -// required dependencies. It creates the SQLite think store (via Store) up front -// and resolves the push path (an injected Pusher, or a default built from -// Active/IM and the rate Gate). It returns ErrNilDB when DB is nil and -// ErrNoThinker when Thinker is nil. +// New 由 cfg 与 deps 构建 Backthink,套用默认值并校验 +// 必需依赖。它先行创建 SQLite 思考存储(经 Store) +// 并解出推送路径(注入的 Pusher,或由 +// Active/IM 与频率 Gate 构建的默认值)。DB 为 nil 时返回 ErrNilDB, +// Thinker 为 nil 时返回 ErrNoThinker。 func New(cfg Config, deps Deps) (*Backthink, error) { if deps.Thinker == nil { return nil, ErrNoThinker @@ -147,7 +147,7 @@ func New(cfg Config, deps Deps) (*Backthink, error) { return nil, fmt.Errorf("backthink: new: %w", err) } - // Resolve the gate up front so the sink setters can rebuild the pusher with it. + // 提前解出频率门,好让槽位设置器能与它一起重建推送器。 gate := deps.Gate if gate == nil { gate = newRateGate(cfg.PushLimit, cfg.PushWindow, cfg.PushCooldown, cfg.KindWindow, log) @@ -178,10 +178,10 @@ func New(cfg Config, deps Deps) (*Backthink, error) { }, nil } -// Start begins the background work: the heartbeat ticker and, when a Bus is -// configured, the event subscriptions. It is idempotent and safe to call twice; a -// second call is a no-op. The provided ctx (may be nil) is the parent of all -// internal work: cancelling it stops the service exactly like Stop. +// Start 启动后台工作:心跳定时器,以及(配置了 Bus 时) +// 事件订阅。它是幂等的,调用两次安全; +// 第二次调用是空操作。传入的 ctx(可为 nil)是所有 +// 内部工作的父级:取消它会像 Stop 一样停止服务。 func (b *Backthink) Start(ctx context.Context) { if ctx == nil { ctx = context.Background() @@ -209,11 +209,11 @@ func (b *Backthink) Start(ctx context.Context) { "session_id", b.sessionID) } -// Stop halts the heartbeat and unsubscribes every bus subscription, then waits -// for the in-flight thinking pass (bounded by ThinkTimeout) to drain. It is -// idempotent; a second call returns nil immediately. When ctx is cancelled before -// the drain completes it returns a wrapped context error rather than leaking the -// goroutine. +// Stop 停止心跳、退订所有总线订阅,然后等待 +// 进行中的思考轮(受 ThinkTimeout 约束)排空。它是 +// 幂等的;第二次调用立即返回 nil。若在排空完成前 ctx 已被 +// 取消,则返回包装后的 context 错误,而不是泄漏 +// goroutine。 func (b *Backthink) Stop(ctx context.Context) error { if ctx == nil { ctx = context.Background() @@ -248,22 +248,22 @@ func (b *Backthink) Stop(ctx context.Context) error { } } -// List returns the most recent thinking records for the M9/M10 monitor panel, -// newest first. A non-positive limit selects the store default. +// List 返回 M9/M10 监控面板用的最近思考记录, +// 最新的在前。非正的 limit 选用存储默认值。 func (b *Backthink) List(ctx context.Context, limit int) ([]Record, error) { return b.store.List(ctx, limit) } -// triggers returns the bus subjects the service watches for event-driven -// thinking. It is a method (not a package var) so no package-level global slice -// appears, per the coding constraint. +// triggers 返回本服务为事件驱动思考所监听的总线主题。 +// 它是一个方法(而非包级变量),因此不会出现包级全局 +// 切片,符合编码约束。 func (b *Backthink) triggers() []string { return []string{iot.DeviceUpdateSubject, SubjectReminderTrigger, SubjectSystemEvent} } -// subscribe registers one handler per trigger subject. Each subscription is -// bound to ctx so cancelling it removes the registration; the unregister -// functions are kept so Stop can also tear them down explicitly. +// subscribe 为每个触发主题注册一个处理器。每个订阅都 +// 绑定到 ctx,因此取消它会移除注册;退订函数 +// 被保留下来,好让 Stop 也能显式拆除它们。 func (b *Backthink) subscribe(ctx context.Context) { for _, subj := range b.triggers() { unsub, err := b.deps.Bus.Subscribe(ctx, subj, b.onEvent) @@ -277,11 +277,11 @@ func (b *Backthink) subscribe(ctx context.Context) { } } -// onEvent is the bus handler for event-triggered thinking. It runs on the bus's -// own worker goroutine, so it must not block on another subscriber. It first -// applies the minimal-disturbance gate (design/12 §6 策略一): a low-value system -// event is downgraded to a registration-only record rather than waking a full -// thinking pass, so platform/system noise cannot flood the thinker. +// onEvent 是事件驱动思考的总线处理器。它跑在总线自己的 +// worker goroutine 上,所以不得阻塞在另一个订阅者上。它先 +// 套用最小打扰门(design/12 §6 策略一):低价值的系统 +// 事件被降级为仅登记一条记录,而不唤醒一整轮 +// 思考,因此平台/系统噪声无法淹没思考器。 func (b *Backthink) onEvent(ctx context.Context, e bus.Event) error { tr := triggerForEvent(e) if !b.shouldThink(tr) { @@ -300,11 +300,11 @@ func (b *Backthink) onEvent(ctx context.Context, e bus.Event) error { return nil } -// shouldThink gates event-triggered thinking (design/12 §6 策略一). On a public -// platform a generic system event is low value, so only a high-importance -// (owner-relevant) system event wakes a pass. IoT device updates and reminder -// triggers are owner-relevant / high value and are never gated, so the productive -// triggers stay fully functional. +// shouldThink 为事件驱动思考把关(design/12 §6 策略一)。在公开 +// 平台上,泛化的系统事件价值低,所以只有高重要度 +// (与主人相关)的系统事件才唤醒一轮。IoT 设备更新与提醒 +// 触发与主人相关 / 价值高,从不受门控,因此这些有产出的 +// 触发保持完全可用。 func (b *Backthink) shouldThink(tr Trigger) bool { if tr.Source == SubjectSystemEvent { return tr.Import >= ImportanceHigh @@ -312,11 +312,11 @@ func (b *Backthink) shouldThink(tr Trigger) bool { return true } -// tickLoop is the heartbeat goroutine. Per 裁决 07.4.4 (design/12 §1) it thinks -// once immediately on process start — producing a humanized startup greeting — -// before settling into one thinking pass per tick; it exits when ctx is -// cancelled. The accepted side effect is that every process restart wakes one -// immediate thinking round. +// tickLoop 是心跳 goroutine。按裁决 07.4.4(design/12 §1),它在进程 +// 启动时立即思考一次——产出一句拟人化的启动问候—— +// 随后稳定为每 tick 一轮思考;ctx 被取消时 +// 退出。可接受的副作用是每次进程重启都会唤醒 +// 一轮即时思考。 func (b *Backthink) tickLoop(ctx context.Context) { defer b.wg.Done() // 启动即思考:不等满一个心跳周期。已取消的上下文直接退出,不落一条失败的 @@ -339,10 +339,10 @@ func (b *Backthink) tickLoop(ctx context.Context) { } } -// think runs one unattended thinking pass: invoke the runtime, read the user -// state, decide whether to interrupt, push when warranted and persist the record. -// It is concurrency-safe provided the injected Thinker, StateProvider and Pusher -// are concurrency-safe, which they are by contract. +// think 跑一轮无人值守的思考:调用运行时、读取用户 +// 状态、判断是否打扰、值得时推送并持久化记录。 +// 只要注入的 Thinker、StateProvider 与 Pusher +// 并发安全,它就是并发安全的——按契约它们都是。 func (b *Backthink) think(parent context.Context, tr Trigger) { at := b.now() cctx, cancel := context.WithTimeout(parent, b.cfg.ThinkTimeout) @@ -363,9 +363,9 @@ func (b *Backthink) think(parent context.Context, tr Trigger) { return } - // P2 (design/20 §2): her own conclusion decides whether she wants to speak - // and to which channel. parseProactive recovers the decision robustly and - // fails safe (malformed/ambiguous → no push). + // P2(design/20 §2):她自己的结论决定她想不想说、 + // 以及发到哪个渠道。parseProactive 稳健地恢复该决策, + // 并失败安全(格式错乱/含糊 → 不推送)。 message, channelHint, push := parseProactive(conclusion) state := b.currentState(cctx) @@ -393,9 +393,9 @@ func (b *Backthink) think(parent context.Context, tr Trigger) { pushRes := PushResult{Route: RouteNone, Detail: decision.Reason} if shouldPush { - // Same-event dedup: a repeating identical event is suppressed after the - // first push (防刷), while a genuinely new event still goes out. Heartbeats - // are never deduped (they reset the chain) and stay gate-governed. + // 同一事件去重:重复的相同事件在首次推送后被抑制 + // (防刷),而真正的新事件仍会发出。心跳 + // 从不去重(它们会重置该链),并保持受门控管辖。 if b.eventRedundant(tr) { pushRes = PushResult{Route: RouteNone, Detail: "duplicate event push suppressed"} } else { @@ -422,8 +422,8 @@ func (b *Backthink) think(parent context.Context, tr Trigger) { } } -// currentState reads the user's current condition for the interrupt decision, -// falling back to a neutral state (and a warn log) when the provider errors. +// currentState 读取用户当前状况用于打扰决策, +// 提供者报错时退回中性状态(并记一条 warn 日志)。 func (b *Backthink) currentState(ctx context.Context) UserState { if b.deps.State == nil { return UserState{} @@ -436,12 +436,12 @@ func (b *Backthink) currentState(ctx context.Context) UserState { return s } -// lastUserActivity reports the wall-clock time of the owner's most recent message -// in the main session, and whether any user message was found. It is the activity -// signal for the activity gate (design/12 §6): a nil provider, a failed read or an -// empty tail yield has=false so the activity gate falls through to the current -// behaviour rather than guessing. The most recent user message is the newest -// RoleUser entry in the recent tail (Recent returns oldest-first). +// lastUserActivity 报告主人最近一条消息的墙钟时间 +// (在主会话里),以及是否找到了任何用户消息。它是活动 +// 门的活动信号(design/12 §6):提供者为 nil、读取失败或 +// 尾部为空都得到 has=false,因此活动门会落到当前 +// 行为而不是靠猜。最近的一条用户消息是最近尾部里 +// 最新的 RoleUser 条目(Recent 返回的是最旧在前)。 func (b *Backthink) lastUserActivity(ctx context.Context) (time.Time, bool) { if b.deps.Recent == nil { return time.Time{}, false @@ -458,14 +458,14 @@ func (b *Backthink) lastUserActivity(ctx context.Context) (time.Time, bool) { return time.Time{}, false } -// record persists a thinking pass. It uses a background context so an audit row -// is written even when the think's own context has already been cancelled. +// record 持久化一轮思考。它使用后台 context,因此即便 +// 该次思考自身的 context 已被取消,审计行仍会写入。 func (b *Backthink) record(r Record) { if _, err := b.store.Append(context.Background(), r); err != nil { b.log.Error("backthink record failed", "error", err) } - // Surface the persisted pass on the WS stream so the frontend's backthink - // consumer (or its default fallback) observes it without polling the store. + // 把持久化的这一轮暴露到 WS 流上,好让前端的 backthink + // 消费者(或其默认兜底)无需轮询存储就能观测到它。 if b.deps.Bus != nil { if err := b.deps.Bus.Publish(context.Background(), "ws.backthink", r); err != nil { b.log.Warn("backthink bus publish failed", "error", err) diff --git a/internal/services/backthink/backthink_test.go b/internal/services/backthink/backthink_test.go index 9dcf1fd..87d1b07 100644 --- a/internal/services/backthink/backthink_test.go +++ b/internal/services/backthink/backthink_test.go @@ -11,8 +11,8 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/iot" ) -// newBackthink builds a Backthink with a fresh in-memory DB and the given deps, -// returning the zeroed deps filled with the DB handle so tests can inspect deps. +// newBackthink 用一份新的内存 DB 与给定 deps 构建 Backthink, +// 并返回填好 DB 句柄的 deps,好让测试能检查 deps。 func newBackthink(t *testing.T, cfg Config, deps Deps) (*Backthink, Deps) { t.Helper() deps.DB = newTestDB(t) @@ -34,9 +34,9 @@ func TestNew_Validation(t *testing.T) { } } -// TestConfig_Defaults verifies the tightened proactive-push defaults: a 15-minute -// heartbeat, at most 1 push per 60 minutes, a 30-minute post-push cooldown, a -// 10-minute recent-conversation cooldown, and a 2-hour per-flavor window. +// TestConfig_Defaults 验证收紧后的主动推送默认值:15 分钟 +// 心跳、每 60 分钟最多 1 次推送、推送后 30 分钟冷却、 +// 最近对话 10 分钟冷却,以及按风格 2 小时的窗口。 func TestConfig_Defaults(t *testing.T) { t.Parallel() cfg := normalize(Config{}) @@ -192,8 +192,8 @@ func TestBackthink_EventTrigger_NoPush(t *testing.T) { func TestBackthink_SystemEventLowValueDowngraded(t *testing.T) { t.Parallel() - // design/12 §6 策略一: a low-value system event on a public platform does not - // wake a full thinking pass — it is downgraded to a registration-only record. + // design/12 §6 策略一:公开平台上的低价值系统事件不会 + // 唤醒一整轮思考——它被降级为仅登记一条记录。 thinker := &mockThinker{text: "should not be called"} bs := bus.New() b, _ := newBackthink(t, Config{}, Deps{ @@ -240,8 +240,8 @@ func TestBackthink_SystemEventLowValueDowngraded(t *testing.T) { func TestBackthink_SystemEventHighValueThinks(t *testing.T) { t.Parallel() - // A high-importance (owner-relevant) system event still wakes a thinking pass — - // the denoise gate only downgrades low-value noise (design/12 §6 策略一). + // 高重要度(与主人相关)的系统事件仍会唤醒一轮思考—— + // 降噪门只降级低价值噪声(design/12 §6 策略一)。 thinker := &mockThinker{text: "important notice"} bs := bus.New() b, _ := newBackthink(t, Config{}, Deps{ @@ -289,8 +289,8 @@ func TestBackthink_List_NewestFirst(t *testing.T) { if err != nil { t.Fatalf("List: %v", err) } - // The single-row listing must be the same newest record as the head of the - // full listing, and the full listing must be strictly newest-first. + // 单行列表里的那一条必须与完整列表的头部是同一条最新 + // 记录,且完整列表必须是严格的由新到旧。 if top[0].ID != all[0].ID { t.Fatalf("List(1)[0].ID = %d, want %d (== newest)", top[0].ID, all[0].ID) } @@ -312,7 +312,7 @@ func TestBackthink_Stop_NoLeak(t *testing.T) { if err := b.Stop(context.Background()); err != nil { t.Fatalf("Stop: %v", err) } - // Idempotent. + // 幂等。 if err := b.Stop(context.Background()); err != nil { t.Fatalf("second Stop: %v", err) } @@ -331,9 +331,9 @@ func TestBackthink_Stop_UnsubscribesBus(t *testing.T) { b, _ := newBackthink(t, Config{Interval: 25 * time.Millisecond}, Deps{Bus: bs, Thinker: thinker}) b.Start(context.Background()) - // Confirm the subscription is live by triggering one think (identified by its - // persisted record, so the 07.4.4 startup round and heartbeat ticks with their - // own trigger strings cannot mask or overwrite it). + // 通过触发一次思考来确认订阅是活的(以它持久化的 + // 记录为准,这样 07.4.4 的启动轮与心跳 tick 各自的 + // 触发字符串不会掩盖或覆盖它)。 if err := bs.Publish(context.Background(), SubjectReminderTrigger, "first"); err != nil { t.Fatalf("Publish: %v", err) } @@ -363,10 +363,10 @@ func TestBackthink_Stop_UnsubscribesBus(t *testing.T) { } } -// TestBackthink_EventPush_DedupSameEvent verifies the same-event dedup guard: a -// repeating identical event (same source + summary) is suppressed after the first -// push (防刷), while a genuinely different event still goes out. The duplicate is -// recorded as suppressed (route none), not delivered. +// TestBackthink_EventPush_DedupSameEvent 验证同一事件去重门: +// 重复的相同事件(同源 + 同摘要)在首次推送后 +// 被抑制(防刷),而真正不同的事件仍会发出。重复的那条 +// 被记为已抑制(route none),而不是投递出去。 func TestBackthink_EventPush_DedupSameEvent(t *testing.T) { t.Parallel() thinker := &mockThinker{text: "washing finished, clothes ready"} @@ -382,9 +382,9 @@ func TestBackthink_EventPush_DedupSameEvent(t *testing.T) { Device: iot.Device{ID: "washer", Type: "sensor"}, Action: "finished", }}) - b.think(context.Background(), ev) // first: pushes - b.think(context.Background(), ev) // second: same event -> suppressed - b.think(context.Background(), ev) // third: same event -> suppressed + b.think(context.Background(), ev) // 第一次:推送 + b.think(context.Background(), ev) // 第二次:同一事件 -> 被抑制 + b.think(context.Background(), ev) // 第三次:同一事件 -> 被抑制 if got := pusher.count(); got != 1 { t.Fatalf("pusher calls = %d, want 1 (repeating same event deduped)", got) @@ -396,7 +396,7 @@ func TestBackthink_EventPush_DedupSameEvent(t *testing.T) { if len(recs) < 3 { t.Fatalf("len(recs) = %d, want at least 3 (one per think)", len(recs)) } - // Newest first: the two most recent records are the suppressed duplicates. + // 最新的在前:最近的这两条就是被抑制的重复。 for i := 0; i < 2; i++ { if recs[i].PushResult.Pushed || recs[i].PushResult.Route != RouteNone { t.Fatalf("recs[%d] = %+v, want suppressed duplicate", i, recs[i].PushResult) @@ -405,12 +405,12 @@ func TestBackthink_EventPush_DedupSameEvent(t *testing.T) { t.Fatalf("recs[%d].Detail = %q, want it to mention duplicate", i, recs[i].PushResult.Detail) } } - // The third-most-recent record is the first (delivered) push. + // 第三新的那条是首次(已投递的)推送。 if !recs[2].PushResult.Pushed || recs[2].PushResult.Route != RouteActiveClient { t.Fatalf("recs[2] = %+v, want the first delivered push", recs[2].PushResult) } - // A genuinely different event resets the chain and goes out. + // 真正不同的事件会重置该链并发出。 ev2 := triggerForEvent(bus.Event{Subject: iot.DeviceUpdateSubject, Payload: iot.DeviceUpdate{ Device: iot.Device{ID: "dryer", Type: "sensor"}, Action: "finished", }}) @@ -418,8 +418,8 @@ func TestBackthink_EventPush_DedupSameEvent(t *testing.T) { if got := pusher.count(); got != 2 { t.Fatalf("pusher calls after a different event = %d, want 2", got) } - // After the different event, the original identical event is no longer a - // consecutive duplicate, so it pushes again — the guard only dedups consecutively. + // 在出现不同事件之后,原先那个相同事件不再是 + // 连续重复,因此它会再次推送——该门只去重连续重复。 b.think(context.Background(), ev) if got := pusher.count(); got != 3 { t.Fatalf("pusher calls after an intervening different event = %d, want 3", got) diff --git a/internal/services/backthink/cfg.go b/internal/services/backthink/cfg.go index c9319b5..a700af3 100644 --- a/internal/services/backthink/cfg.go +++ b/internal/services/backthink/cfg.go @@ -9,83 +9,83 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/config" ) -// Defaults applied by normalize when a Config field is left at its zero value. -// They are compile-time constants so no magic number appears in the run path. +// normalize 在 Config 字段留为零值时套用的默认值。 +// 它们是编译期常量,因此运行路径里不出现魔法数字。 const ( - // defaultIntervalSec is the heartbeat period in seconds when unused - // (15 minutes: the autonomous reflection is deliberately low-frequency). + // defaultIntervalSec 是未使用时的心跳周期秒数 + // (15 分钟:自主反思刻意保持低频)。 defaultIntervalSec = 900 - // defaultQuietStartHour and defaultQuietEndHour bound the do-not-disturb - // window in local clock hours (00:00-07:00 vs 23:00-07:00: the window may - // wrap midnight; two equal non-zero hours disable it). + // defaultQuietStartHour 与 defaultQuietEndHour 以本地钟点约束免打扰 + // 窗口(00:00-07:00 对 23:00-07:00:窗口可以 + // 跨午夜;两个相等的非零钟点会禁用该窗口)。 defaultQuietStartHour = 23 defaultQuietEndHour = 7 - // defaultPushLimit caps proactive pushes within defaultPushWindow to honor - // the anti-spam rule of design/11 §6 (QQ wind control, iLink window). The - // default drops to 1 so the owner is interrupted at most once per hour. + // defaultPushLimit 限制 defaultPushWindow 内的主动推送次数,以遵守 + // design/11 §6 的反骚扰规则(QQ 风控、iLink 窗口)。 + // 默认降到 1,因此主人每小时最多被打扰一次。 defaultPushLimit = 1 defaultPushWindow = 60 * time.Minute - // defaultPushCooldown is the quiet gap after a successful proactive push, - // so a successful delivery is not immediately repeated. + // defaultPushCooldown 是一次成功主动推送之后的安静间隔, + // 以免一次成功投递被立即重复。 defaultPushCooldown = 30 * time.Minute - // defaultRecentChatCooldown is the gap after the owner's last message within - // which an autonomous push is held, so a fresh conversation is not interrupted. + // defaultRecentChatCooldown 是主人最后一条消息之后的间隔, + // 在此期间自主推送被扣住,以免打断刚开始的对话。 defaultRecentChatCooldown = 10 * time.Minute - // defaultKindWindow bounds how often any single push flavor (问候/提醒/分享/ - // 关心) may repeat, so the same style cannot spam within it. + // defaultKindWindow 限制任何一种推送风格(问候/提醒/分享/ + // 关心)的重复频率,使同一风格无法在窗口内刷屏。 defaultKindWindow = 2 * time.Hour - // defaultThinkTimeout bounds a single unattended thought so it can never - // wedge a worker goroutine. + // defaultThinkTimeout 约束单次无人值守思考,因此它绝不会 + // 卡住 worker goroutine。 defaultThinkTimeout = 30 * time.Second - // defaultSessionID scopes a think when the caller supplies none. It is the - // loop.Run durability key, deliberately distinct from history.MainSessionID so - // an autonomous think never collides with the live main session's checkpoint - // store (channelbridge saves/resumes under the main id). The think still sees - // the main conversation: LoopThinker reads history.MainSessionID for context and - // runs under loop.OwnerEnv (design/20 §2). + // defaultSessionID 在调用方未提供时限定一次思考的作用域。它是 + // loop.Run 的持久化键,刻意与 history.MainSessionID 不同, + // 这样自主思考绝不会与活跃主会话的检查点 + // 存储冲突(channelbridge 在主 id 下保存/恢复)。该次思考仍能看到 + // 主对话:LoopThinker 读 history.MainSessionID 取上下文, + // 并在 loop.OwnerEnv 下运行(design/20 §2)。 defaultSessionID = "sess-backthink" ) -// DefaultSessionID is the exported loop.Run session key for an autonomous think. It is -// exposed so the flow-viz publisher (cmd/cyrene) can also publish the backthink flow -// graph, which the backthink page and the web-chat flow page both render. +// DefaultSessionID 是自主思考所用的、对外暴露的 loop.Run 会话键。它 +// 之所以暴露,是为了让流程可视化发布器(cmd/cyrene)也能发布 backthink +// 流程图,供 backthink 页与 Web 聊天流程页共同渲染。 const DefaultSessionID = "sess-backthink" -// Config carries the autonomous-think tuning knobs. Zero values pick safe -// defaults via normalize, so a partially filled Config is still usable. +// Config 携带自主思考的调优旋钮。零值会经 normalize +// 选取安全默认值,因此半填的 Config 仍可用。 type Config struct { - // Interval is the heartbeat period. When zero it is derived from - // IntervalSec (or its default). + // Interval 是心跳周期。为零时由 + // IntervalSec(或其默认值)推导。 Interval time.Duration - // IntervalSec is the heartbeat period in seconds, mapped from - // config.BackgroundConfig.IntervalSec. Ignored when Interval is set. + // IntervalSec 是心跳周期秒数,映射自 + // config.BackgroundConfig.IntervalSec。设置了 Interval 时忽略。 IntervalSec int - // QuietStartHour and QuietEndHour bound the do-not-disturb window in local - // clock hours. Equal non-zero hours disable the window. + // QuietStartHour 与 QuietEndHour 以本地钟点约束免打扰窗口。 + // 两个相等的非零钟点会禁用该窗口。 QuietStartHour int QuietEndHour int - // PushLimit bounds proactive pushes within PushWindow. Zero uses the default. - // Non-positive PushWindow or PushLimit disables the limit. + // PushLimit 限制 PushWindow 内的主动推送次数。为零用默认值。 + // 非正的 PushWindow 或 PushLimit 会禁用该限制。 PushLimit int - // PushWindow is the sliding window over which PushLimit is enforced. + // PushWindow 是执行 PushLimit 的滑动窗口。 PushWindow time.Duration - // PushCooldown is the quiet gap after a successful proactive push before - // another is allowed. Zero uses the default. + // PushCooldown 是一次成功主动推送之后、允许下一次之前的 + // 安静间隔。为零用默认值。 PushCooldown time.Duration - // RecentChatCooldown is the gap after the owner's last message within which an - // autonomous push is held. Zero uses the default. + // RecentChatCooldown 是主人最后一条消息之后的间隔,在此期间 + // 自主推送被扣住。为零用默认值。 RecentChatCooldown time.Duration - // KindWindow bounds how often any single push flavor may repeat. Zero uses the - // default. + // KindWindow 限制任何一种推送风格可重复的频率。为零用 + // 默认值。 KindWindow time.Duration - // ThinkTimeout bounds a single thinking pass. Zero uses the default. + // ThinkTimeout 约束单轮思考。为零用默认值。 ThinkTimeout time.Duration } -// ConfigFromBackground maps a config.BackgroundConfig (the source of the heartbeat -// interval, the shared quiet-hours window and the proactive-push frequency knobs) -// into a backthink.Config, leaving anything the env does not expose at its -// defaults for normalize to fill in. +// ConfigFromBackground 把 config.BackgroundConfig(心跳周期、 +// 共享安静时段窗口与主动推送频次旋钮的来源) +// 映射成 backthink.Config,env 未暴露的项保持其 +// 默认值,交给 normalize 填。 func ConfigFromBackground(bg config.BackgroundConfig) Config { return Config{ IntervalSec: bg.IntervalSec, @@ -99,7 +99,7 @@ func ConfigFromBackground(bg config.BackgroundConfig) Config { } } -// normalize fills a Config's zero fields with safe defaults. +// normalize 用安全默认值填充 Config 的零值字段。 func normalize(cfg Config) Config { if cfg.IntervalSec <= 0 { cfg.IntervalSec = defaultIntervalSec @@ -132,13 +132,13 @@ func normalize(cfg Config) Config { return cfg } -// quietHoursLabel renders the do-not-disturb window for logs. +// quietHoursLabel 为日志渲染免打扰窗口。 func quietHoursLabel(start, end int) string { return fmt.Sprintf("%02d:00-%02d:00", start, end) } -// baseLog returns l when non-nil, otherwise a discard logger, so New always has a -// safe logger to tag. +// baseLog 在 l 非 nil 时返回 l,否则返回丢弃式日志器,好让 New 总是有 +// 一个可标记的安全日志器。 func baseLog(l *slog.Logger) *slog.Logger { if l != nil { return l diff --git a/internal/services/backthink/context.go b/internal/services/backthink/context.go index d8f51e2..7209c64 100644 --- a/internal/services/backthink/context.go +++ b/internal/services/backthink/context.go @@ -7,24 +7,24 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) -// recentContextCount is how many of the most recent main-session messages are -// folded into one autonomous think as conversation context. Compile-time so no -// magic number appears in the run path. +// recentContextCount 是折进一次自主思考作为对话上下文的最新 +// 主会话消息条数。编译期常量,因此 +// 运行路径里不出现魔法数字。 const recentContextCount = 20 -// recentProvider returns the most recent messages of a session in oldest-first -// order, the tail an autonomous think reads to "see" the main conversation. -// *history.Store satisfies it; tests inject a fake. It is the narrow slice of -// history.HistoryStore that the thinker actually depends on, matching the loop's -// own checkpointStore style of minimal seam interfaces. +// recentProvider 以最旧在前的顺序返回会话最近的消息, +// 即一次自主思考为「看到」主对话而读取的尾部。 +// *history.Store 满足它;测试注入 fake。它是 thinker 实际 +// 依赖的那一小片 history.HistoryStore,与 loop 自己的 +// checkpointStore 风格的最小接缝接口一致。 type recentProvider interface { Recent(ctx context.Context, sessionID string, n int) ([]history.Message, error) } -// mainContext returns the most recent main-session messages converted to model -// messages (oldest-first) for one autonomous think. A nil provider, a failed read -// or an empty tail all yield nil so the think degrades to the guidance prompt -// alone rather than failing the pass (design/20 §2). +// mainContext 把最近的主会话消息转换为 model 消息 +// (最旧在前),供一次自主思考使用。提供者为 nil、读取失败 +// 或尾部为空都得到 nil,因此该次思考降级为仅用引导提示词, +// 而不是让这一轮失败(design/20 §2)。 func mainContext(ctx context.Context, prov recentProvider) []model.Message { if prov == nil { return nil @@ -40,9 +40,9 @@ func mainContext(ctx context.Context, prov recentProvider) []model.Message { return msgs } -// mapHistoryMessage converts one persisted chat message into a model message, -// translating the history role and preserving an image part when the message -// carries one so a multimodal main model still sees it. +// mapHistoryMessage 把一条持久化的 chat 消息转换为 model 消息, +// 翻译历史角色,并在消息携带图片时保留该图片片段, +// 因此多模态主模型仍能看到它。 func mapHistoryMessage(h history.Message) model.Message { msg := model.Message{ Role: historyRoleToModel(h.Role), @@ -54,8 +54,8 @@ func mapHistoryMessage(h history.Message) model.Message { return msg } -// historyRoleToModel maps a persisted chat role to the model's role vocabulary, -// defaulting unknown roles to the user lane. +// historyRoleToModel 把持久化的 chat 角色映射到 model 的角色词汇, +// 未知角色默认为 user 通道。 func historyRoleToModel(role string) model.Role { switch role { case history.RoleAssistant: @@ -67,9 +67,9 @@ func historyRoleToModel(role string) model.Role { } } -// thinkInput assembles the full input for one unattended think: the recent -// main-session context (if any) followed by a single guidance prompt turn, so the -// runtime reflects on real conversation rather than an empty macro prompt. +// thinkInput 拼装一次无人值守思考的完整输入:最近的主会话语境 +// (若有)后跟单个引导提示词回合,因此 +// 运行时针对真实对话反思,而不是空泛的宏提示词。 func thinkInput(recent []model.Message, trigger string) []model.Message { out := make([]model.Message, 0, len(recent)+1) out = append(out, recent...) @@ -77,19 +77,19 @@ func thinkInput(recent []model.Message, trigger string) []model.Message { return out } -// guidancePrompt is the instruction handed to the runtime for one unattended -// autonomous thought. Unlike the old backgroundPrompt it deliberately encourages a -// warm, targeted proactive message and drops the "do not produce a chat-style -// message" suppression. Identity/permission stay on the actor metadata + owner env, -// never on this prompt text (red line); the prompt only describes the running -// environment (memories readable/writable, tools callable) that the env already -// grants. +// guidancePrompt 是交给运行时的一次无人值守自主思考的 +// 指令。与旧 backgroundPrompt 不同,它刻意鼓励 +// 温暖、有针对性的主动消息,并去掉「不要产生 chat 风格 +// 消息」的抑制。身份/权限始终在 actor 元数据 + owner env 上, +// 绝不在这段提示词文本上(红线);提示词只描述运行 +// 环境(记忆可读/可写、工具可调用),而那是 env 已经 +// 授予的。 // -// The prompt asks for a compact structured closing — a push/no_push decision, an -// optional target channel and the message body — so the service can honor her own -// judgment instead of gating on the trigger's importance (design/20 §2 P2). The -// format is guidance, not a contract: parseProactive fails safe when she omits or -// mangles it. +// 提示词要求一个紧凑的结构化收尾——push/no_push 决策、 +// 可选的目标渠道与消息正文——好让服务尊重她自己的 +// 判断,而不是按触发的重要度把关(design/20 §2 P2)。该 +// 格式是引导而非契约:她省略或 +// 写乱该格式时,parseProactive 失败安全。 // // 启动轮(裁决 07.4.4,design/12 §1)走 startupPrompt:以角色口吻说一句拟人化的 // 「回来」问候。 diff --git a/internal/services/backthink/decide.go b/internal/services/backthink/decide.go index 6cd7781..7b04ec0 100644 --- a/internal/services/backthink/decide.go +++ b/internal/services/backthink/decide.go @@ -6,26 +6,26 @@ import ( "time" ) -// Importance is the estimated severity of an event that woke a thinking pass. -// Under design/20 §2 P2 it no longer decides *whether* she wants to speak (her -// conclusion does); it decides the hard-gate exemptions: a critical safety event -// punches through work/busy and quiet hours (裁决 07.4.2), and a high event still -// passes quiet hours (the pre-existing cascade semantics). +// Importance 是唤醒一轮思考的事件估计严重度。 +// 在 design/20 §2 P2 之下,它不再决定她*想不想*说话(那由她的 +// 结论决定);它决定硬门豁免:critical 安全事件 +// 穿透工作/忙碌与安静时段(裁决 07.4.2),而 high 事件仍 +// 通过安静时段(既有的级联语义)。 type Importance int const ( - // ImportanceLow is a routine trigger (e.g. the periodic heartbeat). + // ImportanceLow 是例行触发(如周期性心跳)。 ImportanceLow Importance = iota - // ImportanceNormal is an ordinary event worth reflecting on (device change). + // ImportanceNormal 是值得反思的普通事件(设备变化)。 ImportanceNormal - // ImportanceHigh is a notable event (a reminder the user set firing). + // ImportanceHigh 是值得注意的事件(用户设置的提醒触发)。 ImportanceHigh - // ImportanceCritical is a safety-critical event that punches through - // work/busy and quiet hours (裁决 07.4.2 安全域豁免). + // ImportanceCritical 是穿透 + // 工作/忙碌与安静时段的安全关键事件(裁决 07.4.2 安全域豁免)。 ImportanceCritical ) -// String returns a stable label for logs and records. +// String 返回用于日志与记录的稳定标签。 func (i Importance) String() string { switch i { case ImportanceLow: @@ -41,55 +41,55 @@ func (i Importance) String() string { } } -// longIdleThreshold is the gap after the owner's last message beyond which the -// activity gate only lets a genuinely important event through — except heartbeat -// passes, which are never held by a long-silent gap (裁决 07.4.3). Compile-time -// so no magic number appears. +// longIdleThreshold 是主人最后一条消息之后的间隔,超过它活动门 +// 只放行真正重要的事件——但心跳轮除外, +// 它们永远不会被长期静默的间隔拦下(裁决 07.4.3)。编译期 +// 常量,因此不出现魔法数字。 const longIdleThreshold = 2 * time.Hour -// UserState describes the user's current condition as observed from device -// telemetry (heart rate, sleep) and schedule (work, busy). It is the negative -// signal in the interrupt decision: a sleeping, working or busy user is left -// alone. Boredom (0..1) is the soft positive dial that widens interruption. +// UserState 描述由设备遥测(心率、睡眠)与日程(工作、忙碌) +// 观察到的用户当前状况。它是打扰决策里的负向 +// 信号:睡眠、工作或忙碌中的用户不被打扰。 +// 无聊度(0..1)是放宽打扰的软性正向旋钮。 type UserState struct { - // HeartRateBPM is the current heart rate, 0 when unknown. + // HeartRateBPM 是当前心率,未知时为 0。 HeartRateBPM int - // Sleeping reports whether the user is currently asleep from sleep tracking. + // Sleeping 报告睡眠追踪显示用户当前是否睡着。 Sleeping bool - // Working reports whether the user is in a work/scheduled block. + // Working 报告用户是否处于工作/日程时段。 Working bool - // Busy reports whether the user is marked busy (calendar / do-not-disturb). + // Busy 报告用户是否被标记为忙碌(日历 / 免打扰)。 Busy bool - // Boredom ranges 0..1. Higher boredom means the user is idle and more open - // to a gentle interruption. Under design/20 §2 P2 the push intent is her own - // conclusion, so this dial no longer gates the decision; it is kept as a - // telemetry signal her judgment already absorbs. + // Boredom 取值 0..1。无聊度越高表示用户越空闲、越愿意 + // 被温和地打扰。在 design/20 §2 P2 之下推送意图来自她自己的 + // 结论,所以这个旋钮不再为决策把关;它作为一个 + // 她的判断本就会吸收的遥测信号保留下来。 Boredom float64 } -// String returns a concise human-readable summary for logs/records. +// String 返回用于日志/记录的简洁可读摘要。 func (s UserState) String() string { return fmt.Sprintf("bpm=%d sleeping=%v working=%v busy=%v boredom=%.2f", s.HeartRateBPM, s.Sleeping, s.Working, s.Busy, s.Boredom) } -// DecisionInput is the set of signals Decide weighs: the event's importance, the -// current wall-clock time, the user's current state and their boredom. +// DecisionInput 是 Decide 所称量的信号集合:事件重要度、 +// 当前墙钟时间、用户当前状态及其无聊度。 type DecisionInput struct { - // Trigger is a human-readable source label, recorded for traceability. + // Trigger 是可读的来源标签,为可追溯而记录。 Trigger string - // Import is the event's estimated importance. + // Import 是事件的估计重要度。 Import Importance - // At is the current wall-clock time, in the local timezone used for the - // do-not-disturb window. + // At 是当前墙钟时间,处于免打扰窗口所用的 + // 本地时区。 At time.Time - // UserState carries the user's physiological/schedule condition. + // UserState 携带用户的生理/日程状况。 UserState UserState - // LastActivity is the wall-clock time of the owner's most recent message. - // HasActivity reports whether a user message was found; when false the - // activity gate falls through without guessing. + // LastActivity 是主人最近一条消息的墙钟时间。 + // HasActivity 报告是否找到了用户消息;为 false 时 + // 活动门直接落空、不靠猜。 LastActivity time.Time - // HasActivity reports whether LastActivity is meaningful. + // HasActivity 报告 LastActivity 是否有意义。 HasActivity bool // ConclusionPush 是她自己思考结论的推送意愿(design/20 §2 P2):「想不想说」 // 由她的结论决定,本决策只回答「此刻能不能说」。 @@ -99,11 +99,11 @@ type DecisionInput struct { Heartbeat bool } -// Decision is the outcome of Decide. +// Decision 是 Decide 的结果。 type Decision struct { - // ShouldPush reports whether the user should be interrupted. + // ShouldPush 报告是否应打扰用户。 ShouldPush bool - // Reason is a short human-readable justification, surfaced on the panel. + // Reason 是简短可读的理由,会在面板上呈现。 Reason string } @@ -192,9 +192,9 @@ func (b *Backthink) activityHeld(in DecisionInput) (bool, string) { return false, "" } -// inQuietHours reports whether at falls inside the configured do-not-disturb -// window. The window may wrap midnight (e.g. 23:00-07:00); two equal non-zero -// hours disable it. +// inQuietHours 报告 at 是否落在已配置的免打扰 +// 窗口内。窗口可以跨午夜(如 23:00-07:00);两个相等的非零 +// 钟点会禁用它。 func (b *Backthink) inQuietHours(at time.Time) bool { h := at.Hour() start, end := b.cfg.QuietStartHour, b.cfg.QuietEndHour @@ -204,24 +204,24 @@ func (b *Backthink) inQuietHours(at time.Time) bool { return h >= start || h < end } -// PushKind classifies a proactive message into one of a few flavors so the -// frequency gate can limit each flavor within a window, avoiding same-style spam -// (design/12 §6 策略二). It is a soft tag derived from the message body's -// keywords — it affects pacing, never identity or permission. +// PushKind 把主动消息归类成几种风格,好让 +// 频率门能在窗口内限制每种风格,避免同一风格刷屏 +// (design/12 §6 策略二)。它是由消息正文关键词导出的软标签 +// ——它只影响节奏,绝不影响身份或权限。 type PushKind int const ( - // KindGreeting is a warm hello / good-morning / good-night opener. + // KindGreeting 是温暖的打招呼 / 早安 / 晚安开场。 KindGreeting PushKind = iota - // KindReminder is a nudge about a schedule, task or deadline. + // KindReminder 是关于日程、任务或截止时间的提醒。 KindReminder - // KindShare is a discovery, observation or interesting find. + // KindShare 是发现、观察或有意思的见闻。 KindShare - // KindCare is a warm, caring check-in about the owner's wellbeing. + // KindCare 是对主人身心状况的温暖关心问候。 KindCare ) -// String returns a stable label for logs and records. +// String 返回用于日志与记录的稳定标签。 func (k PushKind) String() string { switch k { case KindGreeting: @@ -237,11 +237,11 @@ func (k PushKind) String() string { } } -// classifyPush assigns a PushKind to a proactive message from its keyword -// content. A message that matches no flavor defaults to KindCare (the warm, -// generic intent of an unsolicited proactive note), which is the least -// spam-prone grouping. Priority is reminder > care > greeting > share so the -// dominant intent wins when several keywords co-occur. +// classifyPush 按关键词内容给主动消息指派 PushKind。 +// 不匹配任何风格的消息默认为 KindCare(一条主动问候的 +// 温暖、泛化意图),那是最不容易 +// 刷屏的分组。优先级为 reminder > care > greeting > share, +// 因此多个关键词共现时主流意图获胜。 func classifyPush(message string) PushKind { switch { case hasAnyKeyword(message, kindReminderKeywords...): @@ -257,9 +257,9 @@ func classifyPush(message string) PushKind { } } -// keyword sets for classifyPush. They are package vars (not consts) so the -// classification stays flexible and testable, but they are immutable by -// convention and never mutated at run time. +// classifyPush 所用的关键词集合。它们是包级变量(非常量)以便 +// 分类保持灵活可测,但按约定不可变, +// 从不在运行时改写。 var ( kindReminderKeywords = []string{"提醒", "记得", "别忘了", "该去", "日程", "会议", "体检", "吃药", "喝药", "到点", "几点", "还有", "迟到", "明天", "马上", "最后", "截止"} kindCareKeywords = []string{"关心", "最近", "辛苦", "注意身体", "照顾好", "好好休息", "心情", "还好", "想你了", "想我", "担心", "保重", "你还好", "陪你", "累"} @@ -267,7 +267,7 @@ var ( kindShareKeywords = []string{"发现", "分享", "看到", "听说", "注意到", "告诉你", "有个", "想到", "有意思", "有趣", "你知道吗"} ) -// hasAnyKeyword reports whether s contains any of the given substrings. +// hasAnyKeyword 报告 s 是否包含给定子串中的任意一个。 func hasAnyKeyword(s string, subs ...string) bool { for _, sub := range subs { if strings.Contains(s, sub) { diff --git a/internal/services/backthink/decide_test.go b/internal/services/backthink/decide_test.go index e99f219..195cb25 100644 --- a/internal/services/backthink/decide_test.go +++ b/internal/services/backthink/decide_test.go @@ -5,14 +5,14 @@ import ( "time" ) -// dayAt returns a mid-afternoon time in UTC (outside the quiet-hours window). +// dayAt 返回 UTC 的午后时间(在安静时段窗口之外)。 func dayAt() time.Time { return time.Date(2026, 9, 6, 14, 0, 0, 0, time.UTC) } -// nightAt returns a 02:00 time in UTC (inside the default 23-7 quiet window). +// nightAt 返回 UTC 的 02:00(在默认 23-7 安静窗口之内)。 func nightAt() time.Time { return time.Date(2026, 9, 6, 2, 0, 0, 0, time.UTC) } -// testBackthink builds a Backthink with a normalized empty Config so Decide uses -// the default quiet-hours window (23:00-07:00). +// testBackthink 用归一化的空 Config 构建 Backthink,因此 Decide 使用 +// 默认安静时段窗口(23:00-07:00)。 func testBackthink() *Backthink { return &Backthink{cfg: normalize(Config{})} } @@ -144,8 +144,8 @@ func TestInQuietHours(t *testing.T) { } } -// TestClassifyPush verifies the proactive message body maps to the intended flavor -// (问候/提醒/分享/关心) so the frequency gate can limit each flavor separately. +// TestClassifyPush 验证主动消息正文映射到预期的风格 +// (问候/提醒/分享/关心),使频率门能分别限制每种风格。 func TestClassifyPush(t *testing.T) { t.Parallel() cases := []struct { @@ -168,16 +168,16 @@ func TestClassifyPush(t *testing.T) { } } -// TestDecide_ActivityGate verifies the activity-aware soft gate: a fresh -// conversation holds trivia (unless critical), a long-idle owner is only woken by -// an important event — except a heartbeat pass, which is never held by a -// long-silent gap (裁决 07.4.3) — and the in-between band does not over-suppress. +// TestDecide_ActivityGate 验证活动感知软门:刚开始的 +// 对话会扣下琐碎内容(critical 除外),长期静默的主人只会被 +// 重要事件唤醒——但心跳轮除外,它永远不会被长期 +// 静默的间隔拦下(裁决 07.4.3)——而中间地带不会过度抑制。 func TestDecide_ActivityGate(t *testing.T) { t.Parallel() b := testBackthink() - // Just chatted (within RecentChatCooldown=10min): a normal daytime push is - // held unless the event is safety-critical. + // 刚聊过(在 RecentChatCooldown=10min 内):白天普通推送 + // 会被扣下,除非该事件是安全关键。 fresh := DecisionInput{ Import: ImportanceNormal, At: dayAt(), @@ -194,8 +194,8 @@ func TestDecide_ActivityGate(t *testing.T) { t.Fatalf("critical event should push even in a fresh conversation, got %+v", got) } - // Away a long while (> longIdleThreshold): only an important event is worth - // it — but a heartbeat pass still goes through (裁决 07.4.3 长期静默段不拦心跳). + // 离开很久(> longIdleThreshold):只有重要事件值得 + // 唤醒——但心跳轮仍会通过(裁决 07.4.3 长期静默段不拦心跳)。 idle := DecisionInput{ Import: ImportanceNormal, At: dayAt(), @@ -217,7 +217,7 @@ func TestDecide_ActivityGate(t *testing.T) { t.Fatalf("long idle heartbeat should still push (07.4.3), got %+v", got) } - // Middling activity (30 min ago) is not over-suppressed. + // 中等活动(30 分钟前)不会被过度抑制。 middle := DecisionInput{ Import: ImportanceNormal, At: dayAt(), diff --git a/internal/services/backthink/event_dedup.go b/internal/services/backthink/event_dedup.go index 21d76a7..945bc61 100644 --- a/internal/services/backthink/event_dedup.go +++ b/internal/services/backthink/event_dedup.go @@ -1,20 +1,20 @@ package backthink -// eventRedundant reports whether tr is a duplicate of the immediately preceding -// event-driven push (same source + summary), so a repeating device/reminder event -// does not spam the owner (防刷). It is the one guard that still applies to an -// event-driven push while the PushGate itself is bypassed for events (owner -// mandate: "该发就发" — a genuine event goes out, only a true duplicate is held). +// eventRedundant 报告 tr 是否与前一次事件驱动的推送重复 +// (同源 + 同摘要),因此重复的设备/提醒事件 +// 不会刷屏主人(防刷)。当 PushGate 对事件本身被绕过时, +// 它是仅剩的一道仍适用于事件驱动推送的 +// 门(主人要求:"该发就发"——真正的事件照发,只扣下真正的重复)。 // -// It atomically records tr as the latest event key when the push proceeds, and -// clears the key on any non-event push (heartbeat) so a later repeat of a prior -// event is not spuriously suppressed by an intervening reflection. +// 推送进行时它原子地把 tr 记为最新事件键,并在任何非事件推送 +// (心跳)时清除该键,以免更早事件的再次重复 +// 被中间一次反思错误地抑制。 // -// The caller runs it before handing the push to the Pusher; a true duplicate is -// recorded as suppressed (RouteNone) rather than delivered. +// 调用方把它跑在把推送交给 Pusher 之前;真正的重复 +// 会被记为已抑制(RouteNone),而不是投递出去。 func (b *Backthink) eventRedundant(tr Trigger) bool { if tr.Kind != TriggerEvent { - // A non-event push (heartbeat) breaks the same-event chain. + // 非事件推送(心跳)会打断同一事件链。 b.eventMu.Lock() b.lastEventKey = "" b.eventMu.Unlock() diff --git a/internal/services/backthink/mocks_test.go b/internal/services/backthink/mocks_test.go index a58bf2c..42dfa27 100644 --- a/internal/services/backthink/mocks_test.go +++ b/internal/services/backthink/mocks_test.go @@ -13,8 +13,8 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/db" ) -// mockThinker records every Think invocation and returns a fixed conclusion. It -// respects ctx cancellation so Stop can drain a hung pass. +// mockThinker 记录每次 Think 调用并返回固定结论。它 +// 尊重 ctx 取消,好让 Stop 能排空挂住的轮次。 type mockThinker struct { mu sync.Mutex text string @@ -45,12 +45,12 @@ func (m *mockThinker) Think(ctx context.Context, sessionID, trigger string) (str func (m *mockThinker) count() int { m.mu.Lock(); defer m.mu.Unlock(); return m.calls } func (m *mockThinker) lastTrigger() string { m.mu.Lock(); defer m.mu.Unlock(); return m.last } -// mockState returns a fixed UserState. +// mockState 返回固定的 UserState。 type mockState struct{ state UserState } func (m *mockState) Current(context.Context) (UserState, error) { return m.state, nil } -// mockPusher records Push calls and returns a fixed result. +// mockPusher 记录 Push 调用并返回固定结果。 type mockPusher struct { mu sync.Mutex result PushResult @@ -70,7 +70,7 @@ func (p *mockPusher) Push(ctx context.Context, req PushRequest) PushResult { func (p *mockPusher) count() int { p.mu.Lock(); defer p.mu.Unlock(); return p.calls } -// mockActive implements ActiveClient. +// mockActive 实现 ActiveClient。 type mockActive struct { mu sync.Mutex n int @@ -87,7 +87,7 @@ func (m *mockActive) SendProactive(ctx context.Context, msg adapter.UnifiedMessa return m.n, m.err } -// mockIMAdapter implements IMAdapter. +// mockIMAdapter 实现 IMAdapter。 type mockIMAdapter struct { mu sync.Mutex err error @@ -105,14 +105,14 @@ func (m *mockIMAdapter) SendProactive(ctx context.Context, msg adapter.UnifiedMe func (m *mockIMAdapter) count() int { m.mu.Lock(); defer m.mu.Unlock(); return m.calls } -// mockGate implements PushGate. +// mockGate 实现 PushGate。 type mockGate struct{ allow bool } func (g *mockGate) Allow(context.Context, PushKind) bool { return g.allow } func (g *mockGate) RecordPush(PushKind) {} -// newTestDB opens an isolated in-memory SQLite handle for one test and closes it -// on cleanup. +// newTestDB 为单个测试打开一个隔离的内存 SQLite 句柄,并在 +// 清理时关闭它。 func newTestDB(t *testing.T) *sql.DB { t.Helper() h, err := db.Open(context.Background(), ":memory:", nil) @@ -123,12 +123,12 @@ func newTestDB(t *testing.T) *sql.DB { return h } -// discardLog returns a logger that writes nowhere. +// discardLog 返回一个什么都不写的日志器。 func discardLog() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } -// eventually polls cond until it is true or the deadline passes. +// eventually 轮询 cond 直到它为真或超过期限。 func eventually(t *testing.T, timeout time.Duration, cond func() bool) { t.Helper() deadline := time.Now().Add(timeout) diff --git a/internal/services/backthink/proactive.go b/internal/services/backthink/proactive.go index 831e7f8..2e08fc7 100644 --- a/internal/services/backthink/proactive.go +++ b/internal/services/backthink/proactive.go @@ -6,63 +6,63 @@ import ( "unicode" ) -// The autonomous think is guided (guidancePrompt in context.go) to conclude with -// a compact, machine-parseable decision so the service no longer has to infer -// whether to push from the trigger's importance alone (design/20 §2 P2): +// 自主思考被引导(context.go 的 guidancePrompt)以如下紧凑、 +// 机器可解析的决策收尾,好让服务不必再从触发的重要度 +// 单独推断是否推送(design/20 §2 P2): // -// [我决定:push] (or no_push) +// [我决定:push] (或 no_push) // [渠道:web|im|kook|telegram|...|auto] -// +// <温暖的正文内容> // -// The markers are parsed as plain text so a model that formats them slightly -// differently still yields a usable decision; a malformed/ambiguous decision -// marker fails safe to "no push". +// 这些标记按纯文本解析,因此模型稍有不同地 +// 格式化它们时仍能得出可用的决策;格式错乱/含糊的决策 +// 标记失败安全为「不推送」。 var ( - // decideMarker matches the push/no_push decision token, tolerating a colon in - // either width and optional spaces. It is used to recover the decision, so it - // stays strict on the bracketed form: a bare "我决定:push" is treated as prose, - // not as a decision (fail safe). + // decideMarker 匹配 push/no_push 决策词,容忍两种宽度的 + // 冒号与可选空格。它用于恢复决策,所以对 + // 带括号的形式保持严格:裸的 "我决定:push" 被当作散文, + // 而不是决策(失败安全)。 decideMarker = regexp.MustCompile(`(?i)\[\s*我决定\s*[::]\s*(no_push|push)\s*\]`) - // channelMarker matches the target-channel token on the decision line. + // channelMarker 匹配决策行上的目标渠道词。 channelMarker = regexp.MustCompile(`(?i)\[\s*渠道\s*[::]\s*([a-z0-9_\-]+)\s*\]`) - // decidePrefixes are the marker shapes that signal a decision was attempted - // but could not be parsed, so the pass is treated as ambiguous (fail safe). It - // is deliberately the bracketed form only, so a message that merely contains - // "我决定:" in prose (common in Chinese) is not misread as a malformed marker. + // decidePrefixes 是「尝试了决策但无法解析」的标记形状, + // 这类轮次被视为含糊(失败安全)。它 + // 刻意只含带括号形式,因此仅仅在散文中出现 + // "我决定:"(中文里很常见)的消息不会被误读为格式错乱的标记。 decidePrefixes = []string{"[我决定"} - // stripDecideRe / stripChannelRe recognize the decision/channel markers as a - // token to delete from the user-facing body, in their intended bracketed form - // and with full-width brackets, so a well-formatted decision never leaks. + // stripDecideRe / stripChannelRe 把决策/渠道标记识别为 + // 需要从用户可见正文中删除的词,按它们预期的带括号形式 + // 与全角括号形式处理,因此格式良好的决策永不泄漏。 stripDecideRe = regexp.MustCompile(`(?i)[【\[]\s*我决定\s*[::]\s*(no_push|push)\s*[】\]]`) stripChannelRe = regexp.MustCompile(`(?i)[【\[]\s*渠道\s*[::]\s*[a-z0-9_\-]+\s*[】\]]`) bareDecideRe = regexp.MustCompile(`(?i)我决定\s*[::]\s*(no_push|push)`) bareChannelRe = regexp.MustCompile(`(?i)渠道\s*[::]\s*[a-z0-9_\-]+`) ) -// nonePhrases are conclusions that mean "nothing to report" when no marker is -// present. The body is normalized (case-folded, punctuation/spaces stripped) -// before comparison so "无", "无。" and "Nothing to report." all collapse to the -// same token. +// nonePhrases 是没有标记时表示「无可汇报」的结论。 +// 正文在比较前先归一化(折叠大小写、去掉标点/空格), +// 因此 "无"、"无。" 与 "Nothing to report." 都收敛到 +// 同一个词。 var nonePhrases = []string{ "无", "没什么", "没有", "无物可报", "暂无", "没有值得说的", "nothing", "none", "nothingtoreport", "nothingworthreporting", "nothingnew", } -// parseProactive recovers the push decision, the target-channel hint and the -// clean message body from a think conclusion. It is a pure function: it reads no -// state and performs no I/O, so it is safe to call concurrently and cheap to test. +// parseProactive 从思考结论中恢复推送决策、目标渠道提示与 +// 干净的正文消息。它是纯函数:不读状态、不做 I/O, +// 因此可安全并发调用、测试成本低。 // -// Semantics (design/20 §2 P2): -// - A well-formed [我决定:push|no_push] marker is authoritative. -// - A malformed/ambiguous decision marker → no push (fail safe). -// - No marker: a body that reads as "无"/"nothing to report" → no push; -// otherwise the body is treated as a proactive message (push=true), matching -// the pre-P2 default. -// - The channel hint is the [渠道:...] token, lowercased; empty/auto keeps the -// default active-client-first, IM-fallback route. -// - A push decision whose body is empty or still reads as "nothing" → no push -// (fail safe), so a lone marker never pushes an empty message. +// 语义(design/20 §2 P2): +// - 格式良好的 [我决定:push|no_push] 标记具有权威性。 +// - 格式错乱/含糊的决策标记 → 不推送(失败安全)。 +// - 无标记:正文读作 "无"/"nothing to report" → 不推送; +// 否则正文被当作主动消息(push=true),与 +// P2 之前的默认一致。 +// - 渠道提示是 [渠道:...] 词,转小写;为空/auto 则保持 +// 默认的活跃客户端优先、IM 降级路由。 +// - 决策为推送但正文为空或仍读作「无」 → 不推送 +// (失败安全),因此单独一个标记绝不会推出空消息。 func parseProactive(conclusion string) (message, channelHint string, push bool) { text := strings.TrimSpace(conclusion) @@ -83,8 +83,8 @@ func parseProactive(conclusion string) (message, channelHint string, push bool) return message, channelHint, push } -// matchDecide returns the matched decision token (lowercased) and whether a -// well-formed decision marker was present. +// matchDecide 返回匹配到的决策词(已转小写)以及 +// 是否存在格式良好的决策标记。 func matchDecide(text string) (string, bool) { m := decideMarker.FindStringSubmatch(text) if m == nil { @@ -93,8 +93,8 @@ func matchDecide(text string) (string, bool) { return strings.ToLower(m[1]), true } -// hasAmbiguousDecide reports whether a decision marker was attempted but could -// not be parsed, which the caller treats as "do not push" (fail safe). +// hasAmbiguousDecide 报告是否尝试了决策标记但 +// 无法解析,调用方把它当作「不推送」(失败安全)。 func hasAmbiguousDecide(text string) bool { if decideMarker.MatchString(text) { return false @@ -107,9 +107,9 @@ func hasAmbiguousDecide(text string) bool { return false } -// matchChannel returns the lowercased target-channel token, or "" when the -// conclusion carries no (or a malformed) channel marker. A malformed channel -// marker degrades to the default route rather than failing the whole pass. +// matchChannel 返回已转小写的目标渠道词;结论未携带 +// (或携带格式错乱的)渠道标记时返回 ""。格式错乱的渠道 +// 标记降级为默认路由,而不是让整轮失败。 func matchChannel(text string) string { m := channelMarker.FindStringSubmatch(text) if m == nil { @@ -118,15 +118,15 @@ func matchChannel(text string) string { return strings.ToLower(m[1]) } -// stripInternalMarkers removes the structured decision/channel markers so a -// proactive message body never carries them to the user. It handles the intended -// bracketed form ([我决定:push][渠道:kook]) plus the drift forms a model may emit — -// bare "我决定:push" / "渠道:kook" (no brackets) and full-width 【我决定:push】 — then -// drops any line that becomes empty and collapses the remainder. It is applied -// both inside parseProactive (so the message and record are clean) and again as a -// final guard in buildProactiveMessage, so a marker cannot survive into the -// outbound body no matter how it was formatted. Only our logs/records may carry -// the marker; the user-facing message must be pure warm content. +// stripInternalMarkers 移除结构化的决策/渠道标记,因此 +// 主动消息正文绝不会把它们带给用户。它处理预期的 +// 带括号形式([我决定:push][渠道:kook])以及模型可能发出的漂移形式—— +// 裸的 "我决定:push" / "渠道:kook"(无括号)与全角 【我决定:push】——然后 +// 丢弃因此变空的行并压缩剩余部分。它既在 +// parseProactive 内部应用(使消息与记录干净),又作为 +// buildProactiveMessage 里的最后一道守护,因此无论标记如何 +// 被格式化,都无法存活到出站正文里。只有我们的日志/记录可以携带 +// 该标记;面向用户的消息必须是纯温暖内容。 func stripInternalMarkers(text string) string { lines := strings.Split(text, "\n") out := make([]string, 0, len(lines)) @@ -144,8 +144,8 @@ func stripInternalMarkers(text string) string { return strings.Join(out, "\n") } -// looksNone reports whether the body says "nothing worth saying". It is used both -// to drive the no-marker default and to fail-safe a contradictory push marker. +// looksNone 报告正文是否表示「没什么值得说的」。它既用于 +// 驱动无标记时的默认,也用于对矛盾的 push 标记失败安全。 func looksNone(text string) bool { t := normalizeNone(text) if t == "" { @@ -159,8 +159,8 @@ func looksNone(text string) bool { return false } -// normalizeNone lowercases the body and strips spaces and punctuation so the -// nonePhrases compare cleanly across "无", "无。", "Nothing to report." and similar. +// normalizeNone 把正文转小写并去掉空格与标点,好让 +// nonePhrases 在 "无"、"无。"、"Nothing to report." 之类之间干净比较。 func normalizeNone(text string) string { var b strings.Builder for _, r := range strings.ToLower(strings.TrimSpace(text)) { diff --git a/internal/services/backthink/proactive_test.go b/internal/services/backthink/proactive_test.go index bb73091..ebe5b0c 100644 --- a/internal/services/backthink/proactive_test.go +++ b/internal/services/backthink/proactive_test.go @@ -41,7 +41,7 @@ func TestParseProactive_NoPushMarker(t *testing.T) { func TestParseProactive_ChannelColonWidth(t *testing.T) { t.Parallel() - // Tolerate a full-width colon and trailing spaces, and uppercase channel value. + // 容忍全角冒号与尾随空格,以及大写渠道值。 conclusion := "[我决定:push] [渠道:WEB] 晚上早点睡" msg, hint, push := parseProactive(conclusion) if !push { @@ -104,11 +104,11 @@ func TestParseProactive_MalformedFailsSafe(t *testing.T) { func TestParseProactive_MarkerWithoutBody(t *testing.T) { t.Parallel() - // A lone push marker with no body must not push an empty message (fail safe). + // 单独一个 push 标记、正文为空时绝不能推出空消息(失败安全)。 if _, _, push := parseProactive("[我决定:push]"); push { t.Fatalf("push = true for a marker with no body, want false") } - // A push marker whose body says "nothing" is contradictory → fail safe. + // push 标记的正文写着「无」是自相矛盾的 → 失败安全。 if _, _, push := parseProactive("[我决定:push]\n无"); push { t.Fatalf("push = true for a push marker with nothing body, want false") } @@ -131,8 +131,8 @@ func TestThink_ParsesStructuredDecision_EndToEnd(t *testing.T) { b.think(context.Background(), heartbeatTrigger()) - // The channel hint "kook" routes to the IM bridge (a specific platform), not the - // active client. + // 渠道提示 "kook" 路由到 IM 桥(一个具体平台),而不是 + // 活跃客户端。 if active.calls != 0 { t.Fatalf("active calls = %d, want 0 (specific platform routes to IM)", active.calls) } @@ -172,8 +172,8 @@ func TestThink_ParsesStructuredDecision_EndToEnd(t *testing.T) { func TestThink_NoMarkerDefaultRoute_IM(t *testing.T) { t.Parallel() - // No channel marker → auto → a single IM channel (the bridge picks her choice / - // her recent platform / the whitelist default), NOT the active web client. + // 无渠道标记 → auto → 单个 IM 渠道(由桥挑出她的选择 / + // 她最近的平台 / 白名单默认),而不是活跃 Web 客户端。 conclusion := "[我决定:push]\n洗衣机洗好了,衣服可以收了" thinker := &mockThinker{text: conclusion} active := &mockActive{n: 2} @@ -241,8 +241,8 @@ func TestThink_ConcludedNoPush_NotPushed(t *testing.T) { func TestThink_DoNotDisturbBlocksPush(t *testing.T) { t.Parallel() - // She wants to push, but the user is asleep → the hard safety gate refuses, - // regardless of her decision. + // 她想推送,但用户正在睡觉 → 硬安全门拒绝, + // 与她的决策无关。 conclusion := "[我决定:push]\n想跟你说晚安" thinker := &mockThinker{text: conclusion} b, _ := newBackthink(t, Config{}, Deps{ @@ -292,9 +292,9 @@ func TestBuildProactiveMessage_SpecificPlatform(t *testing.T) { func TestPusher_SpecificPlatform_NoIM_FallsBackToActive(t *testing.T) { t.Parallel() - // When a specific platform is requested but no IM sink is wired, the pusher - // falls back to the default route so the owner still receives the message, and - // records the requested hint. + // 请求了具体平台但未接线 IM 槽时,推送器 + // 降级到默认路由,好让主人仍能收到消息,并 + // 记录下请求的提示。 active := &mockActive{n: 1} p := newPusher(active, nil, &mockGate{allow: true}, discardLog()) res := p.Push(context.Background(), PushRequest{ @@ -309,9 +309,9 @@ func TestPusher_SpecificPlatform_NoIM_FallsBackToActive(t *testing.T) { } } -// TestStripInternalMarkers_CleanBody verifies the internal decision/channel -// markers are removed from a proactive body in every shape a model may emit -// (bracketed, full-width, bare), so a marker can never reach the user-facing text. +// TestStripInternalMarkers_CleanBody 验证内部决策/渠道 +// 标记在模型可能发出的每种形状下(带括号、全角、裸写) +// 都被从主动正文中移除,因此标记永远无法到达面向用户的文本。 func TestStripInternalMarkers_CleanBody(t *testing.T) { t.Parallel() cases := []struct { @@ -336,9 +336,9 @@ func TestStripInternalMarkers_CleanBody(t *testing.T) { } } -// TestThink_BareMarkersStrippedFromBody is the regression for the KOOK leak: a -// conclusion carrying bare (bracket-less) "我决定:push"/"渠道:..." markers must -// still deliver a clean warm body, never the structured decision text. +// TestThink_BareMarkersStrippedFromBody 是 KOOK 泄漏的回归用例: +// 携带裸写(无括号)"我决定:push"/"渠道:..." 标记的结论仍须 +// 投递干净的温暖正文,绝不是结构化的决策文本。 func TestThink_BareMarkersStrippedFromBody(t *testing.T) { t.Parallel() conclusion := "我决定:push\n渠道:kook\n记得喝水哦" diff --git a/internal/services/backthink/push.go b/internal/services/backthink/push.go index 43b9c36..3852dea 100644 --- a/internal/services/backthink/push.go +++ b/internal/services/backthink/push.go @@ -12,56 +12,56 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session" ) -// Route identifies how a proactive message was actually delivered, for the -// monitor panel and the push result record. +// Route 标识一条主动消息实际是如何投递的,供监控面板 +// 与推送结果记录使用。 type Route string const ( - // RouteNone means no push was attempted or no target was available. + // RouteNone 表示未尝试推送,或无可用目标。 RouteNone Route = "none" - // RouteRateLimited means the push was refused by the rate limiter. + // RouteRateLimited 表示推送被限流器拒绝。 RouteRateLimited Route = "rate_limited" - // RouteActiveClient means the message went to an active web/native client. + // RouteActiveClient 表示消息发往了活跃 Web/原生客户端。 RouteActiveClient Route = "active_client" - // RouteIMBridge means the message fell back to the IM bridge. + // RouteIMBridge 表示消息降级到 IM 桥。 RouteIMBridge Route = "im_bridge" ) -// Channel-hint tokens recognized by the push router. They come from the -// conclusion's [渠道:...] marker (parseProactive) and select where a proactive -// message is delivered. An empty/unknown hint is treated as auto. A concrete -// platform id (kook/telegram/...) is a value other than these three. +// 推送路由器识别的渠道提示词。它们来自结论的 +// [渠道:...] 标记(parseProactive),用于选择主动消息 +// 的投递去向。为空/未知的提示按 auto 处理。具体的 +// 平台 id(kook/telegram/...)是这三个之外的值。 const ( channelAuto = "auto" channelIM = "im" channelWeb = "web" - // defaultProactivePlatform is the Platform the outbound proactive message - // carries when the conclusion names no specific channel. + // defaultProactivePlatform 是结论未指明具体渠道时, + // 出站主动消息携带的 Platform。 defaultProactivePlatform = "cyrene-backthink" ) -// String returns the route as a plain label. +// String 把路由作为普通标签返回。 func (r Route) String() string { return string(r) } -// PushResult is the outcome of one proactive push attempt, recorded with each -// thinking pass (design/12 §4). +// PushResult 是一次主动推送尝试的结果,随每轮思考 +// 一起记录(design/12 §4)。 type PushResult struct { - // Pushed reports whether the message was delivered. + // Pushed 报告消息是否已投递。 Pushed bool - // Route is the delivery route taken. + // Route 是实际采用的路由。 Route Route - // Bypassed reports whether this push was event-driven and skipped the PushGate - // (a real event, "该发就发"). A heartbeat push is gate-governed and never - // bypassed. + // Bypassed 报告本次推送是否为事件驱动、跳过了 PushGate + // (真正的事件,"该发就发")。心跳推送受门控管辖,绝不 + // 被绕过。 Bypassed bool - // Detail is a short human-readable note (e.g. client count, decision reason). + // Detail 是简短可读的备注(如客户端数量、决策理由)。 Detail string - // Err is the underlying error text when delivery failed, or that of the - // provider that decided not to push. + // Err 是投递失败时的底层错误文本,或决定不推送的 + // 提供者的错误文本。 Err string } -// String returns a concise summary for logs. +// String 返回用于日志的简洁摘要。 func (p PushResult) String() string { if p.Err != "" { return fmt.Sprintf("pushed=%v route=%s bypass=%v detail=%q err=%q", p.Pushed, p.Route, p.Bypassed, p.Detail, p.Err) @@ -69,110 +69,110 @@ func (p PushResult) String() string { return fmt.Sprintf("pushed=%v route=%s bypass=%v detail=%q", p.Pushed, p.Route, p.Bypassed, p.Detail) } -// ActiveClient delivers a proactive message to every currently active web/native -// client and reports how many received it. It is the preferred push target. The -// gateway WebSocket hub implements this in production; tests inject a mock. +// ActiveClient 把主动消息投递给所有当前活跃的 Web/原生 +// 客户端,并报告有多少个收到。它是首选的推送目标。 +// 生产中由 gateway 的 WebSocket hub 实现;测试注入 mock。 type ActiveClient interface { - // SendProactive pushes msg to each active client and returns the number of - // clients that accepted it. + // SendProactive 把 msg 推给每个活跃客户端,并返回 + // 接受它的客户端数量。 SendProactive(ctx context.Context, msg adapter.UnifiedMessage) (int, error) } -// IMAdapter is the IM bridge fallback: it pushes a proactive message over an IM -// channel. Every adapter.PlatformAdapter satisfies this subset, so the service -// depends on the narrow interface rather than the concrete adapter type. +// IMAdapter 是 IM 桥降级:它把主动消息经 IM +// 渠道推送。每个 adapter.PlatformAdapter 都满足这个子集,因此本服务 +// 依赖这个窄接口而非具体适配器类型。 type IMAdapter interface { - // SendProactive pushes msg to the owner's IM channel. + // SendProactive 把 msg 推给主人的 IM 渠道。 SendProactive(ctx context.Context, msg adapter.UnifiedMessage) error } -// PushGate decides whether a proactive push may proceed right now, applying the -// rate limits of design/11 §6 plus a per-flavor limit and a post-push cooldown. It -// is a seam over channel/session's ProactivePolicy/RateLimiter so the service can -// be tested with a mock. +// PushGate 判断一次主动推送此刻能否进行,套用 +// design/11 §6 的限频规则,外加按风格限制与推送后冷却。它 +// 是对 channel/session 的 ProactivePolicy/RateLimiter 的接缝,因此本服务 +// 可以用 mock 测试。 type PushGate interface { - // Allow reports whether a proactive push of the given kind may be sent now. + // Allow 报告给定风格的主动推送此刻能否发送。 Allow(ctx context.Context, kind PushKind) bool - // RecordPush notes a successful delivery so the cooldown/per-kind windows - // start from a real successful push, not a merely-attempted one. + // RecordPush 记下一次成功投递,使冷却/按风格窗口 + // 从一次真正的成功推送起算,而不是仅仅尝试过。 RecordPush(kind PushKind) } -// PushRequest carries one proactive push attempt: the outbound message plus the -// channel target the autonomous conclusion asked for (design/20 §2 P2). -// ChannelHint is the lowercased [渠道:...] token: ""/auto pick the default (the -// owner mandate: ONE channel, chosen by her / her most recent platform / the -// whitelist default); "web" targets the active client; "im" targets the IM bridge; -// a concrete platform id (kook/telegram/weixin-ilink/...) targets the IM bridge -// with the message tagged for that platform. +// PushRequest 携带一次主动推送尝试:出站消息加上 +// 自主结论所要求的目标渠道(design/20 §2 P2)。 +// ChannelHint 是已转小写的 [渠道:...] 词:""/auto 选默认( +// 主人要求:只发一个渠道,由她 / 她最近的平台 / +// 白名单默认决定);"web" 指向活跃客户端;"im" 指向 IM 桥; +// 具体平台 id(kook/telegram/weixin-ilink/...)指向 IM 桥 +// 并把消息标记为该平台。 type PushRequest struct { - // Message is the normalized outbound proactive message. + // Message 是归一化后的出站主动消息。 Message adapter.UnifiedMessage - // ChannelHint is the requested target channel, "" when she did not choose one. + // ChannelHint 是请求的目标渠道,她未选择时为 ""。 ChannelHint string - // TriggerKind records what woke this pass. An event-driven push bypasses the - // PushGate (owner mandate: "该发就发"); a heartbeat stays gate-governed. + // TriggerKind 记录唤醒本轮的原因。事件驱动的推送绕过 + // PushGate(主人要求:"该发就发");心跳保持受门控管辖。 TriggerKind TriggerKind } -// Pusher delivers a proactive message to ONE target channel. It is bounded by the -// rate gate and honors the channel hint the autonomous conclusion selected: "web" -// goes to the active client, everything else goes over the IM bridge a single time, -// where the bridge picks the one channel (her choice → her most recent platform → -// whitelist default, WeChat included). +// Pusher 把主动消息投递到一个目标渠道。它受 +// 频率门约束,并尊重自主结论所选的渠道提示:"web" +// 去活跃客户端,其余都经 IM 桥走一次, +// 由桥挑出那一个渠道(她的选择 → 她最近的平台 → +// 白名单默认,微信含在内)。 type Pusher interface { - // Push delivers req to a single target channel, per the channel hint. It never - // blocks on a slow target. + // Push 按渠道提示把 req 投递到单个目标渠道。它绝不 + // 阻塞在慢目标上。 Push(ctx context.Context, req PushRequest) PushResult } -// pusher is the concrete Pusher: it consults the gate, then routes the message to -// exactly one target — the active client for a "web" hint, otherwise the IM bridge. -// After the local delivery it mirrors the message to the optional push outlet -// (self-hosted service → Getui fallback) without blocking that delivery. +// pusher 是具体的 Pusher:它先咨询频率门,再把消息路由到 +// 恰好一个目标——"web" 提示走活跃客户端,否则走 IM 桥。 +// 本地投递之后,它把消息镜像到可选的推送出口 +// (自托管服务 → 个推降级),且不阻塞该投递。 type pusher struct { active ActiveClient im IMAdapter gate PushGate exporter PushExporter log *slog.Logger - // exportDone, when non-nil, receives one value after each asynchronous export - // completes. It exists so tests can deterministically observe the hook; the - // send is non-blocking, so a full channel never stalls the exporter. + // exportDone 非 nil 时,会在每次异步导出完成后收到一个值。 + // 它存在是为了让测试能确定性地观测该钩子;发送是非阻塞的, + // 所以通道满也不会卡住导出器。 exportDone chan struct{} } -// newPusher builds a Pusher from the optional active-client sink, IM sink and -// rate gate. Any of them may be nil; a nil target simply makes that route -// unavailable. The push outlet is attached separately with withExporter. +// newPusher 由可选的活跃客户端槽、IM 槽与 +// 频率门构建 Pusher。它们都可以为 nil;目标为 nil 只是让那条路由 +// 不可用。推送出口用 withExporter 单独挂上。 func newPusher(active ActiveClient, im IMAdapter, gate PushGate, log *slog.Logger) *pusher { return &pusher{active: active, im: im, gate: gate, log: log} } -// withExporter attaches the push outlet hook and returns the pusher for fluent -// use. A nil exporter leaves the hook disabled. +// withExporter 挂上推送出口钩子并返回 pusher 以便链式 +// 调用。exporter 为 nil 则钩子保持关闭。 func (p *pusher) withExporter(exporter PushExporter) *pusher { p.exporter = exporter return p } -// Push implements Pusher: it applies the rate gate, then routes the message to a -// single target. A "web" hint reaches the active web/native client; every other -// hint ("im", "auto", or a concrete platform) goes over the IM bridge once, where -// the bridge selects one owner channel (her [渠道:...] choice, then the platform she -// used most recently, then the whitelist default). The IM bridge is primary, so a -// push never fans out to several channels. When no IM sink is wired the active -// client is the honest fallback, so the owner is still reached rather than the -// message being dropped. +// Push 实现 Pusher:它套用频率门,然后把消息路由到 +// 单个目标。"web" 提示到达活跃 Web/原生客户端;其它任何 +// 提示("im"、"auto" 或具体平台)经 IM 桥走一次,由 +// 桥挑出一个主人渠道(她的 [渠道:...] 选择,其次是她 +// 最近用过的平台,再次是白名单默认)。IM 桥是主路径,所以一次 +// 推送绝不扇出到多个渠道。未接线 IM 槽时,活跃 +// 客户端就是诚实的降级,因此主人仍能被触达,而不是 +// 消息被丢弃。 func (p *pusher) Push(ctx context.Context, req PushRequest) PushResult { if err := ctx.Err(); err != nil { return PushResult{Pushed: false, Route: RouteNone, Err: err.Error()} } kind := classifyPush(req.Message.Content) - // A real event-driven push bypasses the PushGate: it should go out as-is ("该发就发"), - // independent of the heartbeat throttle (60min≤1 / cooldown / per-flavor window). - // Only the periodic heartbeat stays gate-governed, so the owner is never spammed - // by an empty reflection. Same-event dedup is applied upstream in think(). + // 真正事件驱动的推送绕过 PushGate:它应原样发出("该发就发"), + // 不受心跳限流约束(60min≤1 / 冷却 / 按风格窗口)。 + // 只有周期性心跳保持受门控管辖,主人因此绝不会被 + // 空洞的反思刷屏。同一事件去重在 think() 上游套用。 eventDriven := req.TriggerKind == TriggerEvent if p.gate != nil && !eventDriven && !p.gate.Allow(ctx, kind) { p.log.Warn("backthink push rate limited", "kind", kind.String()) @@ -186,10 +186,10 @@ func (p *pusher) Push(ctx context.Context, req PushRequest) PushResult { if hint == channelWeb { res = p.pushActive(ctx, msg) } else { - // "im"/"auto"/a concrete platform all travel over the IM bridge once. A - // generic hint hands the sentinel platform so the bridge falls through to its - // recent-active / default selection; a concrete hint is honored when the - // channel is whitelisted and connected. + // "im"/"auto"/具体平台都经 IM 桥走一次。 + // 泛化提示交出哨兵平台,让桥落到它的 + // 最近活跃 / 默认选择上;具体提示在渠道 + // 已加入白名单且已连接时被尊重。 if hint == channelIM || hint == channelAuto { msg.Platform = defaultProactivePlatform } else { @@ -200,16 +200,16 @@ func (p *pusher) Push(ctx context.Context, req PushRequest) PushResult { res = p.pushActive(ctx, msg) } } - // Only a real delivery advances the cooldown/per-kind windows (and only for a - // gate-governed push), so an attempted-but-failed push does not suppress a later - // genuine one, and an event push never contributes to the heartbeat throttle. + // 只有真正的投递才推进冷却/按风格窗口(且只对 + // 受门控的推送),因此尝试过但失败的推送不会抑制之后的 + // 真实推送,而事件推送也绝不计入心跳限流。 if res.Pushed && p.gate != nil && !eventDriven { p.gate.RecordPush(kind) } - // D1: mirror the proactive message to the external push outlet (self-hosted - // unified push service → Getui fallback). Fire-and-forget: a slow or failing - // outlet never delays or breaks the local delivery above, and a rate-limited - // push returned before reaching here. + // D1:把主动消息镜像到外部推送出口(自托管 + // 统一推送服务 → 个推降级)。发后不管:慢或失败的 + // 出口绝不延迟或破坏上面的本地投递,而且限流被拒的 + // 推送在此之前就已返回。 p.exportAsync(msg) res = annotateHint(res, req.ChannelHint) if eventDriven { @@ -219,9 +219,9 @@ func (p *pusher) Push(ctx context.Context, req PushRequest) PushResult { return res } -// exportAsync reports msg to the push outlet on a detached, bounded goroutine so -// the proactive path never blocks on the outlet. A nil exporter (hook disabled) -// or a nil outlet result is a no-op; a delivery miss or error is only logged. +// exportAsync 在脱离的、有界的 goroutine 上把 msg 上报给推送出口, +// 因此主动路径绝不在出口上阻塞。exporter 为 nil(钩子关闭) +// 或出口结果为 nil 都是空操作;投递未命中或出错只记日志。 func (p *pusher) exportAsync(msg adapter.UnifiedMessage) { if p.exporter == nil { return @@ -246,7 +246,7 @@ func (p *pusher) exportAsync(msg adapter.UnifiedMessage) { }() } -// pushActive delivers only to the active web/native client. +// pushActive 只投递给活跃 Web/原生客户端。 func (p *pusher) pushActive(ctx context.Context, msg adapter.UnifiedMessage) PushResult { if p.active != nil { n, err := p.active.SendProactive(ctx, msg) @@ -259,7 +259,7 @@ func (p *pusher) pushActive(ctx context.Context, msg adapter.UnifiedMessage) Pus return PushResult{Pushed: false, Route: RouteNone, Detail: "no active client"} } -// pushIM delivers only to the IM bridge. +// pushIM 只投递给 IM 桥。 func (p *pusher) pushIM(ctx context.Context, msg adapter.UnifiedMessage) PushResult { if p.im == nil { return PushResult{Pushed: false, Route: RouteNone, Detail: "no IM target"} @@ -271,9 +271,9 @@ func (p *pusher) pushIM(ctx context.Context, msg adapter.UnifiedMessage) PushRes return PushResult{Pushed: true, Route: RouteIMBridge, Detail: "delivered via IM bridge"} } -// normalizeChannelHint lowercases and trims a channel hint, mapping synonyms to -// the canonical route tokens (auto/im/web); anything else is a concrete platform -// id (kook/telegram/...). Unknown/blank maps to auto. +// normalizeChannelHint 把渠道提示转小写并去空白,把同义词映射到 +// 规范路由词(auto/im/web);其它一切视为具体平台 +// id(kook/telegram/...)。未知/空映射为 auto。 func normalizeChannelHint(hint string) string { switch strings.ToLower(strings.TrimSpace(hint)) { case "", "auto": @@ -287,15 +287,15 @@ func normalizeChannelHint(hint string) string { } } -// isSpecificChannel reports whether hint names a concrete platform rather than a -// routing keyword (auto/im/web). +// isSpecificChannel 报告 hint 指的是具体平台,而不是 +// 路由关键词(auto/im/web)。 func isSpecificChannel(hint string) bool { return hint != channelAuto && hint != channelIM && hint != channelWeb } -// annotateHint appends the requested channel hint to the push result detail so -// the record/panel surfaces which channel she chose, even when the route fell -// back. Blank/auto hints add nothing. +// annotateHint 把请求的渠道提示附加到推送结果 detail 上, +// 好让记录/面板显示她选了哪个渠道,即便路由发生 +// 了降级。空白/auto 提示不加任何内容。 func annotateHint(res PushResult, hint string) PushResult { h := strings.ToLower(strings.TrimSpace(hint)) if h == "" || h == channelAuto { @@ -310,8 +310,8 @@ func annotateHint(res PushResult, hint string) PushResult { return res } -// annotateBypass tags an event-driven push result so the record/panel shows it -// skipped the PushGate, distinct from a heartbeat push that was rate-limited. +// annotateBypass 给事件驱动的推送结果打标记,使记录/面板显示它 +// 跳过了 PushGate,与因限流被拒的心跳推送区分开。 func annotateBypass(res PushResult) PushResult { if res.Detail == "" { res.Detail = "event_bypass" @@ -321,10 +321,10 @@ func annotateBypass(res PushResult) PushResult { return res } -// rateGate is the production PushGate: a sliding-window counter over -// session.RateLimiter (design/11 §6 anti-spam rule) combined with a post-push -// cooldown and a per-flavor (问候/提醒/分享/关心) window so the owner is never -// spammed with the same style (design/12 §6 策略二). +// rateGate 是生产用的 PushGate:一个基于 +// session.RateLimiter 的滑动窗口计数器(design/11 §6 反骚扰规则), +// 结合推送后冷却与按风格(问候/提醒/分享/关心)窗口, +// 使主人绝不会被同一风格刷屏(design/12 §6 策略二)。 type rateGate struct { log *slog.Logger limiter *session.RateLimiter @@ -337,10 +337,10 @@ type rateGate struct { lastKind map[PushKind]time.Time } -// newRateGate builds a gate that allows at most limit pushes within window, keeps -// pushes cooldown apart after a success, and limits each PushKind to one push per -// kindWindow. A non-positive limit or window yields an always-allow limiter (no -// rate rule); a non-positive cooldown or kindWindow disables that specific guard. +// newRateGate 构建一个门:在 window 内最多放行 limit 次推送, +// 成功之后推送之间至少间隔 cooldown,且每种 PushKind 在 +// kindWindow 内只允许一次。非正的 limit 或 window 得到始终放行的 +// 限流器(无限频规则);非正的 cooldown 或 kindWindow 会禁用对应的那道守护。 func newRateGate(limit int, window, cooldown, kindWindow time.Duration, log *slog.Logger) *rateGate { return &rateGate{ log: log, @@ -352,9 +352,9 @@ func newRateGate(limit int, window, cooldown, kindWindow time.Duration, log *slo } } -// Allow implements PushGate. A canceled context denies (fail closed). It refuses -// when the last successful push is inside the cooldown, or when the same flavor -// was delivered within kindWindow, before consulting the sliding-window limiter. +// Allow 实现 PushGate。已取消的 context 一律拒绝(fail closed)。 +// 当最近一次成功推送仍在冷却期内,或同一风格 +// 在 kindWindow 内已投递过,它就在咨询滑动窗口限流器之前拒绝。 func (g *rateGate) Allow(ctx context.Context, kind PushKind) bool { if err := ctx.Err(); err != nil { return false @@ -371,8 +371,8 @@ func (g *rateGate) Allow(ctx context.Context, kind PushKind) bool { return g.limiter.Allow() } -// RecordPush implements PushGate: it stamps the last successful push time and the -// per-flavor last time so the cooldown and kind window start from a real delivery. +// RecordPush 实现 PushGate:它记下最近成功推送时间与 +// 按风格的最近时间,使冷却与风格窗口从真实投递起算。 func (g *rateGate) RecordPush(kind PushKind) { now := g.now() g.mu.Lock() @@ -381,15 +381,15 @@ func (g *rateGate) RecordPush(kind PushKind) { g.lastKind[kind] = now } -// buildProactiveMessage assembles the normalized outbound message carrying a -// think conclusion's clean body. It tags the platform with the requested channel -// when the conclusion names a concrete platform, otherwise the default backthink -// platform. The concrete active-client and IM adapters apply their own -// routing/targeting to the owner (red line: owner only). +// buildProactiveMessage 拼装承载思考结论文本的、 +// 归一化的出站消息。当结论指明具体平台时,它用请求的渠道 +// 给平台打标签,否则用 backthink 的默认 +// 平台。具体的活跃客户端与 IM 适配器各自对主人施加 +// 自己的路由/寻址(红线:只针对主人)。 // -// The body is run through stripInternalMarkers as a final guard so a structured -// decision/channel marker that survived parsing can never reach the user-facing -// message (only our logs/records may carry it). +// 正文会再过一遍 stripInternalMarkers 作为最后一道守护,因此 +// 侥幸存活过解析的结构化决策/渠道标记绝不 +// 能到达面向用户的消息(只有我们的日志/记录可以携带它)。 func (b *Backthink) buildProactiveMessage(message, channelHint string) adapter.UnifiedMessage { platform := defaultProactivePlatform if h := normalizeChannelHint(channelHint); isSpecificChannel(h) { diff --git a/internal/services/backthink/push_outlet.go b/internal/services/backthink/push_outlet.go index 20d6f94..65ede78 100644 --- a/internal/services/backthink/push_outlet.go +++ b/internal/services/backthink/push_outlet.go @@ -15,58 +15,58 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" ) -// The push outlet (D1, design/12 §4/§6): proactive messages are mirrored to an -// external push service so the owner's mobile client is reached even when no -// WebSocket client or IM channel is live. Delivery is two-tier by design: +// 推送出口(D1,design/12 §4/§6):主动消息被镜像到 +// 外部推送服务,使主人的移动端即便在没有 +// WebSocket 客户端或 IM 渠道在线时也能被触达。按设计投递分两级: // -// 1. the owner's self-hosted unified push service (primary; PUSH_EXPORTER_URL), -// 2. Getui (个推) as the fallback for when the self-hosted service is down. +// 1. 主人的自托管统一推送服务(主级;PUSH_EXPORTER_URL), +// 2. 个推(Getui)作为自托管服务不可用时的降级。 // -// Tier 2 has no SDK integration yet, so an unavailable primary logs an explicit -// "Getui fallback not integrated" note and reports not-delivered instead of -// pretending the message went out. The hook is fire-and-forget: it never delays -// or breaks the existing active-client / IM delivery. +// 第 2 级尚未接入 SDK,因此主级不可用时只记一条明确的 +// "Getui fallback not integrated" 备注,并上报未投递,而不 +// 假装消息已发出。该钩子是发后不管:它绝不延迟 +// 或破坏既有的活跃客户端 / IM 投递。 -// defaultExportTimeout bounds one export attempt so a hung endpoint cannot pin a -// goroutine forever. +// defaultExportTimeout 约束单次导出尝试,因此挂死的端点无法永久 +// 占住一个 goroutine。 const defaultExportTimeout = 5 * time.Second -// maxExportResponseBytes caps how much of the outlet's response body is drained -// before the connection is reused, so a hostile/buggy endpoint cannot stream an -// unbounded body into memory. +// maxExportResponseBytes 限制连接复用前从出口响应体里 +// 抽掉多少数据,因此恶意或有缺陷的端点无法把一个 +// 无界的响应体流进内存。 const maxExportResponseBytes = 4 << 10 -// PushExporter reports one proactive message to an external push outlet. Export -// must be safe for concurrent use and must honor ctx. A nil error with -// delivered=false means the outlet could not deliver (for example the fallback -// tier is not wired); the caller treats that as a logged miss, never a failure -// of the local push path. +// PushExporter 把一条主动消息上报给外部推送出口。Export +// 必须并发安全且必须尊重 ctx。nil 错误配上 +// delivered=false 表示出口无法投递(例如降级 +// 级尚未接线);调用方把它当作一次记账的未命中,绝不当作本地 +// 推送路径的失败。 type PushExporter interface { Export(ctx context.Context, msg adapter.UnifiedMessage) (delivered bool, err error) } -// PushOutletConfig carries the push-outlet wiring. Enabled=false disables the -// hook entirely (NewPushOutlet returns nil). URL/Token/Timeout describe the -// self-hosted primary service; the Getui* fields are reserved for the deferred -// fallback and are not consumed yet. +// PushOutletConfig 携带推送出口的接线。Enabled=false 完全关闭 +// 该钩子(NewPushOutlet 返回 nil)。URL/Token/Timeout 描述 +// 自托管主服务;Getui* 字段保留给延后的 +// 降级级,目前尚未消费。 type PushOutletConfig struct { - // Enabled turns the outlet hook on. + // Enabled 打开出口钩子。 Enabled bool - // URL is the self-hosted unified push service endpoint (HTTP POST JSON). - // Empty leaves the primary tier unconfigured. + // URL 是自托管统一推送服务端点(HTTP POST JSON)。 + // 为空则主级保持未配置。 URL string - // Token is sent as "Authorization: Bearer ". It is never logged. + // Token 以 "Authorization: Bearer " 发送。它绝不写日志。 Token string - // Timeout bounds one export attempt. Zero uses defaultExportTimeout. + // Timeout 约束单次导出尝试。为零用 defaultExportTimeout。 Timeout time.Duration - // Log is the base logger. Nil uses a discard logger. + // Log 是基础日志器。nil 则用丢弃式日志器。 Log *slog.Logger } -// NewPushOutlet builds the two-tier push outlet from cfg. It returns nil when -// the hook is disabled, so the pusher simply skips the export step. When it is -// enabled the primary tier is the self-hosted HTTP endpoint (present only when -// URL is set) and the fallback tier is Getui, which is not integrated yet. +// NewPushOutlet 由 cfg 构建两级推送出口。钩子关闭时它返回 nil, +// 因此推送器直接跳过导出步骤。启用时, +// 主级是自托管 HTTP 端点(仅当 +// URL 已设置时存在),降级级是个推,尚未接入。 func NewPushOutlet(cfg PushOutletConfig) PushExporter { if !cfg.Enabled { return nil @@ -90,19 +90,19 @@ func NewPushOutlet(cfg PushOutletConfig) PushExporter { return &pushOutlet{primary: primary, log: log} } -// pushOutlet is the two-tier exporter: self-hosted service first, Getui second. -// The fallback is nil until the Getui SDK is integrated, so a primary miss is -// logged and reported as not delivered rather than silently dropped. +// pushOutlet 是两级导出器:先自托管服务,再个推。 +// 在个推 SDK 接入前降级级为 nil,因此主级未命中时 +// 只记日志并上报未投递,而不是静默丢弃。 type pushOutlet struct { primary PushExporter fallback PushExporter log *slog.Logger } -// Export implements PushExporter. It tries the primary tier and, when that does -// not deliver, the fallback tier. With no fallback wired it logs the pending -// integration and returns (false, nil): the caller treats that as a miss, never -// as a reason to fail the proactive push. +// Export 实现 PushExporter。它先试主级,主级 +// 未投递时再试降级级。未接降级级时它记录待 +// 接入状态并返回 (false, nil):调用方把这当作未命中, +// 绝不当作让主动推送失败的理由。 func (o *pushOutlet) Export(ctx context.Context, msg adapter.UnifiedMessage) (bool, error) { if o.primary != nil { delivered, err := o.primary.Export(ctx, msg) @@ -123,34 +123,34 @@ func (o *pushOutlet) Export(ctx context.Context, msg adapter.UnifiedMessage) (bo return false, nil } -// pushExportPayload is the JSON body POSTed to the self-hosted push service. It -// is deliberately small and stable: the service owns device targeting, the -// message carries only what is needed to render and de-duplicate it. +// pushExportPayload 是 POST 到自托管推送服务的 JSON 体。它 +// 刻意小而稳定:设备寻址归该服务,消息 +// 只携带渲染与去重所需的内容。 type pushExportPayload struct { - // Source identifies the producer so the service can route/filter. + // Source 标识生产者,好让服务能路由/过滤。 Source string `json:"source"` - // Title is a short display title (the persona name). + // Title 是简短显示标题(角色名)。 Title string `json:"title"` - // Content is the proactive message body (already stripped of internal - // decision markers by buildProactiveMessage). + // Content 是主动消息正文(内部决策标记 + // 已由 buildProactiveMessage 剥掉)。 Content string `json:"content"` - // Platform is the outbound platform tag (cyrene-backthink or a concrete IM - // platform). + // Platform 是出站平台标签(cyrene-backthink 或具体的 IM + // 平台)。 Platform string `json:"platform"` - // Timestamp is the message time in Unix milliseconds. + // Timestamp 是消息时间,Unix 毫秒。 Timestamp int64 `json:"timestamp"` } -// httpPushExporter posts the message to the self-hosted push service. A 2xx -// response means delivered; any other status or transport error is an error. -// The token travels only in the Authorization header and never reaches a log. +// httpPushExporter 把消息 POST 到自托管推送服务。2xx +// 响应表示已投递;其它任何状态或传输错误都算错误。 +// token 只出现在 Authorization 头里,绝不进入日志。 type httpPushExporter struct { url string token string client *http.Client } -// Export implements PushExporter over HTTP POST JSON. +// Export 实现 PushExporter,走 HTTP POST JSON。 func (e *httpPushExporter) Export(ctx context.Context, msg adapter.UnifiedMessage) (bool, error) { body, err := json.Marshal(pushExportPayload{ Source: "cyrene/backthink", @@ -182,8 +182,8 @@ func (e *httpPushExporter) Export(ctx context.Context, msg adapter.UnifiedMessag return true, nil } -// redact removes the endpoint URL from a transport error so a secret embedded in -// the URL (for example a query token) never reaches a log line. +// redact 从传输错误里移除端点 URL,因此 URL 中内嵌的 +// 秘密(例如查询参数里的 token)绝不进入日志行。 func (e *httpPushExporter) redact(err error) error { if err == nil || e.url == "" { return err diff --git a/internal/services/backthink/push_outlet_test.go b/internal/services/backthink/push_outlet_test.go index f6093ad..991b3a3 100644 --- a/internal/services/backthink/push_outlet_test.go +++ b/internal/services/backthink/push_outlet_test.go @@ -16,8 +16,8 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" ) -// mockExporter records exported messages and can block or fail on demand so the -// pusher hook's non-blocking / failure-tolerant behaviour is testable. +// mockExporter 记录已导出的消息,并可按需阻塞或失败, +// 使推送器钩子的非阻塞 / 容错行为可测。 type mockExporter struct { mu sync.Mutex msgs []adapter.UnifiedMessage @@ -49,9 +49,9 @@ func (m *mockExporter) messages() []adapter.UnifiedMessage { return append([]adapter.UnifiedMessage(nil), m.msgs...) } -// TestPushOutletHTTPPrimaryDelivers proves the self-hosted service is the -// primary tier: the payload is POSTed with the bearer token and a 2xx response -// is reported as delivered. +// TestPushOutletHTTPPrimaryDelivers 证明自托管服务是 +// 主级:负载带着 bearer token 被 POST,2xx 响应 +// 被上报为已投递。 func TestPushOutletHTTPPrimaryDelivers(t *testing.T) { t.Parallel() type payload struct { @@ -109,14 +109,14 @@ func TestPushOutletHTTPPrimaryDelivers(t *testing.T) { } } -// TestPushOutletUnreachableLogsGetuiFallback asserts an unreachable primary is a -// logged miss (not an error), names the pending Getui fallback, and never logs -// the token. +// TestPushOutletUnreachableLogsGetuiFallback 断言主级不可达是一次 +// 记账的未命中(不是错误),会点出待接入的个推降级,且绝不记录 +// token。 func TestPushOutletUnreachableLogsGetuiFallback(t *testing.T) { t.Parallel() srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) url := srv.URL - srv.Close() // now unreachable + srv.Close() // 此时已不可达 var buf bytes.Buffer log := slog.New(slog.NewTextHandler(&buf, nil)) @@ -137,7 +137,7 @@ func TestPushOutletUnreachableLogsGetuiFallback(t *testing.T) { } } -// TestPushOutletHTTPErrorReportsMiss asserts a non-2xx primary response is a miss. +// TestPushOutletHTTPErrorReportsMiss 断言主级非 2xx 响应算未命中。 func TestPushOutletHTTPErrorReportsMiss(t *testing.T) { t.Parallel() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { @@ -155,8 +155,8 @@ func TestPushOutletHTTPErrorReportsMiss(t *testing.T) { } } -// TestNewPushOutletDisabledOrUnconfigured covers the two degraded shapes: the -// hook off (nil) and enabled without a primary URL (miss + Getui note). +// TestNewPushOutletDisabledOrUnconfigured 覆盖两种降级形态:钩子 +// 关闭(nil)与启用但无主级 URL(未命中 + 个推备注)。 func TestNewPushOutletDisabledOrUnconfigured(t *testing.T) { t.Parallel() if exp := NewPushOutlet(PushOutletConfig{Enabled: false, URL: "http://example.invalid"}); exp != nil { @@ -178,8 +178,8 @@ func TestNewPushOutletDisabledOrUnconfigured(t *testing.T) { } } -// TestPusherExportsProactiveToOutlet asserts the hook mirrors the delivered -// proactive message to the outlet after the IM delivery. +// TestPusherExportsProactiveToOutlet 断言钩子在 IM 投递之后 +// 把已投递的主动消息镜像到出口。 func TestPusherExportsProactiveToOutlet(t *testing.T) { t.Parallel() exp := &mockExporter{delivered: true} @@ -205,9 +205,9 @@ func TestPusherExportsProactiveToOutlet(t *testing.T) { } } -// TestPusherExportHookNonBlockingAndFailureTolerant asserts a hung/failing outlet -// never delays the push and never turns a delivered proactive message into a -// failure. +// TestPusherExportHookNonBlockingAndFailureTolerant 断言挂死/失败的出口 +// 绝不延迟推送,也绝不把已投递的主动消息变成 +// 失败。 func TestPusherExportHookNonBlockingAndFailureTolerant(t *testing.T) { t.Parallel() started := make(chan struct{}) @@ -238,8 +238,8 @@ func TestPusherExportHookNonBlockingAndFailureTolerant(t *testing.T) { } } -// TestPusherRateLimitedPushIsNotExported asserts a rate-limited push never -// reaches the outlet: the hook mirrors real proactive sends only. +// TestPusherRateLimitedPushIsNotExported 断言被限流的推送绝不 +// 到达出口:该钩子只镜像真实的主动发送。 func TestPusherRateLimitedPushIsNotExported(t *testing.T) { t.Parallel() exp := &mockExporter{delivered: true} diff --git a/internal/services/backthink/push_test.go b/internal/services/backthink/push_test.go index 7f46605..613b26d 100644 --- a/internal/services/backthink/push_test.go +++ b/internal/services/backthink/push_test.go @@ -11,10 +11,10 @@ import ( func TestPusher_AutoRoutesIM(t *testing.T) { t.Parallel() - // The owner mandate: a default (auto) push reaches ONE channel over the IM - // bridge (the bridge picks her [渠道:...] choice, then her most recent platform, - // then the whitelist default) — not the active web client by default. - active := &mockActive{n: 2} // a web client is online but is NOT the default target + // 主人要求:默认(auto)推送经 IM 桥到达 ONE 个渠道 + //(桥依次挑她的 [渠道:...] 选择、她最近的平台、 + // 白名单默认)——默认不是活跃 Web 客户端。 + active := &mockActive{n: 2} // 有 Web 客户端在线,但它不是默认目标 im := &mockIMAdapter{} p := newPusher(active, im, &mockGate{allow: true}, discardLog()) @@ -58,8 +58,8 @@ func TestPusher_WebHintRoutesActive(t *testing.T) { func TestPusher_ConcreteHintRoutesIM_SingleTarget(t *testing.T) { t.Parallel() - // A concrete decision channel is honored over the IM bridge, and exactly ONE - // target is used (the IM bridge carries it; the active client is not touched). + // 具体的决策渠道经 IM 桥被尊重,且只用 ONE 个 + // 目标(由 IM 桥承载;活跃客户端不被触碰)。 active := &mockActive{n: 2} im := &mockIMAdapter{} p := newPusher(active, im, &mockGate{allow: true}, discardLog()) @@ -85,8 +85,8 @@ func TestPusher_ConcreteHintRoutesIM_SingleTarget(t *testing.T) { func TestPusher_NoIMSink_DegradesToActive(t *testing.T) { t.Parallel() - // With no IM sink wired (backthink IM seam absent) the push degrades to the - // active web client so the owner is still reached rather than dropped. + // 未接线 IM 槽(backthink 的 IM 接缝缺失)时推送降级到 + // 活跃 Web 客户端,好让主人仍被触达而不是被丢弃。 active := &mockActive{n: 1} p := newPusher(active, nil, &mockGate{allow: true}, discardLog()) @@ -156,8 +156,8 @@ func TestPusher_CancelledContext_Denied(t *testing.T) { } } -// TestPusher_RateLimit exercises the production rateGate over session.RateLimiter: -// with a limit of 1 push per window, only the first of three attempts passes. +// TestPusher_RateLimit 在 session.RateLimiter 上验证生产 rateGate: +// 窗口内 limit 为 1 次推送时,三次尝试中只有第一次通过。 func TestPusher_RateLimit(t *testing.T) { t.Parallel() active := &mockActive{n: 1} @@ -181,9 +181,9 @@ func TestPusher_RateLimit(t *testing.T) { } } -// TestRateGate_CooldownAfterSuccess verifies the post-push cooldown: after a -// successful delivery a further push is refused until the cooldown elapses, -// regardless of the configured window counter. +// TestRateGate_CooldownAfterSuccess 验证推送后冷却:一次成功 +// 投递之后,在冷却期结束前再推送会被拒绝, +// 与已配置的窗口计数无关。 func TestRateGate_CooldownAfterSuccess(t *testing.T) { t.Parallel() t0 := time.Now() @@ -191,7 +191,7 @@ func TestRateGate_CooldownAfterSuccess(t *testing.T) { log: discardLog(), limiter: session.NewRateLimiter(10, time.Hour), cooldown: 30 * time.Minute, - kindWindow: 0, // disabled to isolate the cooldown + kindWindow: 0, // 置零以单独验证冷却 now: func() time.Time { return t0 }, lastKind: make(map[PushKind]time.Time), } @@ -208,16 +208,16 @@ func TestRateGate_CooldownAfterSuccess(t *testing.T) { } } -// TestRateGate_KindWindowLimitsSameFlavor verifies the per-flavor (问候/提醒/分享/ -// 关心) limit: the same flavor cannot repeat within the kind window, while a -// different flavor and a later same-flavor push (after the window) still pass. +// TestRateGate_KindWindowLimitsSameFlavor 验证按风格(问候/提醒/分享/ +// 关心)限制:同一风格不能在风格窗口内重复,而 +// 不同风格与更晚的同一风格推送(窗口之后)仍可通过。 func TestRateGate_KindWindowLimitsSameFlavor(t *testing.T) { t.Parallel() t0 := time.Now() g := &rateGate{ log: discardLog(), limiter: session.NewRateLimiter(10, time.Hour), - cooldown: 0, // disabled to isolate the kind window + cooldown: 0, // 置零以单独验证风格窗口 kindWindow: 2 * time.Hour, now: func() time.Time { return t0 }, lastKind: make(map[PushKind]time.Time), @@ -238,10 +238,10 @@ func TestRateGate_KindWindowLimitsSameFlavor(t *testing.T) { } } -// TestPusher_EventBypassesGate verifies an event-driven push skips the PushGate: -// with a limit of 1 push per window, three consecutive event pushes are ALL -// delivered (owner mandate "该发就发"), each marked Bypassed and each advancing -// no cooldown/kind window. +// TestPusher_EventBypassesGate 验证事件驱动的推送跳过 PushGate: +// 窗口内 limit 为 1 次推送时,连续三次事件推送全部 +// 被投递(主人要求 "该发就发"),每次都标记 Bypassed,且都不推进 +// 冷却/风格窗口。 func TestPusher_EventBypassesGate(t *testing.T) { t.Parallel() active := &mockActive{n: 1} @@ -266,9 +266,9 @@ func TestPusher_EventBypassesGate(t *testing.T) { } } -// TestPusher_HeartbeatStillRateLimited verifies a heartbeat-driven push stays -// gate-governed: with a limit of 1 push per window, the first passes and the -// second is refused as rate_limited, never Bypassed. +// TestPusher_HeartbeatStillRateLimited 验证心跳驱动的推送仍 +// 受门控管辖:窗口内 limit 为 1 次推送时,第一次通过, +// 第二次作为 rate_limited 被拒,绝不是 Bypassed。 func TestPusher_HeartbeatStillRateLimited(t *testing.T) { t.Parallel() active := &mockActive{n: 1} @@ -289,9 +289,9 @@ func TestPusher_HeartbeatStillRateLimited(t *testing.T) { } } -// TestPusher_EventBypass_DoesNotAdvanceGate verifies an event-driven push does not -// consume the heartbeat limit: after an event push, a following heartbeat push -// still gets its own full allowance. +// TestPusher_EventBypass_DoesNotAdvanceGate 验证事件驱动的推送不会 +// 消耗心跳额度:一次事件推送之后,紧随的心跳推送 +// 仍拿到自己那份完整额度。 func TestPusher_EventBypass_DoesNotAdvanceGate(t *testing.T) { t.Parallel() active := &mockActive{n: 1} diff --git a/internal/services/backthink/sinks.go b/internal/services/backthink/sinks.go index e0ef9c3..a3e16fc 100644 --- a/internal/services/backthink/sinks.go +++ b/internal/services/backthink/sinks.go @@ -1,14 +1,14 @@ package backthink -// The production composition root builds the gateway hub and the channel bridge -// *after* backthink.New (the hub needs the loop, the bridge needs the loop and -// owner profile, both of which are assembled later), so the ActiveClient and -// IMAdapter sinks are unavailable at construction time. These setters inject them -// once the gateway / bridge exist and rebuild the pusher, keeping the resolved -// rate gate so a later push routes correctly. +// 生产组合根在 backthink.New *之后* 才构建 gateway hub 与 channel +// bridge(hub 需要 loop,bridge 需要 loop 与主人档案, +// 两者都在更晚时才组装),因此构造时拿不到 ActiveClient 与 +// IMAdapter 槽。这些设置器在 gateway / bridge 存在后注入它们, +// 并重建推送器,同时保留已解出的 +// 频率门,使之后的推送路由正确。 -// SetActiveSink wires the production active web/native client sink (the gateway -// hub) and rebuilds the pusher. Call it after the gateway exists, before Start. +// SetActiveSink 接入生产用的活跃 Web/原生客户端槽(gateway +// hub)并重建推送器。请在 gateway 存在之后、Start 之前调用。 func (b *Backthink) SetActiveSink(active ActiveClient) { b.mu.Lock() defer b.mu.Unlock() @@ -16,8 +16,8 @@ func (b *Backthink) SetActiveSink(active ActiveClient) { b.rebuildPusherLocked() } -// SetIMSink wires the production IM bridge sink (the channel bridge) and rebuilds -// the pusher. Call it after the channel bridge exists, before Start. +// SetIMSink 接入生产用的 IM 桥槽(channel bridge)并重建 +// 推送器。请在 channel bridge 存在之后、Start 之前调用。 func (b *Backthink) SetIMSink(im IMAdapter) { b.mu.Lock() defer b.mu.Unlock() @@ -25,14 +25,14 @@ func (b *Backthink) SetIMSink(im IMAdapter) { b.rebuildPusherLocked() } -// rebuildPusherLocked replaces the pusher from the current sinks, gate and push -// outlet. The caller must hold b.mu. +// rebuildPusherLocked 用当前槽位、频率门与推送出口替换 +// 推送器。调用方必须持有 b.mu。 func (b *Backthink) rebuildPusherLocked() { b.pusher = newPusher(b.deps.Active, b.deps.IM, b.gate, b.log).withExporter(b.deps.Exporter) } -// currentPusher returns the active pusher under the lifecycle lock, so a sink -// swap never races with a concurrent thinking pass that is about to push. +// currentPusher 在生命周期锁下返回当前推送器,因此一次槽位 +// 换入绝不会与即将推送的并发思考轮发生竞态。 func (b *Backthink) currentPusher() Pusher { b.mu.Lock() defer b.mu.Unlock() diff --git a/internal/services/backthink/sinks_test.go b/internal/services/backthink/sinks_test.go index 92a12f5..48eb473 100644 --- a/internal/services/backthink/sinks_test.go +++ b/internal/services/backthink/sinks_test.go @@ -7,9 +7,9 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" ) -// TestBackthink_SetActiveSink_RebuildsPusher verifies the sink setters rebuild the -// pusher so a later push routes to the injected active client instead of the -// default nil-sink RouteNone pusher built at New. +// TestBackthink_SetActiveSink_RebuildsPusher 验证槽位设置器会重建 +// 推送器,使之后的推送路由到注入的活跃客户端,而不是 +// New 时构建的默认 nil 槽 RouteNone 推送器。 func TestBackthink_SetActiveSink_RebuildsPusher(t *testing.T) { db := newTestDB(t) bt, err := New(Config{}, Deps{ @@ -42,8 +42,8 @@ func TestBackthink_SetActiveSink_RebuildsPusher(t *testing.T) { } } -// TestBackthink_SetIMSink_Fallback verifies a wired IM sink receives the push when -// no active client is online (so the owner is still reached). +// TestBackthink_SetIMSink_Fallback 验证没有活跃客户端在线时, +// 已接线的 IM 槽会收到推送(主人因此仍被触达)。 func TestBackthink_SetIMSink_Fallback(t *testing.T) { db := newTestDB(t) bt, err := New(Config{}, Deps{ @@ -56,7 +56,7 @@ func TestBackthink_SetIMSink_Fallback(t *testing.T) { t.Fatalf("new: %v", err) } - active := &mockActive{n: 0} // no active client online + active := &mockActive{n: 0} // 没有活跃客户端在线 im := &mockIMAdapter{} bt.SetActiveSink(active) bt.SetIMSink(im) @@ -72,9 +72,9 @@ func TestBackthink_SetIMSink_Fallback(t *testing.T) { } } -// TestBackthink_SinkSetters_ConcurrentSafe verifies the setter and the read of the -// current pusher do not race under the lifecycle lock, so an in-flight pass never -// touches a half-rebuilt pusher. +// TestBackthink_SinkSetters_ConcurrentSafe 验证设置器与对 +// 当前推送器的读取在生命周期锁下不产生竞态,因此进行中的轮次绝不会 +// 碰到半重建的推送器。 func TestBackthink_SinkSetters_ConcurrentSafe(t *testing.T) { db := newTestDB(t) bt, err := New(Config{}, Deps{ diff --git a/internal/services/backthink/state.go b/internal/services/backthink/state.go index d0793b5..0c8df09 100644 --- a/internal/services/backthink/state.go +++ b/internal/services/backthink/state.go @@ -11,33 +11,33 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" ) -// StateProvider supplies the user's current condition for the interrupt decision -// (design/12 §2). Production wires IoT/telemetry and calendar; tests inject a -// mock. It must be concurrency-safe. +// StateProvider 为打扰决策提供用户当前状况 +// (design/12 §2)。生产接线 IoT/遥测与日历;测试注入 +// mock。它必须并发安全。 type StateProvider interface { - // Current returns the user's current UserState. A nil-able error means "use - // a neutral state"; a hard error is logged by the caller and treated as - // neutral. + // Current 返回用户当前的 UserState。可 nil 的错误表示「用 + // 中性状态」;硬错误由调用方记日志并按 + // 中性处理。 Current(ctx context.Context) (UserState, error) } -// IoTStateProvider is a best-effort StateProvider built over an iot.Manager. It -// scans the managed devices and reads heart-rate and sleep signals from any -// device whose id/name/type mentions the corresponding keyword (e.g. "heart", -// "心率", "sleep", "睡眠"). Work/busy come from a device/state flag; when nothing -// matches it returns a neutral state rather than an error, so the service still -// runs without full telemetry wiring. +// IoTStateProvider 是基于 iot.Manager 的尽力而为 StateProvider。它 +// 扫描纳管设备,并从任何 id/名称/类型提到对应关键词的设备 +// 读取心率与睡眠信号(例如 "heart"、 +// "心率"、"sleep"、"睡眠")。工作/忙碌来自设备/状态标志;什么都不 +// 匹配时它返回中性状态而不是错误,因此即便没有完整 +// 遥测接线,服务也能运行。 type IoTStateProvider struct { mgr *iot.Manager log *slog.Logger } -// NewIoTStateProvider wraps mgr as a StateProvider. +// NewIoTStateProvider 把 mgr 包装为 StateProvider。 func NewIoTStateProvider(mgr *iot.Manager, log *slog.Logger) *IoTStateProvider { return &IoTStateProvider{mgr: mgr, log: logger.WithSource(baseLog(log), "services/backthink")} } -// Current implements StateProvider. +// Current 实现 StateProvider。 func (p *IoTStateProvider) Current(ctx context.Context) (UserState, error) { if p.mgr == nil { return UserState{}, fmt.Errorf("backthink: state: %w", iot.ErrProviderNotRegistered) @@ -71,7 +71,7 @@ func (p *IoTStateProvider) Current(ctx context.Context) (UserState, error) { return state, nil } -// numState reads the first numeric value among keys from a device's state map. +// numState 从设备状态 map 中按 keys 读取首个数值。 func numState(d iot.Device, keys ...string) (int, bool) { for _, k := range keys { v, ok := d.State[k] @@ -94,13 +94,13 @@ func numState(d iot.Device, keys ...string) (int, bool) { return 0, false } -// boolState reads a bool (or a "sleep"/"asleep" string) among keys. +// boolState 在 keys 中读取一个 bool(或 "sleep"/"asleep" 字符串)。 func boolState(d iot.Device, keys ...string) bool { b, _ := boolStateOK(d, keys...) return b } -// boolStateOK reports whether a bool-typed state flag was found and its value. +// boolStateOK 报告是否找到了 bool 类型的状态标志及其取值。 func boolStateOK(d iot.Device, keys ...string) (bool, bool) { for _, k := range keys { v, ok := d.State[k] diff --git a/internal/services/backthink/state_test.go b/internal/services/backthink/state_test.go index 5e7b004..e0ed69d 100644 --- a/internal/services/backthink/state_test.go +++ b/internal/services/backthink/state_test.go @@ -9,8 +9,8 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/iot" ) -// stateMockProvider is a tiny iot.Provider that returns a heart-rate watch, a -// sleep band and a working-calendar device, to exercise the state decoding. +// stateMockProvider 是一个极小的 iot.Provider,返回心率手表、 +// 睡眠手环与工作日历设备,用于演练状态解码。 type stateMockProvider struct{} func (stateMockProvider) Name() string { return "state-mock" } @@ -73,8 +73,8 @@ func TestIoTStateProvider_EmptyNeutral(t *testing.T) { t.Fatalf("iot.New: %v", err) } p := NewIoTStateProvider(mgr, nil) - // The mock provider has no heart-rate/sleep/working device, so it must yield - // a zero state instead of an error, keeping the service operable. + // 该 mock 提供者没有心率/睡眠/工作设备,所以它必须给出 + // 零值状态而不是错误,保持服务可运行。 st, err := p.Current(context.Background()) if err != nil { t.Fatalf("Current: %v", err) diff --git a/internal/services/backthink/store.go b/internal/services/backthink/store.go index f60214f..134012f 100644 --- a/internal/services/backthink/store.go +++ b/internal/services/backthink/store.go @@ -10,14 +10,14 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" ) -// defaultListLimit is the number of most-recent records returned by List when the -// caller passes a non-positive limit. +// defaultListLimit 是调用方传入非正 limit 时,List 返回的 +// 最近记录条数。 const defaultListLimit = 50 -// DDL for the thinking record store. at/created_at follow the project timestamp -// convention (RFC3339 with a timezone); trigger and conclusion are the free-text -// fields the panel renders; the push_* columns carry a decomposed PushResult so -// List need not parse a blob. The primary key is the project-standard auto id. +// 思考记录存储的 DDL。at/created_at 遵循项目时间戳 +// 约定(带时区的 RFC3339);trigger 与 conclusion 是面板渲染的 +// 自由文本字段;push_* 列携带分解后的 PushResult, +// 使 List 不必解析 blob。主键是项目标准的自增 id。 const createRecordsSQL = ` CREATE TABLE IF NOT EXISTS backthink_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -46,10 +46,10 @@ FROM backthink_records ORDER BY id DESC LIMIT ?` -// Record is one persisted thinking pass (design/12 §4), returned by Store.List -// and the service's List. Trigger is the source subsystem label, At the decision -// timestamp, Conclusion the runtime's output, ShouldPush the interrupt decision -// and PushResult how (or whether) the push went out. +// Record 是一轮已持久化的思考(design/12 §4),由 Store.List +// 与服务的 List 返回。Trigger 是来源子系统标签,At 是决策 +// 时间戳,Conclusion 是运行时的输出,ShouldPush 是打扰决策, +// PushResult 是推送如何(或是否)发出。 type Record struct { ID int64 Trigger string @@ -59,17 +59,17 @@ type Record struct { PushResult PushResult } -// Store persists thinking records in SQLite. It creates its table and index in -// NewStore so the module is self-contained (no migration bookkeeping), mirrors -// how channel/session creates its own table. It is concurrency-safe by virtue of -// the underlying *sql.DB. +// Store 把思考记录持久化到 SQLite。它在 +// NewStore 中建表与索引,因此本模块自包含(无需迁移记账), +// 与 channel/session 自建表的做法一致。它凭借底层的 *sql.DB +// 而并发安全。 type Store struct { db *sql.DB log *slog.Logger } -// NewStore builds a Store, ensuring the backthink_records table and index exist. -// It returns ErrNilDB when db is nil. ctx is used for the DDL. +// NewStore 构建 Store,确保 backthink_records 表与索引存在。 +// db 为 nil 时返回 ErrNilDB。ctx 用于 DDL。 func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error) { if db == nil { return nil, ErrNilDB @@ -84,7 +84,7 @@ func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error) return &Store{db: db, log: log}, nil } -// Append persists r and returns the new record id. +// Append 持久化 r 并返回新记录 id。 func (s *Store) Append(ctx context.Context, r Record) (int64, error) { res, err := s.db.ExecContext(ctx, insertRecordSQL, r.Trigger, @@ -109,8 +109,8 @@ func (s *Store) Append(ctx context.Context, r Record) (int64, error) { return id, nil } -// List returns the most recent records newest-first. A non-positive limit selects -// defaultListLimit. +// List 返回最近的记录,最新的在前。非正的 limit 选用 +// defaultListLimit。 func (s *Store) List(ctx context.Context, limit int) ([]Record, error) { if limit <= 0 { limit = defaultListLimit @@ -158,7 +158,7 @@ func (s *Store) List(ctx context.Context, limit int) ([]Record, error) { return records, nil } -// boolInt maps a bool to the SQLite INTEGER 1/0 representation. +// boolInt 把 bool 映射为 SQLite 的 INTEGER 1/0 表示。 func boolInt(b bool) int { if b { return 1 @@ -166,11 +166,11 @@ func boolInt(b bool) int { return 0 } -// intBool maps a SQLite INTEGER to a bool. +// intBool 把 SQLite 的 INTEGER 映射为 bool。 func intBool(n int) bool { return n != 0 } -// parseTime parses a stored RFC3339(nano/with-zone) timestamp, falling back to -// the zero time so a malformed row never breaks a listing. +// parseTime 解析存储的 RFC3339(带纳秒/时区)时间戳,失败时回退到 +// 零值时间,因此格式错乱的行绝不会破坏列表查询。 func parseTime(s string) time.Time { for _, layout := range []string{time.RFC3339Nano, time.RFC3339} { if t, err := time.Parse(layout, s); err == nil { diff --git a/internal/services/backthink/store_test.go b/internal/services/backthink/store_test.go index 0f7d19b..5dbe4a3 100644 --- a/internal/services/backthink/store_test.go +++ b/internal/services/backthink/store_test.go @@ -38,7 +38,7 @@ func TestStore_AppendList(t *testing.T) { if len(records) != 3 { t.Fatalf("len = %d, want 3", len(records)) } - // Newest first (descending id). + // 最新的在前(按 id 降序)。 if records[0].ID != 3 || records[1].ID != 2 || records[2].ID != 1 { t.Fatalf("order = %d,%d,%d, want 3,2,1", records[0].ID, records[1].ID, records[2].ID) } diff --git a/internal/services/backthink/thinker.go b/internal/services/backthink/thinker.go index 05299e8..8530012 100644 --- a/internal/services/backthink/thinker.go +++ b/internal/services/backthink/thinker.go @@ -7,29 +7,29 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" ) -// LoopThinker adapts a *loop.Loop into a Thinker. It is the production wiring -// that lets an autonomous background pass drive the full tool loop (memory -// injection, model steps, tool calls such as IoT/calendar/reminders) while seeing -// the main session's recent conversation and running under the owner environment. +// LoopThinker 把 *loop.Loop 适配为 Thinker。它是生产接线, +// 让一次自主后台轮驱动完整的工具循环(记忆 +// 注入、模型步骤、IoT/日历/提醒等工具调用),同时看到 +// 主会话的最近对话并在 owner 环境下运行。 type LoopThinker struct { loop *loop.Loop recent recentProvider } -// NewLoopThinker wraps l as a Thinker over the given recent-messages provider -// (*history.Store in production; nil degrades the think to the guidance prompt -// alone, see mainContext). +// NewLoopThinker 把 l 包装成基于给定最近消息提供者的 Thinker +// (生产中是 *history.Store;nil 会让思考降级为仅用引导提示词, +// 见 mainContext)。 func NewLoopThinker(l *loop.Loop, recent recentProvider) *LoopThinker { return &LoopThinker{loop: l, recent: recent} } -// Think runs one unattended think through the wrapped loop and returns the -// conclusion text. It inherits the main session's recent conversation as input -// context and injects the owner session environment (MemWritable=true, ActorOwner, -// full tool set) so the think can read/write memory and call tools. The loop's -// Run session id is the service's own (not the main session id) so the think never -// collides with the live main session's durable checkpoint store (design/20). -// It returns ErrNoLoop when no loop was injected. +// Think 经被包装的 loop 跑一轮无人值守思考并返回 +// 结论文本。它继承主会话的最近对话作为输入 +// 上下文,并注入 owner 会话环境(MemWritable=true、ActorOwner、 +// 完整工具集),因此该次思考能读写记忆并调用工具。loop 的 +// Run 会话 id 是本服务自己的(不是主会话 id),所以该次思考绝不会 +// 与活跃主会话的持久化检查点存储冲突(design/20)。 +// 未注入 loop 时返回 ErrNoLoop。 func (t *LoopThinker) Think(ctx context.Context, sessionID, trigger string) (string, error) { if t.loop == nil { return "", ErrNoLoop @@ -37,8 +37,8 @@ func (t *LoopThinker) Think(ctx context.Context, sessionID, trigger string) (str recent := mainContext(ctx, t.recent) messages := thinkInput(recent, trigger) envCtx := loop.WithSessionEnv(ctx, loop.OwnerEnv()) - // Mute UI broadcast: background reflection must not leak its intermediate - // reasoning into the user-facing chat stream (it only needs the conclusion). + // 静音 UI 广播:后台反思不得把其中间 + // 推理泄漏进面向用户的 chat 流(它只需要结论)。 res, err := t.loop.Run(loop.WithMutedBroadcast(envCtx), sessionID, messages) if err != nil { return "", fmt.Errorf("backthink: think: %w", err) diff --git a/internal/services/backthink/thinker_test.go b/internal/services/backthink/thinker_test.go index cf4355d..3fd9415 100644 --- a/internal/services/backthink/thinker_test.go +++ b/internal/services/backthink/thinker_test.go @@ -14,8 +14,8 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) -// fakeRecent is a recentProvider stub returning a fixed tail/error for the -// main-session context read. +// fakeRecent 是 recentProvider 的 stub,为 +// 主会话语境读取返回固定的尾部/错误。 type fakeRecent struct { msgs []history.Message err error @@ -25,8 +25,8 @@ func (f *fakeRecent) Recent(context.Context, string, int) ([]history.Message, er return f.msgs, f.err } -// captureAdapter is a model.Adapter that records every request it is handed so a -// test can assert on the messages the loop actually fed to the model. +// captureAdapter 是一个 model.Adapter,记录交给它的每个请求,好让 +// 测试能对 loop 实际喂给模型的消息做断言。 type captureAdapter struct { reqs []model.Request } @@ -42,9 +42,9 @@ func (c *captureAdapter) ChatStream(_ context.Context, req model.Request, _ func return c.Chat(context.Background(), req, model.Options{}) } -// newThinkLoop builds a real loop over ad with a working manual memory-extraction -// path so a test can prove the owner environment is in effect (extractMemory runs -// only when MemWritable=true). +// newThinkLoop 在 ad 之上构建一个真实 loop,并带一条可用的手动记忆抽取 +// 路径,好让测试能证明 owner 环境确实生效(extractMemory 只在 +// MemWritable=true 时运行)。 func newThinkLoop(t *testing.T, ad model.Adapter) (*loop.Loop, *memory.Store) { t.Helper() a, err := agent.New("You are Cyrene.", nil, nil, agent.WithModel(ad, nil)) @@ -61,7 +61,7 @@ func newThinkLoop(t *testing.T, ad model.Adapter) (*loop.Loop, *memory.Store) { return l, store } -// newMemoryStore opens an isolated in-memory memory store for one test. +// newMemoryStore 为单个测试打开一个隔离的内存记忆存储。 func newMemoryStore(t *testing.T) *memory.Store { t.Helper() d, err := db.Open(context.Background(), ":memory:", nil) @@ -76,7 +76,7 @@ func newMemoryStore(t *testing.T) *memory.Store { return s } -// modelText returns the concatenated text of a model message's text parts. +// modelText 返回一条 model 消息各文本片段拼接后的文本。 func modelText(m model.Message) string { var b strings.Builder for _, c := range m.Content { @@ -87,9 +87,9 @@ func modelText(m model.Message) string { return b.String() } -// TestGuidancePrompt_EncouragesProactiveWithoutSuppression guards the rewrite: -// the guidance must drop the old suppression phrases and push warm, targeted -// proactive expression (design/20 §2). +// TestGuidancePrompt_EncouragesProactiveWithoutSuppression 守护这次改写: +// 引导必须去掉旧的抑制措辞,并推动温暖、有针对性的 +// 主动表达(design/20 §2)。 func TestGuidancePrompt_EncouragesProactiveWithoutSuppression(t *testing.T) { t.Parallel() p := guidancePrompt("heartbeat") @@ -105,8 +105,8 @@ func TestGuidancePrompt_EncouragesProactiveWithoutSuppression(t *testing.T) { } } -// TestMainContext_MapsRecentMessages proves the recent main-session tail is -// converted in order with roles mapped and image parts preserved. +// TestMainContext_MapsRecentMessages 证明主会话最近尾部被 +// 按序转换、角色被映射、图片片段被保留。 func TestMainContext_MapsRecentMessages(t *testing.T) { t.Parallel() prov := &fakeRecent{msgs: []history.Message{ @@ -129,8 +129,8 @@ func TestMainContext_MapsRecentMessages(t *testing.T) { } } -// TestMainContext_Degrades ensures a nil provider or a failed read yields a nil -// tail so the think runs on the guidance prompt alone rather than failing. +// TestMainContext_Degrades 确保提供者为 nil 或读取失败都得到 nil +// 尾部,因此该次思考仅凭引导提示词运行,而不是失败。 func TestMainContext_Degrades(t *testing.T) { t.Parallel() if got := mainContext(context.Background(), nil); got != nil { @@ -141,8 +141,8 @@ func TestMainContext_Degrades(t *testing.T) { } } -// TestThinkInput_AppendsGuidanceLast verifies the assembled input is the recent -// context followed by a single guidance turn. +// TestThinkInput_AppendsGuidanceLast 验证拼装出的输入是最近 +// 语境后跟单个引导回合。 func TestThinkInput_AppendsGuidanceLast(t *testing.T) { t.Parallel() in := thinkInput([]model.Message{model.TextMessage(model.RoleUser, "hi")}, "heartbeat") @@ -154,10 +154,10 @@ func TestThinkInput_AppendsGuidanceLast(t *testing.T) { } } -// TestLoopThinker_Think_UsesMainContextAndOwnerEnv drives a real think and asserts -// three things from design/20 P1: the model saw the main-session recent message, -// the guidance prompt carries no suppression, and the think ran under the owner -// environment (the manual extractor persists only when MemWritable=true). +// TestLoopThinker_Think_UsesMainContextAndOwnerEnv 驱动一次真实思考并 +// 断言 design/20 P1 的三件事:模型看到了主会话的最近消息、 +// 引导提示词不携带抑制措辞,且该次思考跑在 owner +// 环境之下(手动抽取器只在 MemWritable=true 时持久化)。 func TestLoopThinker_Think_UsesMainContextAndOwnerEnv(t *testing.T) { ad := &captureAdapter{} l, store := newThinkLoop(t, ad) @@ -194,7 +194,7 @@ func TestLoopThinker_Think_UsesMainContextAndOwnerEnv(t *testing.T) { t.Fatalf("input missing the guidance prompt: %+v", last.Messages) } - // Owner env proved: ManualExtractor only persists under MemWritable=true. + // 已证明 owner 环境:ManualExtractor 只在 MemWritable=true 时持久化。 items, err := store.ListByPersona(context.Background(), "persona-a") if err != nil { t.Fatalf("ListByPersona: %v", err) @@ -207,8 +207,8 @@ func TestLoopThinker_Think_UsesMainContextAndOwnerEnv(t *testing.T) { } } -// TestLoopThinker_NilHistoryDegrades keeps the pre-existing backpressure: with no -// history provider the think still runs (guidance prompt only) and never panics. +// TestLoopThinker_NilHistoryDegrades 保持既有的背压行为:没有 +// 历史提供者时该次思考仍会运行(只用引导提示词)且绝不 panic。 func TestLoopThinker_NilHistoryDegrades(t *testing.T) { ad := &captureAdapter{} l, _ := newThinkLoop(t, ad) @@ -221,9 +221,9 @@ func TestLoopThinker_NilHistoryDegrades(t *testing.T) { t.Fatal("no model request captured") } last := ad.reqs[len(ad.reqs)-1] - // With no history provider the input carries the guidance prompt and no - // main-session messages. (The agent prepends its own instruction system message, - // so we assert presence rather than an exact count.) + // 没有历史提供者时,输入携带引导提示词、不含 + // 主会话消息。(agent 会自行前置一条指令系统消息, + // 所以我们断言存在性而非精确条数。) foundGuidance := false for _, m := range last.Messages { if strings.Contains(modelText(m), guidancePrompt("heartbeat")) { diff --git a/internal/services/backthink/trigger.go b/internal/services/backthink/trigger.go index c2ac755..e65eb05 100644 --- a/internal/services/backthink/trigger.go +++ b/internal/services/backthink/trigger.go @@ -7,22 +7,22 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/iot" ) -// TriggerKind classifies what woke a thinking pass, so the push path can apply the -// right policy: a heartbeat is gate-governed (60min≤1 / cooldown / per-flavor), an -// event is a real signal the owner wants to hear about and bypasses the rate gate. +// TriggerKind 归类唤醒一轮思考的原因,好让推送路径套用 +// 正确的策略:心跳受门控管辖(60min≤1 / 冷却 / 按风格), +// 事件是主人确实想听到的真实信号、绕过频率门。 type TriggerKind int const ( - // TriggerHeartbeat is the periodic autonomous reflection. Its proactive push is - // throttled by the PushGate so the owner is never spammed by empty reflections. + // TriggerHeartbeat 是周期性自主反思。它的主动推送受 + // PushGate 限流,使主人绝不会被空洞的反思刷屏。 TriggerHeartbeat TriggerKind = iota - // TriggerEvent is a real event (IoT device change, reminder, owner-relevant - // system event). Its push bypasses the PushGate — "该发就发" — subject only to - // same-event dedup. + // TriggerEvent 是真实事件(IoT 设备变化、提醒、与主人相关的 + // 系统事件)。它的推送绕过 PushGate——"该发就发"——只受 + // 同一事件去重约束。 TriggerEvent ) -// String returns a stable label for logs and records. +// String 返回用于日志与记录的稳定标签。 func (k TriggerKind) String() string { switch k { case TriggerEvent: @@ -34,40 +34,40 @@ func (k TriggerKind) String() string { } } -// Bus subjects the service subscribes to for event-triggered thinking. They are -// the contract defined by design/12 §1: an IoT state change, a reminder firing, -// or a generic system event. +// 本服务为事件驱动思考订阅的总线主题。它们是 +// design/12 §1 定义的契约:一次 IoT 状态变化、一次提醒触发, +// 或一次泛化的系统事件。 const ( - // SubjectReminderTrigger is the bus subject on which reminders fire. + // SubjectReminderTrigger 是提醒触发所用的总线主题。 SubjectReminderTrigger = "reminder.trigger" - // SubjectSystemEvent is the bus subject for generic system events. + // SubjectSystemEvent 是泛化系统事件的总线主题。 SubjectSystemEvent = "system.event" ) -// SystemEvent is the optional typed payload a caller may publish to -// SubjectSystemEvent to convey a system notice. Import defaults to Normal. Under -// design/12 §6 策略一, a low-value system notice (a join/leave ping, generic -// platform noise) is downgraded to a registration-only record and does not wake a -// full thinking pass; only a high-importance (owner-relevant) system event thinks, -// so a busy public platform cannot trigger a flood of expensive reflections. +// SystemEvent 是调用方可发布到 SubjectSystemEvent 以传达 +// 系统通知的可选类型化负载。Import 默认为 Normal。在 +// design/12 §6 策略一之下,低价值的系统通知(加入/退出 ping、泛化的 +// 平台噪声)被降级为仅登记一条记录,不唤醒 +// 一整轮思考;只有高重要度(与主人相关)的系统事件才思考, +// 因此繁忙的公开平台无法触发昂贵反思的洪流。 type SystemEvent struct { Summary string Import Importance } -// Trigger describes what woke a thinking pass: the source subsystem, a -// human-readable summary and the event's estimated importance. +// Trigger 描述唤醒一轮思考的原因:来源子系统、 +// 可读摘要与事件的估计重要度。 type Trigger struct { Source string Summary string Import Importance - // Kind records whether this pass was the periodic heartbeat or a real event, so - // the push path can bypass the rate gate for a genuine event (owner mandate). + // Kind 记录本轮是周期性心跳还是真实事件,好让 + // 推送路径能为真实事件绕过频率门(主人要求)。 Kind TriggerKind } -// heartbeatTrigger is the periodic, low-importance trigger. Low importance means -// the pass rarely interrupts the user, honouring "默认不发送 chat 类型消息". +// heartbeatTrigger 是周期性、低重要度的触发。低重要度意味着 +// 该轮很少打扰用户,遵守 "默认不发送 chat 类型消息"。 func heartbeatTrigger() Trigger { return Trigger{Source: "heartbeat", Summary: "periodic autonomous reflection", Import: ImportanceLow, Kind: TriggerHeartbeat} } @@ -84,10 +84,10 @@ func startupTrigger() Trigger { return Trigger{Source: "startup", Summary: StartupSummary, Import: ImportanceLow, Kind: TriggerHeartbeat} } -// triggerForEvent maps a bus event to a Trigger. An IoT device change is treated -// as normal importance, a reminder firing as high importance, and a generic -// system event as normal importance. Every event is tagged TriggerEvent so its -// proactive push bypasses the heartbeat PushGate. +// triggerForEvent 把总线事件映射为 Trigger。IoT 设备变化按 +// 普通重要度处理,提醒触发按高重要度,泛化的 +// 系统事件按普通重要度。每个事件都打上 TriggerEvent,因此其 +// 主动推送绕过心跳的 PushGate。 func triggerForEvent(e bus.Event) Trigger { switch e.Subject { case iot.DeviceUpdateSubject: