Files
xinmi/internal/workbench/manuscript.go
T
yawan f7fc992f57
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
2026-09-22 16:19:06 +08:00

168 lines
6.6 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 workbench
import (
"context"
"fmt"
"strings"
"github.com/Tencent/WeKnora/internal/accesspolicy"
"github.com/Tencent/WeKnora/internal/agent/token"
"github.com/Tencent/WeKnora/internal/models/chat"
"github.com/Tencent/WeKnora/internal/types"
)
const qualitySystem = `你是内置跨稿质检员。审查已采用正文的逻辑、先修依赖、衔接、重复和论据一致性。严重逻辑断裂或重复为 blocking,措辞建议为 suggestion。不得编造问题或审查范围。严格输出 JSON {"passed":true,"summary":"中文总结","issues":[{"segment_ids":["实际段落编号"],"severity":"blocking 或 suggestion","problem":"具体问题","suggestion":"具体修改建议"}]}。无 blocking 时 passed 必须为 true,否则 false。`
func manuscriptPrompt(segments []Segment, advice string) string {
return "本次仅审查以下实际采用的正文。用户可以主动跳过内容段,编号不连续或段数少于原计划本身不是缺陷,不得要求恢复已跳过段;若确有未完成承诺,应建议在已采用段内补足或修正预告。\n已采用正文:" + compact(segments) + "\n用户补充意见:" + advice
}
// Every accepted segment is reviewed in full, and adjacent windows overlap by
// one complete segment. A single segment that cannot fit still fails explicitly.
func manuscriptWindows(c *completionRequest, segments []Segment, advice string) ([][]Segment, error) {
fits := func(rows []Segment) bool {
messages := append(c.messages[:0:0], c.messages...)
messages[1].Content = manuscriptPrompt(rows, advice)
return c.estimator.EstimateMessages(messages) <= c.inputLimit
}
if fits(segments) {
return [][]Segment{segments}, nil
}
windows := [][]Segment{}
current := []Segment{}
for _, segment := range segments {
if !fits([]Segment{segment}) {
return nil, fmt.Errorf("%w: 模型上下文容量不足以容纳单个内容段,请为质检选择更大上下文模型或拆分该段", ErrInvalid)
}
if len(current) > 0 && !fits(append(append([]Segment(nil), current...), segment)) {
windows = append(windows, current)
current = []Segment{current[len(current)-1]}
if !fits(append(current, segment)) {
return nil, fmt.Errorf("%w: 相邻正文的上下文容量不足,无法完整检查衔接,请选择更大上下文模型", ErrInvalid)
}
}
current = append(current, segment)
}
if len(current) > 0 {
windows = append(windows, current)
}
return windows, nil
}
func mergeQualityReports(reports []QualityReport, accepted []Segment) (*QualityReport, error) {
merged := &QualityReport{Passed: true, Issues: []QualityIssue{}}
seen := map[string]bool{}
for i, report := range reports {
merged.Summary += fmt.Sprintf("检查 %d:%s\n", i+1, report.Summary)
for _, issue := range report.Issues {
key := compact(issue)
if !seen[key] {
seen[key] = true
merged.Issues = append(merged.Issues, issue)
}
if issue.Severity == "blocking" {
merged.Passed = false
}
}
}
if err := validateQuality(merged, accepted); err != nil {
return nil, err
}
return merged, nil
}
func (s *Service) prepareQualityCompletion(ctx context.Context, agentID string) (*completionRequest, error) {
if !accesspolicy.Allowed(ctx, "content.review") {
return nil, ErrForbidden
}
snapshot, frozen := snapshotFromContext(ctx, agentID)
if !frozen || snapshot.ModelID == "" {
return s.prepareCompletion(ctx, agentID, qualitySystem, "")
}
// Use the task's recorded model for review, without loading private skills,
// persona, memory, or knowledge that the reviewer cannot otherwise access.
metadata, err := s.models.GetModelByID(ctx, snapshot.ModelID)
if err != nil {
return nil, err
}
if metadata == nil {
return nil, fmt.Errorf("%w: 任务的质检模型已不可用,请更新任务配置", ErrInvalid)
}
estimator, err := token.NewEstimator()
if err != nil {
return nil, err
}
contextSize := types.AgentMaxContextTokens(0, metadata.Parameters.ContextWindow)
if snapshot.ContextWindow > 0 {
contextSize = min(contextSize, snapshot.ContextWindow)
}
outputLimit := snapshot.OutputLimit
if outputLimit <= 0 {
outputLimit = types.DefaultMaxCompletionTokens(types.AgentModeQuickAnswer, "")
}
if metadata.Parameters.MaxOutputTokens > 0 {
outputLimit = min(outputLimit, metadata.Parameters.MaxOutputTokens)
}
agent := &types.CustomAgent{ID: agentID, Config: types.CustomAgentConfig{ModelID: snapshot.ModelID, Temperature: snapshot.Temperature, Thinking: snapshot.Thinking}}
c := &completionRequest{agent: agent, estimator: estimator, contextSize: contextSize,
inputLimit: contextSize - contextSize*15/100 - outputLimit, outputLimit: outputLimit,
messages: []chat.Message{{Role: "system", Content: qualitySystem}, {Role: "user"}}, jsonOutput: true}
return c, c.validateBudget()
}
func (s *Service) reviewManuscript(ctx context.Context, agentID string, accepted []Segment, advice string) (*QualityReport, error) {
c, err := s.prepareQualityCompletion(ctx, agentID)
if err != nil {
return nil, err
}
windows, err := manuscriptWindows(c, accepted, advice)
if err != nil {
return nil, err
}
reports := []QualityReport{}
for i, window := range windows {
if lease, ok := ctx.Value(leaseContextKey{}).(jobLease); ok {
if err := s.progress(ctx, lease.ID, 40+40*i/len(windows), fmt.Sprintf("跨稿质检:第 %d/%d 组,完整检查正文及相邻衔接", i+1, len(windows))); err != nil {
return nil, err
}
}
c.messages[1].Content = manuscriptPrompt(window, advice)
c.jsonOutput = true
text, err := s.runCompletion(ctx, c)
if err != nil {
return nil, err
}
report, err := parseJSON[QualityReport](text)
if err != nil {
return nil, err
}
if err := validateQuality(&report, window); err != nil {
return nil, err
}
reports = append(reports, report)
}
report, err := mergeQualityReports(reports, accepted)
if err != nil {
return nil, err
}
if len(windows) > 1 {
report.Summary = fmt.Sprintf("共 %d 组,全部已采用正文逐组完整质检,相邻组保留完整衔接段;远距离语义一致性仍需结合整稿复核。\n", len(windows)) + report.Summary
}
return report, nil
}
func composeAccepted(accepted []Segment) string {
parts := make([]string, 0, len(accepted))
for _, segment := range accepted {
parts = append(parts, segment.Content)
}
return strings.Join(parts, "\n\n")
}
func (s *Service) composeManuscript(ctx context.Context, agentID string, accepted []Segment, advice string) (string, error) {
if strings.TrimSpace(advice) != "" {
return "", fmt.Errorf("%w: 组合成稿保留原文;请先在对应内容段应用修改建议并重新质检", ErrInvalid)
}
return composeAccepted(accepted), nil
}