testFlow/api/doc/doc_processor_demand.go
Wyle.Gong-巩文昕 67b0ad2723 init
2025-04-22 16:42:48 +08:00

761 lines
24 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 doc
import (
"app/cfg"
M "app/models"
"bytes"
"context"
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/ledongthuc/pdf"
"github.com/openai/openai-go"
"github.com/openai/openai-go/option"
"github.com/xeipuuv/gojsonschema"
"github.com/unidoc/unioffice/document"
)
// DemandTree 表示一个完整的需求树
type DemandTree struct {
Demands []DemandNode `json:"demands" jsonschema:"required,description=需求树列表"`
HasMore bool `json:"has_more" jsonschema:"required,description=是否还有更多需求需要继续在下一次输出中继续输出"`
AnalysisPercent int `json:"analysis_percent" jsonschema:"required,description=估计的分析进度百分比范围是0-100不一定递增可以调整比如发现增长过快的时候可以减少只有全部解析完毕才可以到100"`
}
type DemandNode struct {
ReqID string `json:"id" jsonschema:"required,description=需求ID格式为REQ-数字"`
Title string `json:"title" jsonschema:"required,description=需求标题"`
Description string `json:"description" jsonschema:"required,description=需求详细描述"`
Priority string `json:"priority" jsonschema:"enum=高,enum=中,enum=低,description=需求优先级"`
Type string `json:"type" jsonschema:"enum=功能需求,enum=非功能需求,enum=业务需求,description=需求类型"`
Status string `json:"status" jsonschema:"enum=待实现,enum=开发中,enum=已完成,enum=已验收,description=需求状态"`
ParentReqID string `json:"parent_req_id,omitempty" jsonschema:"description=父需求ID如果是顶级需求则为空"`
Children []DemandNode `json:"children,omitempty" jsonschema:"description=子需求列表"`
}
// 同样修改 SimpleDemandNode
type SimpleDemandNode struct {
ReqID string `json:"id" jsonschema:"required,description=需求ID格式为REQ-数字"`
Title string `json:"title" jsonschema:"required,description=需求标题"`
Description string `json:"description" jsonschema:"required,description=需求详细描述"`
Priority string `json:"priority" jsonschema:"enum=高,enum=中,enum=低,description=需求优先级"`
Type string `json:"type" jsonschema:"enum=功能需求,enum=非功能需求,enum=业务需求,description=需求类型"`
Status string `json:"status" jsonschema:"enum=待实现,enum=开发中,enum=已完成,enum=已验收,description=需求状态"`
ParentReqID string `json:"parent_req_id,omitempty" jsonschema:"description=父需求ID如果是顶级需求则为空"`
Children []map[string]string `json:"children,omitempty" jsonschema:"description=子需求列表"`
}
// 简化的需求树结构,用于生成 Schema
type SimpleDemandTree struct {
Demands []SimpleDemandNode `json:"demands" jsonschema:"required,description=需求树列表"`
HasMore bool `json:"has_more" jsonschema:"required,description=是否还有更多需求需要继续在下一次输出中继续输出"`
AnalysisPercent int `json:"analysis_percent" jsonschema:"required,description=估计的分析进度百分比范围是0-100不一定递增可以调整比如发现增长过快的时候可以减少只有全部解析完毕才可以到100"`
}
// DemandChatHistory 表示与大模型的对话历史
type DemandChatHistory struct {
Messages []openai.ChatCompletionMessageParamUnion
DocContent string
Schema string
}
// DemandAnalyzer 处理需求文档分析
type DemandAnalyzer struct {
client *openai.Client
logger *log.Logger
}
// NewDemandAnalyzer 创建一个新的需求分析器实例
func NewDemandAnalyzer() (*DemandAnalyzer, error) {
client := openai.NewClient(
option.WithAPIKey("sk-0213c70194624703a1d0d80e0f762b0e"),
option.WithBaseURL("https://dashscope.aliyuncs.com/compatible-mode/v1/"),
)
fmt.Println("需求分析器初始化成功")
return &DemandAnalyzer{
client: client,
logger: setupLogger(),
}, nil
}
// generateDemandJSONSchema 生成需求树的JSON Schema
// func generateDemandJSONSchema() ([]byte, error) {
// reflector := jsonschema.Reflector{
// RequiredFromJSONSchemaTags: true,
// AllowAdditionalProperties: true,
// DoNotReference: true,
// }
// schema := reflector.Reflect(&SimpleDemandTree{})
// // schema := reflector.Reflect(&DemandTree{})
// return json.MarshalIndent(schema, "", " ")
// }
func generateDemandJSONSchema() ([]byte, error) {
// 使用预定义的 JSON Schema 字符串,手动处理递归引用
schemaStr := `{
"type": "object",
"properties": {
"demands": {
"type": "array",
"description": "需求树列表",
"items": {
"$ref": "#/definitions/demandNode"
}
},
"has_more": {
"type": "boolean",
"description": "是否还有更多需求需要继续在下一次输出中继续输出"
},
"analysis_percent": {
"type": "integer",
"description": "估计的分析进度百分比范围是0-100不一定递增可以调整比如发现增长过快的时候可以减少只有全部解析完毕才可以到100"
}
},
"required": ["demands", "has_more", "analysis_percent"],
"definitions": {
"demandNode": {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "需求ID格式为REQ-数字"
},
"title": {
"type": "string",
"description": "需求标题"
},
"description": {
"type": "string",
"description": "需求详细描述"
},
"priority": {
"type": "string",
"enum": ["高", "中", "低"],
"description": "需求优先级"
},
"type": {
"type": "string",
"enum": ["功能需求","性能需求","安全需求", "合规性需求", "可靠性需求"],
"description": "需求类型"
},
"status": {
"type": "string",
"enum": ["待实现", "开发中", "已完成", "已测试"],
"description": "需求状态"
},
"parent_req_id": {
"type": "string",
"description": "父需求ID如果是顶级需求则为空"
},
"children": {
"type": "array",
"description": "子需求列表每个子需求也是一个demandNode而且子需求也可能有自己的子需求列表以此类推",
"items": {
"$ref": "#/definitions/demandNode"
}
}
},
"required": ["id", "title", "description"]
}
}
}`
// 解析 JSON 以确保格式正确
var schema interface{}
if err := json.Unmarshal([]byte(schemaStr), &schema); err != nil {
return nil, fmt.Errorf("解析预定义 Schema 失败: %v", err)
}
// 重新格式化为美观的 JSON
return json.MarshalIndent(schema, "", " ")
}
// validateDemandJSON 验证JSON响应是否符合Schema
func validateDemandJSON(data []byte) error {
schema, err := generateDemandJSONSchema()
if err != nil {
return fmt.Errorf("生成Schema失败: %v", err)
}
schemaLoader := gojsonschema.NewBytesLoader(schema)
documentLoader := gojsonschema.NewBytesLoader(data)
result, err := gojsonschema.Validate(schemaLoader, documentLoader)
if err != nil {
return fmt.Errorf("验证错误: %v", err)
}
if !result.Valid() {
var errors []string
for _, desc := range result.Errors() {
errors = append(errors, desc.String())
}
return fmt.Errorf("无效的JSON: %v", errors)
}
return nil
}
// cleanDemandJSONResponse 清理并验证LLM响应
func cleanDemandJSONResponse(response string) (string, error) {
// 查找第一个 { 和最后一个 }
start := 0
end := len(response)
for i := 0; i < len(response); i++ {
if response[i] == '{' {
start = i
break
}
}
for i := len(response) - 1; i >= 0; i-- {
if response[i] == '}' {
end = i + 1
break
}
}
if start >= end {
return "", fmt.Errorf("无效的JSON结构")
}
jsonStr := response[start:end]
// 验证JSON
if err := validateDemandJSON([]byte(jsonStr)); err != nil {
return "", fmt.Errorf("JSON验证失败: %v", err)
}
return jsonStr, nil
}
// repairDemandJSON 修复格式错误的JSON
// 修改 repairDemandJSON 方法
func (a *DemandAnalyzer) repairDemandJSON(ctx context.Context, response string, originalErr error) (string, error) {
prompt := fmt.Sprintf(`你是一个JSON修复专家。我有一个JSON字符串但它存在一些问题无法解析。
原始JSON:
%s
错误信息:
%v
请修复这个JSON使其符合以下要求
1. 所有字段名必须使用双引号
2. 字符串值必须使用双引号
3. 不要添加或删除字段,只修复格式问题
4. 如果parent_req_id是null替换为空字符串""
5. 确保demands是一个数组即使为空也应为[]
6. 确保has_more是一个布尔值
7. 结果必须是有效的JSON
请只返回修复后的JSON不要包含任何其他解释或评论。`, response, originalErr)
stream := a.client.Chat.Completions.NewStreaming(ctx, openai.ChatCompletionNewParams{
Messages: openai.F([]openai.ChatCompletionMessageParamUnion{
openai.UserMessage(prompt),
}),
Model: openai.F("qwen-plus"),
})
var responseBuilder strings.Builder
for stream.Next() {
chunk := stream.Current()
for _, choice := range chunk.Choices {
responseBuilder.WriteString(choice.Delta.Content)
}
}
if err := stream.Err(); err != nil {
return "", fmt.Errorf("JSON修复API调用失败: %v", err)
}
fixed := responseBuilder.String()
// 关键修复移除可能的Markdown代码块标记
fixed = strings.TrimSpace(fixed)
// 检查并移除开头的Markdown标记
if strings.HasPrefix(fixed, "```") {
// 找到第一行结束位置
firstLineEnd := strings.Index(fixed, "\n")
if firstLineEnd != -1 {
// 跳过第一行(包含```json或```
fixed = fixed[firstLineEnd+1:]
} else {
// 如果没有换行,可能整个字符串都是标记,返回错误
return "", fmt.Errorf("修复后的JSON格式异常")
}
}
// 检查并移除结尾的Markdown标记
if strings.HasSuffix(fixed, "```") {
fixed = fixed[:len(fixed)-3]
}
// 再次去除前后空白
fixed = strings.TrimSpace(fixed)
// 验证修复后的JSON是否有效
var testObj map[string]interface{}
if err := json.Unmarshal([]byte(fixed), &testObj); err != nil {
return "", fmt.Errorf("修复后的JSON仍然无效: %v", err)
}
return fixed, nil
}
// func (da *DemandAnalyzer) repairDemandJSON(ctx context.Context, malformedJSON string, originalError error) (string, error) {
// prompt := fmt.Sprintf(`修复这个格式错误的JSON错误信息: %v
// 需要修复的JSON:
// %s
// 只返回修复后的JSON不要有任何解释。`, originalError, malformedJSON)
// var responseBuilder strings.Builder
// stream := da.client.Chat.Completions.NewStreaming(ctx, openai.ChatCompletionNewParams{
// Messages: openai.F([]openai.ChatCompletionMessageParamUnion{
// openai.UserMessage(prompt),
// }),
// Model: openai.F("qwen-plus"),
// })
// for stream.Next() {
// chunk := stream.Current()
// for _, choice := range chunk.Choices {
// responseBuilder.WriteString(choice.Delta.Content)
// }
// }
// if err := stream.Err(); err != nil {
// return "", fmt.Errorf("发送消息失败: %v", err)
// }
// fixed := responseBuilder.String()
// if fixed == "" {
// return "", fmt.Errorf("没有修复响应")
// }
// return fixed, nil
// }
// extractDemands 从文档内容中提取需求信息
func (da *DemandAnalyzer) extractDemands(ctx context.Context, docContent string, doc *M.Doc) error {
schema, err := generateDemandJSONSchema()
if err != nil {
return fmt.Errorf("生成Schema失败: %v", err)
}
da.logger.Printf("需求JSON Schema:\n%s", schema)
// 初始化对话历史
history := &DemandChatHistory{
Messages: make([]openai.ChatCompletionMessageParamUnion, 0),
}
// 初始化对话发送文档内容和schema
initialPrompt := fmt.Sprintf(`你是一个需求文档分析助手。你的任务是从文档中提取需求信息并构建需求树。
请从以下文档中提取所有需求信息包括需求ID、标题、描述、优先级、类型和状态。
请确保完整提取每个需求的所有信息,并正确构建需求的层级关系。
如果文档内容较多,你可以分批次输出,每次输出一部分需求信息:
1. 如果还有更多需求未处理,设置 "has_more": true
2. 在后续输出中,继续提取剩余的需求,不要重复之前已输出的需求
3. 当所有需求都输出完成时,设置 "has_more": false
4. 每次输出时,设置 "analysis_percent" 表示估计的分析进度百分比
需求应该按照层级结构组织,主需求包含子需求,子需求可能还有更深层次的子需求,以此类推,需求树的深度没有限制。
对于每个子需求,请设置其 "parent_req_id" 为父需求的ID这样可以明确表示需求之间的层级关系。
你可以在后续输出中继续为前面已输出的需求添加子需求,只需正确设置 "parent_req_id" 即可。
如果文档中包含多个独立的需求树或模块,请将它们作为独立的顶级需求节点输出。
每个需求都应该有一个唯一的req_id格式为REQ-数字例如REQ-001。
如果文档中没有明确的需求ID请自动生成一个。
文档内容:
%s
JSON Schema:
%s
请严格按照schema格式输出确保是有效的JSON格式。输出的JSON必须符合以上schema的规范。`, docContent, string(schema))
totalDemands := 0 // 用于跟踪总共处理的需求数量
maxRetries := 10
// 添加初始消息到历史记录
history.Messages = append(history.Messages, openai.UserMessage(initialPrompt))
maxInitRetries := 3
var responseBuilder strings.Builder
var streamErr error
// 发送初始消息并重试
for i := 0; i < maxInitRetries; i++ {
responseBuilder.Reset()
stream := da.client.Chat.Completions.NewStreaming(ctx, openai.ChatCompletionNewParams{
Messages: openai.F(history.Messages),
Model: openai.F("qwen-plus"),
})
for stream.Next() {
chunk := stream.Current()
for _, choice := range chunk.Choices {
responseBuilder.WriteString(choice.Delta.Content)
}
}
if streamErr = stream.Err(); streamErr == nil {
break
}
time.Sleep(time.Second)
}
if streamErr != nil {
da.logger.Printf("发送初始消息失败,已重试: %v", streamErr)
return fmt.Errorf("发送初始消息失败: %v", streamErr)
}
response := responseBuilder.String()
// 添加模型的响应到历史记录
history.Messages = append(history.Messages, openai.AssistantMessage(response))
for {
// 休眠30秒以避免速率限制
time.Sleep(time.Second * 30)
da.logger.Printf("LLM响应:\n%s", response)
// 清理并验证响应
cleanedJSON, err := cleanDemandJSONResponse(response)
if err != nil {
// 尝试修复JSON
da.logger.Printf("尝试修复JSON: %v", err)
fixed, repairErr := da.repairDemandJSON(ctx, response, err)
if repairErr != nil {
return fmt.Errorf("JSON修复失败: %v (原始错误: %v)", repairErr, err)
}
da.logger.Printf("JSON已修复:\n%s", fixed)
cleanedJSON, err = cleanDemandJSONResponse(fixed)
if err != nil {
return fmt.Errorf("JSON验证失败: %v", err)
}
}
// 解析响应
var result DemandTree
if err := json.Unmarshal([]byte(cleanedJSON), &result); err != nil {
return fmt.Errorf("解析LLM响应失败: %v", err)
}
// 处理这一批次的需求
for _, demand := range result.Demands {
totalDemands += da.processDemandNode(demand, doc.ID, "")
}
// 更新文档处理进度
progress := result.AnalysisPercent
if err := cfg.DB().Model(doc).Update("analysis_percent", progress).Error; err != nil {
da.logger.Printf("更新进度失败: %v", err)
}
// 如果没有更多需求要处理,退出循环
if !result.HasMore {
break
}
// 使用chat history继续对话
followUpPrompt := `请继续提取剩余的需求信息,保持相同的输出格式。
请记住:
1. 不要重复之前已输出的需求,如果你想给之前的需求添加子需求,你可以设置 "parent_req_id" 为父需求的ID
2. 如果还有更多内容,添加 "has_more": true
3. 如果已经输出完所有内容,添加 "has_more": false
4. 请严格按照schema格式输出确保是有效、完整的JSON格式。输出的JSON必须符合以上schema的规范比如string类型的值要注意转义字符的使用。`
history.Messages = append(history.Messages, openai.UserMessage(followUpPrompt))
// 重试逻辑
for retry := 0; retry < maxRetries; retry++ {
responseBuilder.Reset()
stream := da.client.Chat.Completions.NewStreaming(ctx, openai.ChatCompletionNewParams{
Messages: openai.F(history.Messages),
Model: openai.F("qwen-plus"),
})
for stream.Next() {
chunk := stream.Current()
for _, choice := range chunk.Choices {
responseBuilder.WriteString(choice.Delta.Content)
}
}
if streamErr = stream.Err(); streamErr == nil {
response = responseBuilder.String()
history.Messages = append(history.Messages, openai.AssistantMessage(response))
break
}
da.logger.Printf("尝试 %d 失败: %v, 重试中...", retry+1, streamErr)
time.Sleep(time.Second * 30)
}
if streamErr != nil {
return fmt.Errorf("发送后续消息失败,已重试 %d 次: %v", maxRetries, streamErr)
}
}
return nil
}
// processDemandNode 递归处理需求节点及其子节点
func (da *DemandAnalyzer) processDemandNode(node DemandNode, docID string, parentID string) int {
count := 1 // 当前节点计数为1
var existingDemand M.Demand
if err := cfg.DB().Where("req_id = ? AND doc_id = ?", node.ReqID, docID).First(&existingDemand).Error; err == nil {
// 如果需求已存在,直接使用现有需求处理子节点
for _, child := range node.Children {
count += da.processDemandNode(child, docID, existingDemand.ID)
}
return count
}
// 计算当前节点的层级
level := 0
if parentID != "" {
// 如果有父节点查询父节点的层级并加1
var parentDemand M.Demand
if err := cfg.DB().Where("id = ?", parentID).First(&parentDemand).Error; err == nil {
level = parentDemand.Level + 1
}
} else if node.ParentReqID != "" {
// 如果没有直接的父节点ID但有父需求ID尝试通过ReqID查找父节点
var parentDemand M.Demand
if err := cfg.DB().Where("req_id = ? AND doc_id = ?", node.ParentReqID, docID).First(&parentDemand).Error; err == nil {
parentID = parentDemand.ID // 设置父节点ID
level = parentDemand.Level + 1
} else {
da.logger.Printf("通过ReqID查找父节点失败: %v, ReqID: %s", err, node.ParentReqID)
}
}
// 创建需求记录
demand := &M.Demand{
DocID: docID,
Description: node.Description,
ReqID: node.ReqID,
Priority: node.Priority,
Type: node.Type,
Status: node.Status,
ParentReqID: node.ParentReqID, // 添加父需求ID
Tree: M.Tree{
Name: node.Title,
ParentID: parentID,
Level: level,
},
}
if err := cfg.DB().Create(demand).Error; err != nil {
da.logger.Printf("创建需求记录失败: %v", err)
return count
}
// 递归处理子需求
for _, child := range node.Children {
count += da.processDemandNode(child, docID, demand.ID)
}
return count
}
// readDocContent 从文件中读取文档内容
func (da *DemandAnalyzer) readDocContent(filePath string) (string, error) {
ext := strings.ToLower(filepath.Ext(filePath))
switch ext {
case ".pdf":
return da.readPDF(filePath)
case ".txt":
return da.readTXT(filePath)
case ".docx":
return da.readDOCX(filePath)
default:
return "", fmt.Errorf("不支持的文件类型: %s", ext)
}
}
// readPDF 读取PDF文件内容
func (da *DemandAnalyzer) readPDF(filePath string) (string, error) {
f, r, err := pdf.Open(filePath)
if err != nil {
return "", fmt.Errorf("打开PDF文件失败: %v", err)
}
defer f.Close()
var buf bytes.Buffer
b, err := r.GetPlainText()
if err != nil {
return "", fmt.Errorf("读取PDF文本失败: %v", err)
}
buf.ReadFrom(b)
return buf.String(), nil
}
// readTXT 读取TXT文件内容
func (da *DemandAnalyzer) readTXT(filePath string) (string, error) {
content, err := os.ReadFile(filePath)
if err != nil {
return "", fmt.Errorf("读取TXT文件失败: %v", err)
}
return string(content), nil
}
// readDOCX 读取DOCX文件内容
func (da *DemandAnalyzer) readDOCX(filePath string) (string, error) {
// 使用 unidoc/unioffice 库读取 DOCX 文件
doc, err := document.Open(filePath)
if err != nil {
return "", fmt.Errorf("打开DOCX文件失败: %v", err)
}
var content strings.Builder
// 遍历所有段落并提取文本
for _, para := range doc.Paragraphs() {
for _, run := range para.Runs() {
content.WriteString(run.Text())
}
content.WriteString("\n") // 段落结束添加换行
}
return content.String(), nil
}
// DemandProcessor 处理需求文档的处理器
type DemandProcessor struct {
analyzer *DemandAnalyzer
tasks map[string]*M.Doc
processingDoc map[string]context.CancelFunc
mutex sync.Mutex
logger *log.Logger
}
var demandProcessor *DemandProcessor
var demandProcessorOnce sync.Once
// GetDemandProcessor 获取需求处理器单例
func GetDemandProcessor() *DemandProcessor {
demandProcessorOnce.Do(func() {
analyzer, err := NewDemandAnalyzer()
if err != nil {
log.Fatalf("初始化需求分析器失败: %v", err)
}
demandProcessor = &DemandProcessor{
analyzer: analyzer,
tasks: make(map[string]*M.Doc),
processingDoc: make(map[string]context.CancelFunc),
mutex: sync.Mutex{},
logger: setupLogger(),
}
})
return demandProcessor
}
// AddTask 添加需求文档处理任务
func (dp *DemandProcessor) AddTask(doc *M.Doc, filePath string) {
dp.mutex.Lock()
defer dp.mutex.Unlock()
dp.tasks[doc.ID] = doc
go dp.processDoc(doc, filePath)
}
// CancelDocProcessing 取消文档处理
func (dp *DemandProcessor) CancelDocProcessing(docID string) {
dp.mutex.Lock()
defer dp.mutex.Unlock()
if cancel, exists := dp.processingDoc[docID]; exists {
cancel()
delete(dp.processingDoc, docID)
}
}
// processDoc 处理需求文档
func (dp *DemandProcessor) processDoc(doc *M.Doc, filePath string) {
dp.logger.Printf("开始处理需求文档: %s", doc.Name)
// 创建可取消的上下文
ctx, cancel := context.WithCancel(context.Background())
dp.mutex.Lock()
dp.processingDoc[doc.ID] = cancel
dp.mutex.Unlock()
defer func() {
dp.mutex.Lock()
delete(dp.processingDoc, doc.ID)
dp.mutex.Unlock()
}()
// 更新文档状态为处理中
if err := cfg.DB().Model(doc).Updates(map[string]interface{}{
"analysis_completed": false,
"analysis_percent": 0,
"analysis_error": "",
}).Error; err != nil {
dp.logger.Printf("更新文档状态失败: %v", err)
return
}
// 读取文档内容
content, err := dp.analyzer.readDocContent(filePath)
if err != nil {
errMsg := fmt.Sprintf("读取文档内容失败: %v", err)
dp.logger.Printf(errMsg)
// 更新文档状态为失败
if dbErr := cfg.DB().Model(doc).Updates(map[string]interface{}{
"analysis_completed": true,
"analysis_error": errMsg,
}).Error; dbErr != nil {
dp.logger.Printf("更新文档状态失败: %v", dbErr)
}
return
}
// 提取需求
if err := dp.analyzer.extractDemands(ctx, content, doc); err != nil {
errMsg := fmt.Sprintf("提取需求失败: %v", err)
dp.logger.Printf(errMsg)
// 更新文档状态为失败
if dbErr := cfg.DB().Model(doc).Updates(map[string]interface{}{
"analysis_completed": true,
"analysis_error": errMsg,
}).Error; dbErr != nil {
dp.logger.Printf("更新文档状态失败: %v", dbErr)
}
return
}
// 更新文档状态为完成
if err := cfg.DB().Model(doc).Updates(map[string]interface{}{
"analysis_completed": true,
"analysis_percent": 100,
}).Error; err != nil {
dp.logger.Printf("更新文档状态失败: %v", err)
return
}
dp.logger.Printf("需求文档处理完成: %s", doc.Name)
}