From fe4887be185600c218bdd8a3f4dae7ae24aac758 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 30 Sep 2026 09:57:36 +0000 Subject: [PATCH] Lift document-task quality: attachment budget, prompts, docx order Why: attachments were hard-truncated at 16k chars (most of a meeting transcript silently dropped), injection carried no task guidance, the builtin profile prompt was one generic line, subagents were capped at 300 chars, and docx tables were appended after all paragraphs losing document order - together these produced sparse meeting minutes. Evidence: go test ./... 16 packages ok incl. new ctxasm limit tests and docx order assertions (heading->para->table->para); composite make test green over the pinned combo. Impact: attachment ceiling is 48k chars (settings context.maxAttachmentChars), truncated on rune boundaries and observable in the context report; the builtin prompt demands full-coverage structured markdown for document tasks and upgrades stale builtin rows on boot; budget.maxTokens now reaches the provider; subagents may write full-length results; docx tables stay in place. Refs: feature/chat-live-quality task card. --- internal/ctxasm/ctxasm.go | 17 +++++-- internal/ctxasm/ctxasm_test.go | 68 ++++++++++++++++++++++++++ internal/ingest/ingest.go | 89 +++++++++++++++++++++------------- internal/ingest/ingest_test.go | 24 ++++++++- internal/profiles/profiles.go | 22 +++++++-- internal/runtime/llm.go | 9 +++- 6 files changed, 182 insertions(+), 47 deletions(-) create mode 100644 internal/ctxasm/ctxasm_test.go diff --git a/internal/ctxasm/ctxasm.go b/internal/ctxasm/ctxasm.go index eb33a65..6b8b987 100644 --- a/internal/ctxasm/ctxasm.go +++ b/internal/ctxasm/ctxasm.go @@ -16,6 +16,10 @@ type Report struct { EstChars int `json:"estChars"` } +// DefaultMaxAttachmentChars 单个附件注入模型的默认字符上限(settings context.maxAttachmentChars 可调)。 +// 16k 时代码值对会议纪要类长文档明显不足(正文大半被静默丢弃);48k ≈ 主流 128k 上下文的安全占比。 +const DefaultMaxAttachmentChars = 48000 + // AssembleInput 组装模型输入消息。 func AssembleInput(prof core.AgentProfile, eff core.EffectiveSettings, history []core.Message, currentUser core.Message, attachments []core.ParsedDocument, knowledge []core.RetrievalHit) ([]agentllm.Message, *Report) { rep := &Report{} @@ -59,13 +63,16 @@ func AssembleInput(prof core.AgentProfile, eff core.EffectiveSettings, history [ } } if len(attachments) > 0 { - sb.WriteString("\n\n[附件]\n") + limit := int(eff.GetNumber("context.maxAttachmentChars", DefaultMaxAttachmentChars)) + if limit <= 0 { + limit = DefaultMaxAttachmentChars + } + sb.WriteString("\n\n[附件](用户上传文档的解析全文,按原文顺序保留标题/列表/表格;超长时文末标注截断。请依据文档内容完成任务,输出完整不省略)\n") for _, doc := range attachments { text := doc.PlainText() - limit := 16000 - if len(text) > limit { - text = text[:limit] + "…(附件超长截断)" - rep.Truncations = append(rep.Truncations, "附件 "+doc.Meta.Filename+" 超长截断") + if runes := []rune(text); len(runes) > limit { + text = string(runes[:limit]) + "…(附件超长截断)" + rep.Truncations = append(rep.Truncations, fmt.Sprintf("附件 %s 超 %d 字符截断(原文 %d 字符)", doc.Meta.Filename, limit, len(runes))) } fmt.Fprintf(&sb, "### %s\n%s\n", doc.Meta.Filename, text) } diff --git a/internal/ctxasm/ctxasm_test.go b/internal/ctxasm/ctxasm_test.go new file mode 100644 index 0000000..90542be --- /dev/null +++ b/internal/ctxasm/ctxasm_test.go @@ -0,0 +1,68 @@ +package ctxasm + +import ( + "strings" + "testing" + + core "agent-system-core" +) + +func fakeUser(text string) core.Message { + return core.Message{ID: "m1", Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: text}}} +} + +func TestAssembleInputAttachmentLimit(t *testing.T) { + prof := core.AgentProfile{SystemPrompt: "s"} + long := strings.Repeat("会", 60000) // 60k 字符 > 默认 48k 上限 + doc := core.ParsedDocument{ + Meta: core.DocMeta{Filename: "纪要.docx"}, + Blocks: []core.DocBlock{{Type: core.BlockPara, Text: long}}, + } + msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, nil, fakeUser("请总结"), []core.ParsedDocument{doc}, nil) + + last := msgs[len(msgs)-1] + if !strings.Contains(last.Content, "纪要.docx") { + t.Fatal("attachment filename missing from model input") + } + if got := strings.Count(last.Content, "会"); got != DefaultMaxAttachmentChars { + t.Errorf("injected chars = %d, want %d (default limit)", got, DefaultMaxAttachmentChars) + } + if !strings.Contains(last.Content, "附件超长截断") { + t.Error("truncation marker missing in model input") + } + if len(rep.Truncations) != 1 || !strings.Contains(rep.Truncations[0], "纪要.docx") { + t.Errorf("truncation not observable in report: %v", rep.Truncations) + } +} + +func TestAssembleInputAttachmentLimitConfigurable(t *testing.T) { + prof := core.AgentProfile{SystemPrompt: "s"} + doc := core.ParsedDocument{ + Meta: core.DocMeta{Filename: "a.txt"}, + Blocks: []core.DocBlock{{Type: core.BlockPara, Text: strings.Repeat("甲", 120)}}, + } + eff := core.EffectiveSettings{"context": map[string]any{"maxAttachmentChars": float64(100)}} + msgs, rep := AssembleInput(prof, eff, nil, fakeUser("总结"), []core.ParsedDocument{doc}, nil) + last := msgs[len(msgs)-1] + if got := strings.Count(last.Content, "甲"); got != 100 { + t.Errorf("injected chars = %d, want 100 (configured limit)", got) + } + if len(rep.Truncations) != 1 { + t.Errorf("expected truncation record, got %v", rep.Truncations) + } +} + +func TestAssembleInputAttachmentNoTruncationUnderLimit(t *testing.T) { + prof := core.AgentProfile{SystemPrompt: "s"} + doc := core.ParsedDocument{ + Meta: core.DocMeta{Filename: "b.txt"}, + Blocks: []core.DocBlock{{Type: core.BlockPara, Text: strings.Repeat("乙", 100)}}, + } + msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, nil, fakeUser("总结"), []core.ParsedDocument{doc}, nil) + if len(rep.Truncations) != 0 { + t.Errorf("under-limit doc should not truncate: %v", rep.Truncations) + } + if !strings.Contains(msgs[len(msgs)-1].Content, strings.Repeat("乙", 100)) { + t.Error("full text should be injected") + } +} diff --git a/internal/ingest/ingest.go b/internal/ingest/ingest.go index 0a3c2ae..cb1d74a 100644 --- a/internal/ingest/ingest.go +++ b/internal/ingest/ingest.go @@ -133,13 +133,6 @@ func parsePlain(s string) []core.DocBlock { // ---- docx(OOXML:zip 内 word/document.xml)---- -type xdoc struct { - Body struct { - Paragraphs []xpar `xml:"p"` - Tables []xtbl `xml:"tbl"` - } `xml:"body"` -} - type xpar struct { Props struct { Style struct { @@ -190,42 +183,70 @@ func parseDocx(content []byte) ([]core.DocBlock, error) { if docXML == nil { return nil, fmt.Errorf("word/document.xml not found") } - var doc xdoc - if err := xml.Unmarshal(docXML, &doc); err != nil { - return nil, fmt.Errorf("unmarshal document.xml: %w", err) - } + return parseDocxBody(docXML) +} + +// parseDocxBody 按文档顺序解码 body 的段落与表格:token 流上遇到 p/tbl 就地 +// DecodeElement(整元素消费,单元格内嵌 p 不会重复入列),表格与前后段落保持原位。 +func parseDocxBody(docXML []byte) ([]core.DocBlock, error) { + dec := xml.NewDecoder(bytes.NewReader(docXML)) var blocks []core.DocBlock - for _, p := range doc.Body.Paragraphs { - text := paragraphText(p) - if strings.TrimSpace(text) == "" { + for { + tok, err := dec.Token() + if err == io.EOF { + break + } + if err != nil { + return nil, fmt.Errorf("decode document.xml: %w", err) + } + start, ok := tok.(xml.StartElement) + if !ok { continue } - if lvl, ok := headingLevel(p.Props.Style.Val); ok { - blocks = append(blocks, core.DocBlock{Type: core.BlockHeading, Level: lvl, Text: text}) - } else { - blocks = append(blocks, core.DocBlock{Type: core.BlockPara, Text: text}) - } - } - for _, tbl := range doc.Body.Tables { - var rows [][]string - for _, tr := range tbl.Rows { - row := []string{} - for _, tc := range tr.Cells { - cell := []string{} - for _, p := range tc.Paragraphs { - cell = append(cell, paragraphText(p)) - } - row = append(row, strings.Join(cell, " ")) + switch start.Name.Local { + case "p": + var p xpar + if err := dec.DecodeElement(&p, &start); err != nil { + return nil, fmt.Errorf("decode paragraph: %w", err) + } + text := paragraphText(p) + if strings.TrimSpace(text) == "" { + continue + } + if lvl, ok := headingLevel(p.Props.Style.Val); ok { + blocks = append(blocks, core.DocBlock{Type: core.BlockHeading, Level: lvl, Text: text}) + } else { + blocks = append(blocks, core.DocBlock{Type: core.BlockPara, Text: text}) + } + case "tbl": + var tbl xtbl + if err := dec.DecodeElement(&tbl, &start); err != nil { + return nil, fmt.Errorf("decode table: %w", err) + } + if rows := tableRows(tbl); len(rows) > 0 { + blocks = append(blocks, core.DocBlock{Type: core.BlockTable, Rows: rows}) } - rows = append(rows, row) - } - if len(rows) > 0 { - blocks = append(blocks, core.DocBlock{Type: core.BlockTable, Rows: rows}) } } return blocks, nil } +func tableRows(tbl xtbl) [][]string { + var rows [][]string + for _, tr := range tbl.Rows { + row := []string{} + for _, tc := range tr.Cells { + cell := []string{} + for _, p := range tc.Paragraphs { + cell = append(cell, paragraphText(p)) + } + row = append(row, strings.Join(cell, " ")) + } + rows = append(rows, row) + } + return rows +} + func paragraphText(p xpar) string { var sb strings.Builder for _, r := range p.Runs { diff --git a/internal/ingest/ingest_test.go b/internal/ingest/ingest_test.go index 79d6bfa..52ee616 100644 --- a/internal/ingest/ingest_test.go +++ b/internal/ingest/ingest_test.go @@ -60,6 +60,7 @@ func TestParseDocx(t *testing.T) { 项值 版本1.0 + 表格之后的段落。 ` if f, err := zw.Create("word/document.xml"); err == nil { @@ -73,17 +74,36 @@ func TestParseDocx(t *testing.T) { if doc.Meta.Parser != "builtin:docx" || len(doc.Warnings) != 0 { t.Fatalf("meta: %+v warnings: %v", doc.Meta, doc.Warnings) } - var heading, para, table bool + var heading, para, table, afterTable bool for _, b := range doc.Blocks { switch b.Type { case core.BlockHeading: heading = b.Level == 1 && b.Text == "总则" case core.BlockPara: - para = strings.Contains(b.Text, "医疗器械") + para = para || strings.Contains(b.Text, "医疗器械") + afterTable = afterTable || strings.Contains(b.Text, "表格之后的段落") case core.BlockTable: table = len(b.Rows) == 2 && b.Rows[1][1] == "1.0" } } + if !afterTable { + t.Error("paragraph after table missing (table must not reorder content)") + } + // 文档顺序:标题 → 段落 → 表格 → 表后段落(表格原位,不后置)。 + var seq []core.DocBlockType + for _, b := range doc.Blocks { + seq = append(seq, b.Type) + } + want := []core.DocBlockType{core.BlockHeading, core.BlockPara, core.BlockTable, core.BlockPara} + if len(seq) != len(want) { + t.Fatalf("block count: got %v want %v", seq, want) + } + for i := range want { + if seq[i] != want[i] { + t.Errorf("block order: got %v want %v", seq, want) + break + } + } if !heading || !para || !table { t.Errorf("docx blocks wrong: heading=%v para=%v table=%v blocks=%+v", heading, para, table, doc.Blocks) } diff --git a/internal/profiles/profiles.go b/internal/profiles/profiles.go index b15154d..286162f 100644 --- a/internal/profiles/profiles.go +++ b/internal/profiles/profiles.go @@ -2,6 +2,8 @@ package profiles import ( + "reflect" + core "agent-system-core" "agent-system-backend/internal/store" ) @@ -11,6 +13,15 @@ const OwnerDefault = store.OwnerDefault const DefaultProfileID = "default" +// defaultSystemPrompt 默认画像系统提示:通用基线 + 文档任务输出要求。 +// 后者是纪要/总结质量的直接杠杆——不做输出要求时模型默认给"简短摘要"。 +const defaultSystemPrompt = `你是运行在 Agent-System 中的通用智能体助手。回答准确、结构清晰;需要外部信息时优先使用可用工具;产出文档时写入工件。 + +依据用户上传文档执行任务(总结、会议纪要、问答、改写等)时: +- 以文档全文为依据,覆盖其中的关键信息与细节,不遗漏要点,不臆造文档之外的内容; +- 输出用 Markdown 结构化排版(标题、要点列表、表格),内容完整详实;除非用户明确要求压缩篇幅,不要自行省略; +- 决议、待办、责任人与时间等逐项列出,重要结论可注明依据的文档位置。` + func DefaultProfile() core.AgentProfile { maxTools := 40 maxTurns := 16 @@ -20,7 +31,7 @@ func DefaultProfile() core.AgentProfile { Description: "内置默认画像:全部工具可用,HTTP 工具执行前确认(T0 基线)。", Builtin: true, Source: "builtin", - SystemPrompt: "你是一个通用智能体助手,运行在 Agent-System 中。回答准确、简洁;需要外部信息时优先使用可用工具;产出文档时写入工件。", + SystemPrompt: defaultSystemPrompt, Model: core.ModelRoute{}, Tools: core.ToolPolicy{Allow: []string{"*"}, Confirm: []string{"http."}}, Context: core.ContextPolicy{MaxHistoryMessages: 40, MaxToolResultChars: 20000}, @@ -33,10 +44,13 @@ type Service struct{ st *store.Store } func New(st *store.Store) *Service { return &Service{st: st} } -// EnsureDefault 幂等写入内置画像。 +// EnsureDefault 幂等写入内置画像;内置画像只读(Save 拒绝编辑),库中与内置定义 +// 有差异的行只可能来自更早版本的内置定义,因此差异时升级为当前内置定义。 func (s *Service) EnsureDefault() error { - if _, err := s.st.GetProfile(OwnerDefault, DefaultProfileID); err == nil { - return nil + if cur, err := s.st.GetProfile(OwnerDefault, DefaultProfileID); err == nil { + if reflect.DeepEqual(cur, DefaultProfile()) { + return nil + } } return s.st.SaveProfile(DefaultProfile()) } diff --git a/internal/runtime/llm.go b/internal/runtime/llm.go index 220ab61..2b36692 100644 --- a/internal/runtime/llm.go +++ b/internal/runtime/llm.go @@ -85,6 +85,11 @@ func (l *LLMRunner) Execute(rc RunCtx) error { if prof.Budget.MaxToolCalls != nil { maxTools = *prof.Budget.MaxToolCalls } + // 输出预算:画像 budget.maxTokens 配置时下发(0 = 走服务端默认,不限制输出长度)。 + streamOpts := agentllm.StreamOptions{} + if prof.Budget.MaxTokens != nil && *prof.Budget.MaxTokens > 0 { + streamOpts.MaxTokens = *prof.Budget.MaxTokens + } toolCallsMade := 0 thinkingIdx, textIdx := 0, 0 @@ -98,7 +103,7 @@ func (l *LLMRunner) Execute(rc RunCtx) error { var turnThinking, turnText strings.Builder var calls []agentllm.ToolCall var usage agentllm.Usage - ev := client.Stream(rc.Context(), route.Model, msgs, decls, agentllm.StreamOptions{}) + ev := client.Stream(rc.Context(), route.Model, msgs, decls, streamOpts) phaseEmitted := false for e := range ev { switch e.Kind { @@ -249,7 +254,7 @@ func (l *SubLLMRunner) Execute(rc RunCtx) error { rc.Emit(core.EvAgentProgress, core.AgentProgressPayload{AgentRun: rc.Run().ID, Note: "执行子任务……", Phase: core.PhaseActing}) client := &agentllm.Client{BaseURL: route.Base, APIKey: route.Key} msgs := []agentllm.Message{ - {Role: agentllm.RoleSystem, Content: "你是被派生的子代理。独立完成给定子任务,输出简明结果摘要(不超过 300 字)。"}, + {Role: agentllm.RoleSystem, Content: "你是被派生的子代理。独立完成给定子任务,输出完整、结构化的结果;需要分点时分条列出,不要为了简短而省略关键内容。"}, {Role: agentllm.RoleUser, Content: rc.Subtask()}, } var sb strings.Builder