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