Files
Agent-System-Backend/internal/ingest/ingest.go
T
weichen 0d74ec94dd Implement the backend: store/bus/runmgr core
Details (original message):
Implement the backend: store/bus/runmgr core, mock and LLM runners (agent-native client), HITL three-branch routing, subagent runs, builtin and declarative HTTP tools, ingest parsers (md/txt/docx/pdf-text), knowledge fulltext, layered settings, profiles, plugin host, REST+SSE API, single-binary entrypoint; e2e and unit tests.
2026-09-29 17:53:24 +00:00

314 lines
8.2 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 ingest 实现文档摄取与解析(4.11):上传/工件 → ParsedDocument IR。
// 主路线为内置代码解析器(确定性、纯 Go、可独立测试);MinerU 深度解析与
// agent 驱动解析为远期档位(ParserProvider 注册位已留)。
package ingest
import (
"archive/zip"
"bytes"
"encoding/xml"
"fmt"
"io"
"path/filepath"
"strings"
"time"
pdflib "github.com/ledongthuc/pdf"
core "agent-system-core"
)
// Parse 按扩展名分派内置解析器;无法解析时返回带 warning 的空文档(显式失败,不静默)。
func Parse(filename, mime string, content []byte) core.ParsedDocument {
doc := core.ParsedDocument{Meta: core.DocMeta{
Filename: filename, MIME: mime, Parser: "builtin", ParsedAt: time.Now().UTC(),
}}
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(filename), "."))
switch ext {
case "md", "markdown":
doc.Meta.Parser = "builtin:md"
doc.Blocks = parseMarkdown(string(content))
doc.Meta.Confidence = 1.0
case "txt", "text", "log", "csv":
doc.Meta.Parser = "builtin:txt"
doc.Blocks = parsePlain(string(content))
doc.Meta.Confidence = 1.0
case "docx":
doc.Meta.Parser = "builtin:docx"
blocks, err := parseDocx(content)
if err != nil {
doc.Warnings = append(doc.Warnings, "docx 解析失败:"+err.Error())
doc.Meta.Confidence = 0.3
} else {
doc.Blocks = blocks
doc.Meta.Confidence = 0.92
}
case "pdf":
doc.Meta.Parser = "builtin:pdf"
blocks, err := parsePDF(content)
if err != nil {
doc.Warnings = append(doc.Warnings, "pdf 文本层解析失败:"+err.Error()+"(扫描件需远期 MinerU 档)")
doc.Meta.Confidence = 0.2
} else {
doc.Blocks = blocks
doc.Meta.Confidence = 0.85
}
default:
if isImage(ext) {
doc.Warnings = append(doc.Warnings, "图片不走文本解析(OCR 为 MinerU 深度档,远期)")
} else {
// 未知类型按纯文本尝试(二进制内容会产生乱码 warning)。
text := string(content)
if !isMostlyPrintable(text) {
doc.Warnings = append(doc.Warnings, fmt.Sprintf("未知类型 .%s 且内容非文本,未解析", ext))
} else {
doc.Blocks = parsePlain(text)
doc.Meta.Confidence = 0.6
doc.Warnings = append(doc.Warnings, "未知类型按纯文本解析")
}
}
}
return doc
}
// ---- md / txt ----
func parseMarkdown(s string) []core.DocBlock {
var blocks []core.DocBlock
var para []string
flush := func() {
if len(para) > 0 {
blocks = append(blocks, core.DocBlock{Type: core.BlockPara, Text: strings.Join(para, "\n")})
para = nil
}
}
for _, line := range strings.Split(s, "\n") {
t := strings.TrimSpace(line)
switch {
case t == "":
flush()
case strings.HasPrefix(t, "#"):
flush()
level := 0
for level < len(t) && t[level] == '#' {
level++
}
blocks = append(blocks, core.DocBlock{Type: core.BlockHeading, Level: level, Text: strings.TrimSpace(t[level:])})
case strings.HasPrefix(t, "- ") || strings.HasPrefix(t, "* "):
flush()
if n := len(blocks); n > 0 && blocks[n-1].Type == core.BlockList {
blocks[n-1].Items = append(blocks[n-1].Items, strings.TrimSpace(t[2:]))
} else {
blocks = append(blocks, core.DocBlock{Type: core.BlockList, Items: []string{strings.TrimSpace(t[2:])}})
}
case strings.HasPrefix(t, "```"):
flush()
blocks = append(blocks, core.DocBlock{Type: core.BlockCode, Text: ""}) // 简化:代码块内容并入口令块
default:
para = append(para, t)
}
}
flush()
return blocks
}
func parsePlain(s string) []core.DocBlock {
var blocks []core.DocBlock
var para []string
flush := func() {
if len(para) > 0 {
blocks = append(blocks, core.DocBlock{Type: core.BlockPara, Text: strings.Join(para, "\n")})
para = nil
}
}
for _, line := range strings.Split(s, "\n") {
if strings.TrimSpace(line) == "" {
flush()
continue
}
para = append(para, strings.TrimRight(line, "\r"))
}
flush()
return blocks
}
// ---- 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 {
Val string `xml:"val,attr"`
} `xml:"pStyle"`
} `xml:"pPr"`
Runs []xrun `xml:"r"`
}
type xrun struct {
Text []struct {
Content string `xml:",chardata"`
} `xml:"t"`
Break struct {
Val string `xml:"val,attr"`
} `xml:"br"`
}
type xtbl struct {
Rows []xtr `xml:"tr"`
}
type xtr struct {
Cells []xtc `xml:"tc"`
}
type xtc struct {
Paragraphs []xpar `xml:"p"`
}
func parseDocx(content []byte) ([]core.DocBlock, error) {
zr, err := zip.NewReader(bytes.NewReader(content), int64(len(content)))
if err != nil {
return nil, fmt.Errorf("open zip: %w", err)
}
var docXML []byte
for _, f := range zr.File {
if f.Name == "word/document.xml" {
rc, err := f.Open()
if err != nil {
return nil, err
}
docXML, _ = io.ReadAll(rc)
rc.Close()
break
}
}
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)
}
var blocks []core.DocBlock
for _, p := range doc.Body.Paragraphs {
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})
}
}
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, " "))
}
rows = append(rows, row)
}
if len(rows) > 0 {
blocks = append(blocks, core.DocBlock{Type: core.BlockTable, Rows: rows})
}
}
return blocks, nil
}
func paragraphText(p xpar) string {
var sb strings.Builder
for _, r := range p.Runs {
for _, t := range r.Text {
sb.WriteString(t.Content)
}
}
return sb.String()
}
func headingLevel(style string) (int, bool) {
s := strings.ToLower(style)
switch {
case strings.Contains(s, "heading1") || s == "1":
return 1, true
case strings.Contains(s, "heading2") || s == "2":
return 2, true
case strings.Contains(s, "heading3") || s == "3" || strings.Contains(s, "title"):
return 3, true
}
return 0, false
}
// ---- pdf(文本层提取)----
func parsePDF(content []byte) ([]core.DocBlock, error) {
r, err := pdflib.NewReader(bytes.NewReader(content), int64(len(content)))
if err != nil {
return nil, err
}
var blocks []core.DocBlock
for i := 1; i <= r.NumPage(); i++ {
page := r.Page(i)
if page.V.IsNull() {
continue
}
text, err := page.GetPlainText(nil)
if err != nil {
continue
}
for _, para := range strings.Split(text, "\n\n") {
if t := strings.TrimSpace(para); t != "" {
blocks = append(blocks, core.DocBlock{Type: core.BlockPara, Text: t})
}
}
}
if len(blocks) == 0 {
return nil, fmt.Errorf("无文本层(可能是扫描件)")
}
return blocks, nil
}
// ---- 辅助 ----
func isImage(ext string) bool {
switch ext {
case "png", "jpg", "jpeg", "gif", "webp", "bmp", "svg":
return true
}
return false
}
func isMostlyPrintable(s string) bool {
if len(s) == 0 {
return true
}
nonPrint := 0
for _, r := range s {
if r == 0xFFFD || (r < 0x20 && r != '\n' && r != '\r' && r != '\t') {
nonPrint++
}
}
return nonPrint*10 < len(s)
}
// ParsersDescription 返回解析器注册表描述(来源标记;设置页/文档用)。
func ParsersDescription() []core.ParserDesc {
return []core.ParserDesc{
{ID: "builtin:md", Formats: []string{"md", "markdown"}, Source: core.SourceBuiltin},
{ID: "builtin:txt", Formats: []string{"txt", "csv", "log"}, Source: core.SourceBuiltin},
{ID: "builtin:docx", Formats: []string{"docx"}, Source: core.SourceBuiltin},
{ID: "builtin:pdf", Formats: []string{"pdf"}, Source: core.SourceBuiltin, Note: "文本层;扫描件为远期 MinerU 深度档"},
{ID: "mineru", Formats: []string{"pdf", "png", "jpg"}, Source: core.SourcePlugin, Note: "远期可选:深度解析与交叉验证(默认不部署)"},
}
}