feature/chat-live-quality: live streaming bubble, markdown spacing, document-task quality #12

Merged
weichen merged 1 commits from feature/chat-live-quality into main 2026-09-30 09:59:29 +00:00
6 changed files with 182 additions and 47 deletions
+12 -5
View File
@@ -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)
}
+68
View File
@@ -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")
}
}
+55 -34
View File
@@ -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 {
+22 -2
View File
@@ -60,6 +60,7 @@ func TestParseDocx(t *testing.T) {
<w:tr><w:tc><w:p><w:r><w:t>项</w:t></w:r></w:p></w:tc><w:tc><w:p><w:r><w:t>值</w:t></w:r></w:p></w:tc></w:tr>
<w:tr><w:tc><w:p><w:r><w:t>版本</w:t></w:r></w:p></w:tc><w:tc><w:p><w:r><w:t>1.0</w:t></w:r></w:p></w:tc></w:tr>
</w:tbl>
<w:p><w:r><w:t>表格之后的段落。</w:t></w:r></w:p>
</w:body>
</w:document>`
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)
}
+18 -4
View File
@@ -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())
}
+7 -2
View File
@@ -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