// 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: | 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 数据块:searxng 位次 → SearchResult(content ≤800 截断、 // score 归一、max_results 裁剪、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 } for i, it := range items { if i >= max { break } 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) } // ② 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 "" }