单二进制三角色 + Dock 适配器 + Swarm stack 达到可部署态;mgr1 实测订阅经 central-proxy bootstrap,探活 alive=41/52。 Co-authored-by: Cursor <cursoragent@cursor.com>
295 lines
8.4 KiB
Go
295 lines
8.4 KiB
Go
// 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 数据块: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 ""
|
||
}
|