onesvm-browser-server/server/internal/scheduler/template.go
chii 347c94c121 fix: 搜索缓存往返保留 results,并加检索源规则过滤
缓存命中改写信封时补 UnmarshalJSON,避免 results 被抹空;零结果不进 300s 缓存。模版层对 SearXNG hits 做广告标记丢弃与同域限额 1 条,减轻 1688 霸屏。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 11:33:13 +08:00

300 lines
8.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package scheduler 队列/调度角色:队列核心 + 能力路由 + 降级链 + 模版层整合
// (Contract §5-A3;模版层整合器归属 S7 P1-AR1 锁定)。
//
// template.go:共享模版层(整合器)。所有适配器的 RawResult 统一经此产出 Envelope:
// ① Size guard(markdown ≤ max_chars 截断 + truncated 标记)
// ② safetyscan(高危 block → error denied + audit;PII redact;注入包裹 wrap)
// ③ score 归一 0–1(searxng 位次分 1 - 0.05*rank 截断 [0,1])
// ④ 信封封装(provenance.adapter/proxy_exit/cached/retrieved_at +08:00、
//
// usage.tokens_estimate = ceil(chars/4))。
//
// 合规代码零散在适配器内 = 门闩矩阵漂移,禁止。
package scheduler
import (
"fmt"
"strings"
"onesvm.com/onesvm/browser-server/internal/contract"
"onesvm.com/onesvm/browser-server/internal/safetyscan"
)
// maxContentSnippet 搜索 content 片段上限(mcp-usage §2.1:content ≤800 字符)。
const maxContentSnippet = 800
// Template 共享模版层。
type Template struct {
scanner *safetyscan.Scanner
}
// NewTemplate 构造(词表加载失败即 error——fail-closed,不允许无扫描出结果)。
func NewTemplate() (*Template, error) {
sc, err := safetyscan.Load()
if err != nil {
return nil, fmt.Errorf("scheduler: safetyscan 词表加载: %w", err)
}
return &Template{scanner: sc}, nil
}
// TemplateInput 模版层输入。
type TemplateInput struct {
Job contract.JobEnvelope
Adapter string // provenance.adapter
ProxyExit string // direct | pool:<name> | none
Cached bool
Raw *contract.RawResult
Warnings []string // 降级链/引擎附加警示(降质必须显式打 warnings)
TookMs int64
}
// Build RawResult → Envelope。返回信封;safetyscan 高危命中时 ok=false +
// error=denied(合规拦截不假装成功,audit 由调用方落库)。
func (t *Template) Build(in TemplateInput) *contract.Envelope {
env := &contract.Envelope{
OK: true,
Kind: in.Job.Intent,
RequestID: in.Job.RequestID,
TookMs: in.TookMs,
}
warnings := in.Warnings
if warnings == nil {
warnings = []string{}
}
switch in.Job.Intent {
case "search":
sp, blocked := t.buildSearch(in, warnings)
if blocked != nil {
return blocked
}
env.Data = sp
env.Usage = contract.Usage{Credits: 1, Engine: in.Raw.Engine,
TokensEstimate: tokensEstimateOf(searchCharsOf(sp))}
case "read":
rp, blocked := t.buildRead(in, warnings)
if blocked != nil {
return blocked
}
env.Data = rp
env.Usage = contract.Usage{Credits: 1, Engine: in.Raw.Engine,
TokensEstimate: tokensEstimateOf(len([]rune(rp.Markdown)))}
default:
env.OK = false
env.Error = &contract.ErrBody{Code: contract.CodeUpstream, Message: "未知意图 " + in.Job.Intent}
}
env.Provenance = contract.Provenance{
URL: in.Raw.FinalURL,
FinalURL: in.Raw.FinalURL,
RetrievedAt: contract.NowTime(),
Adapter: in.Adapter,
ProxyExit: proxyExitOrDefault(in.ProxyExit),
Cached: in.Cached,
}
return env
}
// buildDenied 合规拦截信封(error.code=denied;不产数据块)。
func (t *Template) buildDenied(in TemplateInput, ruleID, reason string, warnings []string) *contract.Envelope {
env := &contract.Envelope{
OK: false,
Kind: in.Job.Intent,
RequestID: in.Job.RequestID,
TookMs: in.TookMs,
Data: nil,
Error: &contract.ErrBody{Code: contract.CodeDenied, Message: reason},
}
env.Provenance = contract.Provenance{
RetrievedAt: contract.NowTime(),
Adapter: in.Adapter,
ProxyExit: proxyExitOrDefault(in.ProxyExit),
}
_ = warnings // 拦截信封无正文,warnings 随审计落库
return env
}
// buildSearch kind=search 数据块:规则过滤全量 hits → 裁 max_results → SearchResult
// (content ≤800、位次分 1-0.05*i、published_at 东八区)。
func (t *Template) buildSearch(in TemplateInput, warnings []string) (*contract.SearchPayload, *contract.Envelope) {
items, unres := parseSearxResults(in.Raw)
if len(unres) > 0 {
warnings = append(warnings, "unresponsive_engines: "+strings.Join(unres, ","))
}
payload := &contract.SearchPayload{Query: queryOf(in.Raw, in.Job), Results: []contract.SearchResult{}}
max := 5
if in.Job.Search != nil && in.Job.Search.MaxResults > 0 {
max = in.Job.Search.MaxResults
}
if max > 20 {
max = 20 // mcp-usage §2.1:≤20
}
filtered := filterSearchHits(items)
dropped := len(items) - len(filtered)
if len(filtered) > max {
filtered = filtered[:max]
}
for i, it := range filtered {
res := contract.SearchResult{
ID: fmt.Sprintf("r%d", i+1),
Title: it.Title,
URL: it.URL,
Content: clipRunes(it.Content, maxContentSnippet),
Score: normalizeRankScore(i),
Engine: it.Engine,
}
if it.PublishedDate != nil {
if pt, ok := publishedAtOf(*it.PublishedDate); ok {
res.PublishedAt = &pt
}
}
payload.Results = append(payload.Results, res)
}
if dropped > 0 {
warnings = append(warnings, fmt.Sprintf("filtered_nav_hits:%d", dropped))
}
// ② safetyscan:title/content 逐条扫(search 无 markdown 字段)。
for i := range payload.Results {
res, outTitle, _, outMD := t.scanner.Scan(payload.Results[i].Title, "", payload.Results[i].Content)
if res.Blocked {
return nil, t.buildDenied(TemplateInput{Job: in.Job, Adapter: in.Adapter, TookMs: in.TookMs},
res.WordlistVer, "搜索结果命中高危词表(wordlist "+res.WordlistVer+")", res.Hits)
}
payload.Results[i].Title = outTitle
payload.Results[i].Content = outMD
if res.Redacted {
warnings = append(warnings, "pii_redacted in search results")
}
}
payload.EnsureEmptySlice()
return payload, nil
}
// buildRead kind=read 数据块:size guard → safetyscan → 注入包裹。
func (t *Template) buildRead(in TemplateInput, warnings []string) (*contract.ReadPayload, *contract.Envelope) {
in2 := in.Raw
md := in2.Markdown
if md == "" && in2.Text != "" {
md = in2.Text // CDP 引擎 text 兜底
}
// ① Size guard:max_chars 截断 + truncated 显式标记。
maxChars := 20000
if in.Job.Read != nil && in.Job.Read.MaxChars > 0 {
maxChars = in.Job.Read.MaxChars
}
truncated := false
runes := []rune(md)
if len(runes) > maxChars {
md = string(runes[:maxChars])
truncated = true
}
charCount := len([]rune(md))
// ② safetyscan:title/description/markdown 分别扫。
res, outTitle, _, outMD := t.scanner.Scan(in2.Title, "", md)
if res.Blocked {
return nil, t.buildDenied(TemplateInput{Job: in.Job, Adapter: in.Adapter, TookMs: in.TookMs},
res.WordlistVer, "正文命中高危词表(wordlist "+res.WordlistVer+")", res.Hits)
}
// ② 注入包裹:正文包 delimiter(防 ignore-previous-instructions)。
wrapped := safetyscan.Wrap(outMD)
if res.Redacted {
warnings = append(warnings, fmt.Sprintf("pii_redacted (%d 处)", res.RedactCount))
}
rp := &contract.ReadPayload{
URL: firstNonEmptyStr(readURLof(in.Job), in2.FinalURL),
FinalURL: in2.FinalURL,
Title: outTitle,
Markdown: wrapped,
Truncated: truncated,
CharCount: charCount,
Metadata: contract.ReadMetadata{
StatusCode: in2.StatusCode,
ContentType: contentTypeOf(in2),
Language: "",
RetrievedAt: contract.NowTime(),
},
Links: []string{},
Images: []string{},
Warnings: warnings,
Extracted: nil,
}
rp.EnsureEmptySlice()
return rp, nil
}
// normalizeRankScore 位次分归一(plan-final §2.5 / A3.2:1 - 0.05*rank 截断 [0,1])。
func normalizeRankScore(rank int) float64 {
s := 1 - 0.05*float64(rank)
if s < 0 {
s = 0
}
if s > 1 {
s = 1
}
return s
}
// tokensEstimate usage.tokens_estimate = ceil(chars/4)。
func tokensEstimateOf(chars int) int {
if chars <= 0 {
return 0
}
return (chars + 3) / 4
}
func searchCharsOf(p *contract.SearchPayload) int {
n := 0
for _, r := range p.Results {
n += len([]rune(r.Title + r.Content))
}
return n
}
func proxyExitOrDefault(p string) string {
if p == "" {
return "none"
}
return p
}
func clipRunes(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n])
}
func firstNonEmptyStr(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}
func queryOf(raw *contract.RawResult, job contract.JobEnvelope) string {
if q, ok := raw.Extra["query"].(string); ok && q != "" {
return q
}
if job.Search != nil {
return job.Search.Query
}
return ""
}
func readURLof(job contract.JobEnvelope) string {
if job.Read != nil {
return job.Read.URL
}
return ""
}
func contentTypeOf(raw *contract.RawResult) string {
if ct, ok := raw.Extra["content_type"].(string); ok {
return ct
}
if raw.StatusCode >= 200 && raw.StatusCode < 300 {
return "text/html"
}
return ""
}