动因:`.env` 已冻结(基础键不加/不改/不删),而「加一家 provider」原本会逼出 新的 env 键(一家一个 struct 字段 + searchProviderVector 逐家读 + 逐家写死的 switch)⇒ 想接腾讯云元宝/秘塔就只能破红线。面板本来就该是插件配置的一等来源 ($DATA_DIR/config/plugins/web-search.json),故让**文件成为权威**。 - plugins/tools/io/search/registry.go:接入位表加 defaultBaseURL(「没配 baseUrl 回落代码默认值」从此只有一个出处);新增 Descriptor(s)/DefaultBaseURL。 - cmd/cyrene/websearch_providers.go(新):按接入位表逐家算出生效投影 —— 加一家时这里**零改动**,这正是本片的核心;顺手把 wire_io_tools.go 从 430 行 降到 342(它逼近 §10 的 500 行上限)。 - cmd/cyrene/wire_plugin_actions.go / websearch_action_data.go / websearch_selection.go:基地址判定统一走「文件值 → 接入位默认值」的进位链; checkPrimaryUsable 判据不放松(真没配齐仍 400 primary_not_configured)。 🔴 **一处对我任务书的纠正(子代理提出、我采纳)**:我要求「既有 6 家在文件里 没有该条目时仍回落 .env」。仓库现状**没有**这层回落,且有回归用例把它钉死 (TestWireIOTools_PanelConfigClearedProviderDoesNotReviveFromEnv:「文件里没有 的家不得从 .env 复活」)。本片按「不改变既有行为」实现 —— 我的要求才是错的: 文件一旦存在就是权威,env 只在文件缺失/为空时播种。 验收(主会话复跑 + 亲自变异):go build ./... + 两包 vet 绿;search 包与 cmd/cyrene 全绿;我用 -overlay 让文件里的 apiKey 被忽略 ⇒ 4 条用例变红 (含「无 env 字段的家只认文件也能进注册表」这条核心判据)。 ⚠️ 新家仍未接:腾讯云元宝(tencent_wsa)/ 秘塔(metaso)两片随后串行落地。
343 lines
18 KiB
Go
343 lines
18 KiB
Go
package main
|
||
|
||
// 本文件装配 io 工具集(plugins/tools/io)的选项:知识检索器、日志、
|
||
// **web-search 的后端与超时**(切片 WS1-A 接线 + WS1-B provider 层 + PLG-4 可变源),
|
||
// 以及 design/28 §8 B5 的**浏览器降级档挂载**(`bodyFallbackOption()`,实现与口径在
|
||
// `wire_body_fallback.go` —— 挂载条件 = 能力面里确实有可用浏览器,详见那个文件)。
|
||
//
|
||
// 为什么单独成文件而不是塞在 wire_runtime.go 里:那条装配线本就接近文件行数上限,
|
||
// 而这里新增的裁决(端点/provider 关闭 ⇒ 工具不进注册面;选定 provider 未登记/未配置
|
||
// ⇒ 启动报错;面板配置优先、缺失由 `.env` 播种)需要一个能单测的落点。
|
||
//
|
||
// 六条口径:
|
||
//
|
||
// 1. **后端由配置选定**:主 provider 选谁(`WEB_SEARCH_PROVIDER` / 面板文件),降级链
|
||
// 怎么走,各家的 key 与基地址从哪来(design/27 §3)。
|
||
// 2. **配置有可变源**(design/09 §5.7 E):先读 `$DATA_DIR/config/plugins/web-search.json`
|
||
// —— 面板可改、0600 + temp+rename;文件**缺失/为空**时由 `.env` 播种(`.env` 从此只是
|
||
// 「最小化功能可跑」的引导面,本片**不新增任何 env 键**);文件**非法**时记 WARN 回落
|
||
// `.env`,且**不覆盖**原文件(损坏的配置可以人工救回)。
|
||
// 3. **拿不到可用后端就不注册工具**(主人 2026-10-05 报障的核心诉求):端点显式关闭
|
||
// (空串 / off / none)或不是 http(s) 绝对地址时,web-search **不进注册面** ——
|
||
// 模型根本看不见它,而不是看得见、一调就报错。
|
||
// 4. **各家的名字表来自插件,不来自 `.env`**:文件存在时它是**唯一权威**(文件里没有的家
|
||
// 就是没有),逐家的名字 / 是否需要 key / 默认基地址全取 `plugins/tools/io/search` 的
|
||
// 接入位表 ⇒ **加一家 provider 不必加 env 键**(`.env` 冻结红线)。实现在
|
||
// `websearch_providers.go`。
|
||
// 5. **未配置的家不进可用清单**:`search.Configured` 判定(云家看 key、searxng 看端点),
|
||
// false ⇒ 该家不出现在注册表里(fail-closed)。选定 provider 未配置时由 io 工具集在
|
||
// **启动期报错**,绝不静默回落到兜底。
|
||
// 6. **超时不另造默认值**:`WEB_SEARCH_TIMEOUT` / 面板的 `timeout` 未声明时沿用 io 包既有
|
||
// 超时;负值是笔误,按未配置处理并记一条带键名的 WARN(不静默)。
|
||
//
|
||
// 同一套解析也服务**热重载**(切片 W3,裁决 2026-10-06 #1):面板保存后由
|
||
// `buildWebSearchTool` 按**当前落盘配置**重建这一把工具并替换注册表条目,因此
|
||
// 「启动期的接线」与「保存后的接线」恒为同一条路径(不是两份语义)。两份调用的差别
|
||
// 只有日志里的 cause 字段(startup / panel-save),便于排障时区分。
|
||
|
||
import (
|
||
"log/slog"
|
||
"strings"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||
iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
|
||
)
|
||
|
||
// web-search 的 env 键名:与 `.env.example` 逐字一致(AGENTS §10 的 E6 条目),
|
||
// 由 wire_web_search_test.go 逐字核对。它们仍然是**引导面**的键(.env 冻结后不再新增)。
|
||
const (
|
||
webSearchProviderEnv = "WEB_SEARCH_PROVIDER"
|
||
webSearchEndpointEnv = "WEB_SEARCH_ENDPOINT"
|
||
webSearchTimeoutEnv = "WEB_SEARCH_TIMEOUT"
|
||
webSearchMaxResultsEnv = "WEB_SEARCH_MAX_RESULTS"
|
||
webSearchFallbackEnv = "WEB_SEARCH_FALLBACK"
|
||
)
|
||
|
||
// ioLogSource 是本文件日志的来源标签(项目规范 §10:禁止无来源裸日志)。
|
||
const ioLogSource = "cmd/cyrene/wire_io_tools"
|
||
|
||
// 生效配置的来源标签(写进启动日志:一眼看出「这次的 provider 是 .env 给的还是面板给的」)。
|
||
const (
|
||
// webSearchSourceEnv 表示本次的配置来自 `.env`(含「文件缺失、刚由 .env 播种」这一步)。
|
||
webSearchSourceEnv = "env"
|
||
// webSearchSourcePanel 表示本次的配置来自面板可变源(面板是唯一来源)。
|
||
webSearchSourcePanel = "panel"
|
||
)
|
||
|
||
// 解析一次 web-search 配置的**触发场景**(只进日志的 cause 字段)。
|
||
const (
|
||
// webSearchCauseStartup 是启动期装配(`ioToolOptions`)。
|
||
webSearchCauseStartup = "startup"
|
||
// webSearchCausePanelSave 是面板保存后的热重载(`buildWebSearchTool`,见 websearch_reload.go)。
|
||
webSearchCausePanelSave = "panel-save"
|
||
)
|
||
|
||
// webSearchToolName 是 web-search 工具在能力注册表里的名字。
|
||
//
|
||
// 它与 io 包 `func (s *webSearchTool) Name()` 里的字面量同源,而那个名字没有导出常量
|
||
// (`plugins/tools/io` 不在本片文件面内,不去改它):因此这里用字面量,并由
|
||
// websearch_reload_test.go 钉住 —— 名字一旦漂移,热重载会立刻找不到工具(测试红)。
|
||
const webSearchToolName = "web-search"
|
||
|
||
// ioToolOptions 组装 io 工具集的全部选项。wire_runtime 把它原样交给 iotools.New,
|
||
// 因此「注册面里有哪些 io 工具」只由这一处决定。
|
||
//
|
||
// 它**只做 io 选项**(切片 X3 的收口):browser 的接线挂点原先是借这里转交的
|
||
// (V5 当时改不了 wire_runtime.go),现已归位到 `wireRuntime` 里与 `wireVision` /
|
||
// `wireTranscode` 并列的那一行(见 wire_runtime.go)。转交留下的第二个代价是副作用:
|
||
// 凡是只为了要一份 iotools.Option 而调用本函数的路径(例如 web-search 的接线测试)
|
||
// 都会顺带探测并注册浏览器工具 —— 那不该是「取选项」这件事的含义。
|
||
func (a *app) ioToolOptions() []iotools.Option {
|
||
opts := []iotools.Option{
|
||
iotools.WithKnowledgeRetriever(a.retriever),
|
||
iotools.WithLogger(a.log),
|
||
// B5 的挂载点:有可用浏览器就挂上浏览器渲染取正文,没有就不挂(口径与实现
|
||
// 都在 wire_body_fallback.go)。挂载条件必须在**浏览器装配之后**求值 ——
|
||
// 这就是 wireRuntime 里 `a.wireBrowser()` 排在 io 工具集之前的原因。
|
||
a.bodyFallbackOption(),
|
||
}
|
||
return append(opts, a.webSearchOptions(webSearchCauseStartup)...)
|
||
}
|
||
|
||
// buildWebSearchTool 按**当前生效配置**构造这一把 web-search 工具(面板保存后的热重载用它
|
||
// 重建工具,见 websearch_reload.go)。
|
||
//
|
||
// 三种返回值,调用方必须分开处置:
|
||
// - (tool, nil) ⇒ 正常,工具该在注册面里;
|
||
// - (nil, nil) ⇒ 当前配置下它**不该在注册面里**(端点/主 provider 显式关闭,或选定
|
||
// provider 没配齐被 WithoutSearch 摘掉)—— 调用方据此把旧条目摘掉;
|
||
// - (nil, err) ⇒ 这份配置会让后端**下次启动直接失败**(主 provider 未登记/未配置,
|
||
// design/27 §3 的 fail-fast)—— 调用方必须如实报错,绝不留下一个起不来的文件。
|
||
//
|
||
// 为什么要构造整份 io 工具集再挑出 web-search:io 包的公开构造入口只有
|
||
// `iotools.New(cfg, opts...)`(整份集合),没有「只造 web-search」的入口,而
|
||
// `plugins/tools/io` 不在本片文件面内。代价是一次 New(几个工具对象 + 一个 http.Client
|
||
// 壳;Transport 是共享的 http.DefaultTransport,不新开连接池)—— 面板保存是低频动作,
|
||
// 付得起,换来的是**重载后的工具与启动期构造的逐字段同源**(不是第二份接线逻辑)。
|
||
func (a *app) buildWebSearchTool(cause string) (sdk.Tool, error) {
|
||
set, err := iotools.New(a.cfg, a.webSearchOptions(cause)...)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
for _, t := range set {
|
||
if t.Name() == webSearchToolName {
|
||
return t, nil
|
||
}
|
||
}
|
||
return nil, nil
|
||
}
|
||
|
||
// webSearchOptions 把生效配置翻译成 io 选项。cause 说明**为什么**解析这一次(启动装配 /
|
||
// 面板保存后的热重载),只进日志:同一个函数被两处调用,日志里必须能分清是哪一次。
|
||
// 端点/后端「关闭 / 非法 / 可用」三种去处都在这里定死(ResolveProvider/ResolveEndpoint)。
|
||
func (a *app) webSearchOptions(cause string) []iotools.Option {
|
||
log := logger.WithSource(a.log, ioLogSource)
|
||
eff, source := a.effectiveWebSearch(log)
|
||
ws := eff.Scalar
|
||
|
||
// ① 后端显式关闭(主 provider = 空 / off / none)⇒ 整件工具不注册。
|
||
if decision := ws.ResolveProvider(); decision.Disabled {
|
||
// 配置值**绝不写进日志**(只记键名与原因)。
|
||
log.Warn("web-search tool not registered",
|
||
"selectorKey", webSearchProviderEnv, "reason", config.WebSearchReasonOff, "configSource", source,
|
||
"cause", cause,
|
||
"effect", "the model will not see a web-search tool at all")
|
||
return []iotools.Option{iotools.WithoutSearch()}
|
||
}
|
||
|
||
specs := searchProviderSpecs(ws, eff.Providers)
|
||
|
||
// ② searxng 的可用性 = 端点裁决(WS1-A 的既有语义):端点显式关闭 / 不是 http(s)
|
||
// 绝对地址时**整件工具不注册** —— 那两种取值是「关掉搜索」的显式选择,不是配置
|
||
// 错误,因此不 fatal,只留一条带键名的 WARN。
|
||
if endpoint := ws.ResolveEndpoint(); !endpoint.Enabled && selectsSearXNG(ws) {
|
||
log.Warn("web-search tool not registered",
|
||
"endpointKey", webSearchEndpointEnv, "reason", endpoint.Reason, "configSource", source,
|
||
"cause", cause,
|
||
"effect", "the model will not see a web-search tool at all")
|
||
return []iotools.Option{iotools.WithoutSearch()}
|
||
}
|
||
|
||
// primary 是**生效值**(可能来自面板文件、也可能来自 `.env` 播种)⇒ 它旁边的键名只是
|
||
// 「这个键在哪份配置里」,而 source 才是「本次的值从哪来」。两者都打出来,不混为一谈。
|
||
log.Info("web-search providers configured", "cause", cause,
|
||
"selectorKey", webSearchProviderEnv, "primary", strings.ToLower(strings.TrimSpace(ws.Provider)),
|
||
"fallbackKey", webSearchFallbackEnv, "fallback", ws.Fallback, "configSource", source,
|
||
"available", availableProviderNames(specs))
|
||
|
||
opts := []iotools.Option{
|
||
iotools.WithSearchProviders(specs...),
|
||
// 选定主 provider + 降级链:未登记(如本期未做的 tavily)或未配置(缺 key)
|
||
// ⇒ io 工具集在启动期报错(不许静默回落)。
|
||
iotools.WithSearchSelection(ws.Provider, ws.Fallback),
|
||
iotools.WithSearchMaxResults(ws.MaxResults),
|
||
}
|
||
switch {
|
||
case ws.Timeout > 0:
|
||
opts = append(opts, iotools.WithSearchTimeout(ws.Timeout))
|
||
case ws.Timeout < 0:
|
||
log.Warn("web-search timeout is negative, treated as unset",
|
||
"key", webSearchTimeoutEnv, "configSource", source, "cause", cause,
|
||
"effect", "io package default timeout applies")
|
||
}
|
||
return opts
|
||
}
|
||
|
||
// effectiveWebSearch 解析 web-search 的**生效配置**,并报告它是从哪儿来的:
|
||
//
|
||
// - 面板文件存在且合法 ⇒ 它是唯一来源(design/09 §5.7 E 的「先读可变源」);
|
||
// - 文件缺失/为空 ⇒ 用 `.env` 播种落盘,本次行为与 `.env` 完全一致(「缺失回落 `.env`
|
||
// 播种」,照 models.json materialize 范式);
|
||
// - 文件存在但非法 ⇒ 记 WARN、本次回落 `.env`,**不覆盖**原文件。
|
||
//
|
||
// 返回值里的第二个值是来源标签,只用于日志(`.env` 还是面板),不参与任何裁决。
|
||
func (a *app) effectiveWebSearch(log *slog.Logger) (effectiveWebSearchConfig, string) {
|
||
env := a.cfg.WebSearch
|
||
path := runtimeconfig.WebSearchConfigPath(resolveDataDir(a.cfg))
|
||
f, found, err := runtimeconfig.LoadWebSearch(path)
|
||
switch {
|
||
case err != nil:
|
||
log.Warn("web-search 面板配置非法,本次按 .env 运行(不覆盖原文件)",
|
||
"path", path, "error", err)
|
||
return envEffectiveWebSearch(env), webSearchSourceEnv
|
||
case !found:
|
||
return envEffectiveWebSearch(a.seedWebSearchConfig(log, path, env)), webSearchSourceEnv
|
||
}
|
||
warnUnknownSearchProviders(log, path, f)
|
||
eff, convErr := effectiveSearchConfig(env, f)
|
||
if convErr != nil {
|
||
log.Warn("web-search 面板配置无法生效,本次按 .env 运行",
|
||
"path", path, "error", convErr)
|
||
return envEffectiveWebSearch(env), webSearchSourceEnv
|
||
}
|
||
log.Info("web-search 面板配置生效", "path", path, "providers", len(f.Providers))
|
||
return eff, webSearchSourcePanel
|
||
}
|
||
|
||
// envEffectiveWebSearch 是「**没有可用文件**」时的生效配置:逐字沿用 `.env` 播种层的语义
|
||
// (= `seedWebSearchFile` 会落盘的那份形状)。
|
||
//
|
||
// 它是 `.env` 唯一还能决定各家接线的时刻:文件一旦存在,权威就在文件那边(design/09 §5.7 E
|
||
// 的「先读可变源、缺失回落 `.env` 播种」)。
|
||
func envEffectiveWebSearch(env config.WebSearchConfig) effectiveWebSearchConfig {
|
||
return effectiveWebSearchConfig{Scalar: env, Providers: searchProviderVector(env)}
|
||
}
|
||
|
||
// seedWebSearchConfig 把 `.env` 播种成面板文件(`.env` 只作引导面)。
|
||
//
|
||
// 播种失败**不阻断启动**:本次按 `.env` 运行,只记一条 WARN(与 models.json 的处置一致)。
|
||
func (a *app) seedWebSearchConfig(log *slog.Logger, path string, env config.WebSearchConfig) config.WebSearchConfig {
|
||
seed := seedWebSearchFile(env)
|
||
if err := runtimeconfig.SaveWebSearch(path, seed); err != nil {
|
||
log.Warn("web-search 面板配置播种失败(本次按 .env 运行)", "path", path, "error", err)
|
||
return env
|
||
}
|
||
log.Info("web-search 面板配置已按 .env 播种(此后面板是唯一来源)",
|
||
"path", path, "providers", len(seed.Providers))
|
||
return env
|
||
}
|
||
|
||
// seedWebSearchFile 把 `.env` 的 web-search 段投影成可变源的初始形状。
|
||
//
|
||
// **本期内置的家全列**(含还没配 key 的):面板的用途就是「一次把各家配齐」,
|
||
// 让用户先看到完整清单、再逐条填,比只列已配置的家少一轮「名字得自己知道」。
|
||
//
|
||
// 「全列」的名单来自 `.env` 的字段表(`envSearchProviders`)—— 那是**播种层**的输入,
|
||
// 因此这里的条数与被播种的键集逐字保持现状(`.env` 冻结:不加、不改、不删)。
|
||
func seedWebSearchFile(ws config.WebSearchConfig) runtimeconfig.WebSearchFile {
|
||
enabled := runtimeconfig.EnableFlag(true)
|
||
providers := envSearchProviders(ws)
|
||
entries := make([]runtimeconfig.ProviderEntry, 0, len(providers))
|
||
for _, p := range providers {
|
||
entries = append(entries, runtimeconfig.ProviderEntry{
|
||
Provider: p.Name, APIKey: p.APIKey, BaseURL: p.BaseURL, Enabled: enabled,
|
||
})
|
||
}
|
||
return runtimeconfig.WebSearchFile{
|
||
Version: runtimeconfig.WebSearchVersion,
|
||
Provider: ws.Provider,
|
||
Fallback: ws.Fallback,
|
||
MaxResults: ws.MaxResults,
|
||
Timeout: seedTimeout(ws.Timeout),
|
||
Providers: entries,
|
||
}
|
||
}
|
||
|
||
// effectiveSearchConfig 把面板文件翻译成工具要的生效配置。
|
||
//
|
||
// **权威边界**(写清楚,免得「谁覆盖谁」靠猜):
|
||
//
|
||
// - **标量以文件为准**:主 provider / 降级链来自文件(文件里写空 / off / none = 显式关闭,
|
||
// 与 `.env` 同一套哨兵语义);maxResults / timeout 在「没写」时沿用 `.env`(那两个标量
|
||
// 在「没写」与「写成空」之间无法区分,而它们的默认值本来就是引导面的一部分)。
|
||
// - **各家以文件为准**:文件里没有的家就是没有(绝不从 `.env` 复活,否则「我在面板里
|
||
// 删了它」会变成一句假话),凭据为空 = 这家没配。逐家的名字表**不再由 `.env` 的字段表
|
||
// 决定**,而是来自接入位表 —— 见 effectiveSearchProviders / resolveSearchProviders。
|
||
// - **searxng 的端点是开关**:文件里那条的 baseUrl 为空 = 关掉这家,**不回落** `.env`
|
||
// (回落会让「清空地址」变成假话)。
|
||
func effectiveSearchConfig(env config.WebSearchConfig, f runtimeconfig.WebSearchFile) (effectiveWebSearchConfig, error) {
|
||
out := env
|
||
out.Provider = f.Provider
|
||
out.Fallback = append([]string(nil), f.Fallback...)
|
||
if f.MaxResults > 0 {
|
||
out.MaxResults = f.MaxResults
|
||
}
|
||
d, err := f.TimeoutDuration()
|
||
if err != nil {
|
||
return effectiveWebSearchConfig{}, err
|
||
}
|
||
if d > 0 {
|
||
out.Timeout = d
|
||
}
|
||
// 逐家字段一律清零:它们承载的是 `.env` 的**播种**形状,而生效值现在由 Providers 承载
|
||
// (见 effectiveWebSearchConfig 的注释)。清零也顺带保证了任何误用都不会把 `.env` 的
|
||
// 旧凭据当成「生效凭据」。
|
||
out.ZhipuAPIKey, out.ZhipuBaseURL = "", ""
|
||
out.AliyunIQSAPIKey, out.AliyunIQSBaseURL = "", ""
|
||
out.BochaAPIKey, out.BochaBaseURL = "", ""
|
||
out.VolcengineAPIKey, out.VolcengineBaseURL = "", ""
|
||
out.BaiduQianfanAPIKey, out.BaiduQianfanBaseURL = "", ""
|
||
out.Endpoint = ""
|
||
if e, ok := findProviderEntry(f, search.NameSearXNG); ok && e.IsEnabled() {
|
||
out.Endpoint = strings.TrimSpace(e.BaseURL)
|
||
}
|
||
return effectiveWebSearchConfig{Scalar: out, Providers: effectiveSearchProviders(f)}, nil
|
||
}
|
||
|
||
// warnUnknownSearchProviders 对文件里本期没登记的家记一条 WARN。
|
||
//
|
||
// 它们不可能被选中(选中的家由 io 工具集在启动期校验),但「配了却不生效」必须说出来 ——
|
||
// 静默忽略正是「我明明配了 Tavily 却搜不出」那类报障的来源。
|
||
func warnUnknownSearchProviders(log *slog.Logger, path string, f runtimeconfig.WebSearchFile) {
|
||
for _, e := range f.Providers {
|
||
name := strings.ToLower(strings.TrimSpace(e.Provider))
|
||
if name == "" || search.Known(name) {
|
||
continue
|
||
}
|
||
log.Warn("web-search 面板配置里有未登记的提供商,已忽略",
|
||
"path", path, "provider", name, "known", search.KnownNames())
|
||
}
|
||
}
|
||
|
||
// selectsSearXNG 报告当前配置选的是不是 searxng(大小写与空白不敏感)。
|
||
func selectsSearXNG(ws config.WebSearchConfig) bool {
|
||
return strings.EqualFold(strings.TrimSpace(ws.Provider), search.NameSearXNG)
|
||
}
|
||
|
||
// 各家的配置投影(searchProviderWire / searchProviderVector / searchProviderSpecs /
|
||
// availableProviderNames)在 `websearch_providers.go` —— 那一片是本片「去 env 化」的本体
|
||
// (按**接入位表**逐家投影,而不是按 `.env` 的字段表)。
|
||
|
||
// seedTimeout 把超时播成 duration 字符串:未声明 / 笔误(≤0)落成空 = 未声明。
|
||
func seedTimeout(d time.Duration) string {
|
||
if d <= 0 {
|
||
return ""
|
||
}
|
||
return d.String()
|
||
}
|