Merge pull request 'feature/chat-live-quality: live streaming bubble, markdown spacing, document-task quality' (#12) from feature/chat-live-quality into main
This commit was merged in pull request #12.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user