返回 DeepSeek-Reasonix
server_search.go
根目录 / internal / provider / server_search.go
1 package provider
2
3 import (
4 "encoding/json"
5 "strings"
6 )
7
8 // ServerSearchHit is one provider-executed web search result shown on a card.
9 // Encrypted page bodies stay on ServerSearchCall.Raw for API replay only.
10 type ServerSearchHit struct {
11 Title string `json:"title,omitempty"`
12 URL string `json:"url,omitempty"`
13 }
14
15 // ServerSearchCall is one provider-executed web_search turn. UI cards read
16 // Query/Results; Anthropic replay uses ID + Raw. omitempty keeps old sessions
17 // byte-compatible.
18 type ServerSearchCall struct {
19 SourcesStatus string `json:"sources_status,omitempty"`
20 ID string `json:"id"`
21 Query string `json:"query,omitempty"`
22 Results []ServerSearchHit `json:"results,omitempty"`
23 Raw json.RawMessage `json:"raw,omitempty"`
24 }
25
26 type wireSearchHit struct {
27 Title string `json:"title"`
28 URL string `json:"url"`
29 }
30
31 // ParseServerSearchHits extracts title/url pairs and ignores encrypted_content.
32 func ParseServerSearchHits(raw json.RawMessage) []ServerSearchHit {
33 if len(raw) == 0 {
34 return nil
35 }
36 var parsed []wireSearchHit
37 if err := json.Unmarshal(raw, &parsed); err != nil {
38 return nil
39 }
40 var out []ServerSearchHit
41 for _, hit := range parsed {
42 if hit.Title == "" && hit.URL == "" {
43 continue
44 }
45 out = append(out, ServerSearchHit(hit))
46 }
47 return out
48 }
49
50 // ParseServerSearchQuery reads {"query":"..."} from a server_tool_use input.
51 func ParseServerSearchQuery(raw string) string {
52 raw = strings.TrimSpace(raw)
53 if raw == "" {
54 return ""
55 }
56 var parsed struct {
57 Query string `json:"query"`
58 }
59 if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
60 return ""
61 }
62 return strings.TrimSpace(parsed.Query)
63 }
64
65 // FormatServerSearchArgs is the tool-card argument JSON {"query":"..."}.
66 func FormatServerSearchArgs(query string) string {
67 query = strings.TrimSpace(query)
68 if query == "" {
69 return ""
70 }
71 raw, err := json.Marshal(map[string]string{"query": query})
72 if err != nil {
73 return ""
74 }
75 return string(raw)
76 }
77
78 // FormatServerSearchFootnotes is the post-answer source list the desktop and
79 // CLI render as markdown. It is display-only and must not be written into
80 // Message.Content.
81 func FormatServerSearchFootnotes(hits []ServerSearchHit) string {
82 if len(hits) == 0 {
83 return ""
84 }
85 var b strings.Builder
86 for _, hit := range hits {
87 if hit.Title == "" && hit.URL == "" {
88 continue
89 }
90 b.WriteString("\n- **")
91 b.WriteString(hit.Title)
92 b.WriteString("**")
93 if hit.URL != "" {
94 b.WriteString("\n <")
95 b.WriteString(hit.URL)
96 b.WriteString(">")
97 }
98 }
99 if b.Len() == 0 {
100 return ""
101 }
102 return "\n" + b.String() + "\n"
103 }
104
105 // ParseServerSearchOutput reads title/URL pairs from FormatServerSearchOutput.
106 func ParseServerSearchOutput(output string) []ServerSearchHit {
107 // Independent searches carry a summary alongside sources. Keep accepting
108 // the original title/URL output so old sessions render unchanged.
109 var result struct {
110 Sources []ServerSearchHit `json:"sources"`
111 }
112 if strings.HasPrefix(strings.TrimSpace(output), "{") && json.Unmarshal([]byte(output), &result) == nil && result.Sources != nil {
113 return result.Sources
114 }
115 var out []ServerSearchHit
116 for line := range strings.SplitSeq(output, "\n") {
117 line = strings.TrimSpace(line)
118 if line == "" {
119 continue
120 }
121 if strings.HasPrefix(line, "http://") || strings.HasPrefix(line, "https://") {
122 if n := len(out); n > 0 && out[n-1].URL == "" {
123 out[n-1].URL = line
124 } else {
125 out = append(out, ServerSearchHit{URL: line})
126 }
127 continue
128 }
129 out = append(out, ServerSearchHit{Title: line})
130 }
131 return out
132 }
133
134 // FormatServerSearchOutput is the tool-card body: title then URL, one result
135 // per pair. It is not assistant answer text.
136 func FormatServerSearchOutput(hits []ServerSearchHit) string {
137 if len(hits) == 0 {
138 return ""
139 }
140 var b strings.Builder
141 for i, hit := range hits {
142 if i > 0 {
143 b.WriteByte('\n')
144 }
145 if hit.Title != "" {
146 b.WriteString(hit.Title)
147 if hit.URL != "" {
148 b.WriteByte('\n')
149 }
150 }
151 if hit.URL != "" {
152 b.WriteString(hit.URL)
153 }
154 }
155 return b.String()
156 }
157
158 // ServerSearchFromResponsesItem maps a completed web_search_call replay item
159 // onto the shared card/replay record. Unknown shapes return nil.
160 func ServerSearchFromResponsesItem(raw json.RawMessage) *ServerSearchCall {
161 var item struct {
162 ID string `json:"id"`
163 Type string `json:"type"`
164 Status string `json:"status"`
165 Action struct {
166 Type string `json:"type"`
167 Query string `json:"query"`
168 Queries []string `json:"queries"`
169 URL string `json:"url"`
170 Sources []struct {
171 URL string `json:"url"`
172 Title string `json:"title"`
173 } `json:"sources"`
174 } `json:"action"`
175 }
176 if err := json.Unmarshal(raw, &item); err != nil || item.Type != "web_search_call" || strings.TrimSpace(item.ID) == "" {
177 return nil
178 }
179 call := &ServerSearchCall{ID: item.ID, Query: strings.TrimSpace(item.Action.Query), Raw: append(json.RawMessage(nil), raw...)}
180 if call.Query == "" {
181 call.Query = strings.Join(item.Action.Queries, "\n")
182 }
183 // DeepSeek returns visited URLs on open_page/find_in_page actions even
184 // when search actions omit sources and message annotations are empty.
185 // Use that structured evidence; never infer sources from generated prose.
186 if item.Status == "completed" && (item.Action.Type == "open_page" || item.Action.Type == "find_in_page") && item.Action.URL != "" {
187 call.Results = append(call.Results, ServerSearchHit{URL: item.Action.URL})
188 }
189 for _, src := range item.Action.Sources {
190 if src.Title == "" && src.URL == "" {
191 continue
192 }
193 call.Results = append(call.Results, ServerSearchHit{Title: src.Title, URL: src.URL})
194 }
195 return call
196 }
197
198 // WalkServerSearchEstimate visits the compact and overflow-estimate surface
199 // for one provider search: id, query, and visible hit titles/URLs. Encrypted
200 // Raw is omitted because Anthropic does not count it toward input tokens
201 // when the block is replayed. Replay still sends Raw verbatim.
202 func WalkServerSearchEstimate(search ServerSearchCall, visit func(string)) {
203 if visit == nil {
204 return
205 }
206 visit(search.ID)
207 visit(search.Query)
208 for _, hit := range search.Results {
209 visit(hit.Title)
210 visit(hit.URL)
211 }
212 }
213
214 // MergeServerSearch upserts call into dst by ID, filling query/results/raw.
215 func MergeServerSearch(dst []ServerSearchCall, call ServerSearchCall) []ServerSearchCall {
216 call.SourcesStatus = ServerSearchSourcesStatus(call)
217 if strings.TrimSpace(call.ID) == "" {
218 return dst
219 }
220 for i := range dst {
221 if dst[i].ID != call.ID {
222 continue
223 }
224 if call.Query != "" {
225 dst[i].Query = call.Query
226 }
227 if call.SourcesStatus != "" {
228 dst[i].SourcesStatus = call.SourcesStatus
229 }
230 if len(call.Results) > 0 {
231 dst[i].Results = append([]ServerSearchHit(nil), call.Results...)
232 }
233 if len(call.Raw) > 0 {
234 dst[i].Raw = append(json.RawMessage(nil), call.Raw...)
235 }
236 return dst
237 }
238 copied := call
239 if len(call.Results) > 0 {
240 copied.Results = append([]ServerSearchHit(nil), call.Results...)
241 }
242 if len(call.Raw) > 0 {
243 copied.Raw = append(json.RawMessage(nil), call.Raw...)
244 }
245 return append(dst, copied)
246 }
247
248 // SearchPolicy separates native search injection from an independent client tool.
249 type SearchPolicy struct {
250 NativeEnabled bool
251 ClientEnabled bool
252 }
253
253 lines GO