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) }