onesvm-browser-server/server/internal/scheduler/template.go
chii eb972dfa93 feat: 落地 browser-server 控制面并打通 mgr1 海外订阅
单二进制三角色 + Dock 适配器 + Swarm stack 达到可部署态;mgr1 实测订阅经 central-proxy bootstrap,探活 alive=41/52。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 15:05:12 +08:00

295 lines
8.4 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 数据块: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 ""
}