998 lines
32 KiB
Go
998 lines
32 KiB
Go
package doc
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"log"
|
||
"regexp"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"app/cfg"
|
||
M "app/models"
|
||
|
||
"github.com/openai/openai-go"
|
||
)
|
||
|
||
// TODO 另一种思路是先粗糙生成一些需求再建立联系
|
||
type AgenticDemandProcessor struct {
|
||
analyzer *DemandAnalyzer
|
||
tasks map[string]*M.Doc
|
||
processingDoc map[string]context.CancelFunc
|
||
mutex sync.Mutex
|
||
logger *log.Logger
|
||
}
|
||
|
||
var agenticDemandProcessor *AgenticDemandProcessor
|
||
var agenticDemandProcessorOnce sync.Once
|
||
|
||
func GetAgenticDemandProcessor() *AgenticDemandProcessor {
|
||
agenticDemandProcessorOnce.Do(func() {
|
||
analyzer, err := NewDemandAnalyzer()
|
||
if err != nil {
|
||
log.Fatalf("初始化需求分析器失败: %v", err)
|
||
}
|
||
|
||
agenticDemandProcessor = &AgenticDemandProcessor{
|
||
analyzer: analyzer,
|
||
tasks: make(map[string]*M.Doc),
|
||
processingDoc: make(map[string]context.CancelFunc),
|
||
mutex: sync.Mutex{},
|
||
logger: setupLogger(),
|
||
}
|
||
})
|
||
return agenticDemandProcessor
|
||
}
|
||
|
||
// AddTask 添加需求文档处理任务
|
||
func (dp *AgenticDemandProcessor) 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 *AgenticDemandProcessor) CancelDocProcessing(docID string) {
|
||
dp.mutex.Lock()
|
||
defer dp.mutex.Unlock()
|
||
|
||
if cancel, exists := dp.processingDoc[docID]; exists {
|
||
cancel()
|
||
delete(dp.processingDoc, docID)
|
||
}
|
||
}
|
||
|
||
func (dp *AgenticDemandProcessor) splitTextIntoChunks(content string) ([]string, error) {
|
||
// 直接在Go中实现文本分块,不再调用Python脚本
|
||
// 设置每个块的最大和最小字符数
|
||
maxChunkSize := 4000
|
||
minChunkSize := 500 // 设置最小块大小为500字符
|
||
|
||
// 定义标点符号正则表达式,包括中英文标点
|
||
punctuationPattern := "[。!?.!?;;]"
|
||
re := regexp.MustCompile(punctuationPattern)
|
||
|
||
// 优化的分块方法:从maxChunkSize位置向前查找最近的标点
|
||
var chunks []string
|
||
remaining := content
|
||
|
||
for len(remaining) > 0 {
|
||
if len(remaining) <= maxChunkSize {
|
||
// 如果剩余内容不超过最大块大小,直接添加
|
||
chunks = append(chunks, remaining)
|
||
break
|
||
}
|
||
|
||
// 确定切分位置:从maxChunkSize位置向前查找最近的标点
|
||
cutPos := maxChunkSize
|
||
if cutPos > len(remaining) {
|
||
cutPos = len(remaining)
|
||
}
|
||
|
||
// 在maxChunkSize范围内查找最后一个标点
|
||
searchEnd := cutPos
|
||
searchStart := cutPos - 100 // 向前查找100个字符范围内的标点
|
||
if searchStart < 0 {
|
||
searchStart = 0
|
||
}
|
||
|
||
// 在指定范围内查找最后一个标点
|
||
searchText := remaining[searchStart:searchEnd]
|
||
allMatches := re.FindAllStringIndex(searchText, -1)
|
||
|
||
if len(allMatches) > 0 {
|
||
// 找到了标点,使用最后一个标点作为切分点
|
||
lastMatch := allMatches[len(allMatches)-1]
|
||
cutPos = searchStart + lastMatch[1] // 使用标点后的位置作为切分点
|
||
|
||
// 检查切分后的块是否太小
|
||
if cutPos < minChunkSize {
|
||
// 如果太小,直接使用maxChunkSize作为切分点
|
||
cutPos = maxChunkSize
|
||
}
|
||
} else if searchStart > 0 {
|
||
// 如果在100字符范围内没找到,扩大搜索范围到整个maxChunkSize
|
||
searchText = remaining[:searchEnd]
|
||
allMatches = re.FindAllStringIndex(searchText, -1)
|
||
if len(allMatches) > 0 {
|
||
lastMatch := allMatches[len(allMatches)-1]
|
||
cutPos = lastMatch[1] // 使用标点后的位置作为切分点
|
||
|
||
// 检查切分后的块是否太小
|
||
if cutPos < minChunkSize {
|
||
// 如果太小,直接使用maxChunkSize作为切分点
|
||
cutPos = maxChunkSize
|
||
}
|
||
} else {
|
||
// 如果仍然没找到标点,就使用maxChunkSize作为切分点
|
||
cutPos = maxChunkSize
|
||
}
|
||
} else {
|
||
// 如果无法向前查找(已经在文本开头),直接使用maxChunkSize
|
||
cutPos = maxChunkSize
|
||
}
|
||
|
||
// 添加当前块并继续处理剩余内容
|
||
chunks = append(chunks, remaining[:cutPos])
|
||
remaining = remaining[cutPos:]
|
||
}
|
||
|
||
return chunks, nil
|
||
}
|
||
|
||
// 修改 processDoc 方法
|
||
func (dp *AgenticDemandProcessor) 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)
|
||
dp.updateDocErrorStatus(doc, errMsg)
|
||
return
|
||
}
|
||
|
||
// 将文档内容分块
|
||
chunks, err := dp.splitTextIntoChunks(content)
|
||
if err != nil {
|
||
errMsg := fmt.Sprintf("文档分块失败: %v", err)
|
||
dp.logger.Printf(errMsg)
|
||
dp.updateDocErrorStatus(doc, errMsg)
|
||
return
|
||
}
|
||
|
||
// 用于跟踪所有已保存的需求
|
||
var allSavedDemands []*M.Demand
|
||
totalChunks := len(chunks)
|
||
|
||
// 逐块处理文本
|
||
for i, chunk := range chunks {
|
||
// 检查是否被取消
|
||
select {
|
||
case <-ctx.Done():
|
||
dp.logger.Printf("文档处理被取消: %s", doc.Name)
|
||
return
|
||
default:
|
||
}
|
||
|
||
// 更新处理进度
|
||
progress := float64(i) / float64(totalChunks) * 100
|
||
if err := cfg.DB().Model(doc).Update("analysis_percent", progress).Error; err != nil {
|
||
dp.logger.Printf("更新进度失败: %v", err)
|
||
}
|
||
|
||
// 提取当前块的需求,同时考虑已有的需求树
|
||
newDemands, err := dp.extractDemandsFromChunk(ctx, chunk, allSavedDemands)
|
||
if err != nil {
|
||
errMsg := fmt.Sprintf("处理文本块 %d/%d 失败: %v", i+1, totalChunks, err)
|
||
dp.logger.Printf(errMsg)
|
||
dp.updateDocErrorStatus(doc, errMsg)
|
||
return
|
||
}
|
||
|
||
dp.logger.Printf("文本块 %d/%d 提取了 %d 个需求", i+1, totalChunks, len(newDemands))
|
||
|
||
if len(newDemands) > 0 {
|
||
// 立即保存这个块的需求
|
||
if err := dp.saveDemands(doc, newDemands); err != nil {
|
||
errMsg := fmt.Sprintf("保存文本块 %d/%d 的需求失败: %v", i+1, totalChunks, err)
|
||
dp.logger.Printf(errMsg)
|
||
dp.updateDocErrorStatus(doc, errMsg)
|
||
return
|
||
}
|
||
|
||
dp.logger.Printf("文本块 %d/%d 的需求已保存到数据库", i+1, totalChunks)
|
||
|
||
// 更新已保存的需求列表
|
||
// 首先从数据库获取完整的需求列表,确保包含所有已保存的需求及其关系
|
||
var updatedSavedDemands []*M.Demand
|
||
if err := cfg.DB().Where("doc_id = ?", doc.ID).Find(&updatedSavedDemands).Error; err != nil {
|
||
dp.logger.Printf("获取已保存需求列表失败: %v", err)
|
||
// 即使获取失败,也继续使用当前已知的需求列表
|
||
allSavedDemands = append(allSavedDemands, newDemands...)
|
||
} else {
|
||
allSavedDemands = updatedSavedDemands
|
||
}
|
||
} else {
|
||
dp.logger.Printf("文本块 %d/%d 没有提取到新需求", i+1, totalChunks)
|
||
}
|
||
}
|
||
|
||
// 更新文档状态为完成
|
||
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,总共提取并保存了 %d 个需求", doc.Name, len(allSavedDemands))
|
||
}
|
||
|
||
// 添加一个辅助方法来处理错误状态更新
|
||
func (dp *AgenticDemandProcessor) updateDocErrorStatus(doc *M.Doc, errMsg string) {
|
||
if dbErr := cfg.DB().Model(doc).Updates(map[string]interface{}{
|
||
"analysis_completed": true,
|
||
"analysis_error": errMsg,
|
||
}).Error; dbErr != nil {
|
||
dp.logger.Printf("更新文档错误状态失败: %v", dbErr)
|
||
}
|
||
}
|
||
|
||
// extractDemandsFromChunk 从文本块中提取需求
|
||
// extractDemandsFromChunk 从文本块中提取需求
|
||
func (dp *AgenticDemandProcessor) extractDemandsFromChunk(ctx context.Context, chunk string, previousBlockDemands []*M.Demand) ([]*M.Demand, error) {
|
||
// 用于跟踪当前文本块所有提取的需求
|
||
var currentBlockDemands []DemandNode
|
||
|
||
// 初始提取时,没有当前块的需求
|
||
var currentDemandModels []*M.Demand
|
||
|
||
// 最大尝试次数,避免无限循环
|
||
maxAttempts := 10
|
||
attemptCount := 0
|
||
hasMore := true
|
||
|
||
// 初始化对话历史
|
||
history := []openai.ChatCompletionMessageParamUnion{}
|
||
// && attemptCount < maxAttempts
|
||
// 循环直到所有需求提取完毕或达到最大尝试次数
|
||
for hasMore {
|
||
// 是否是后续提取
|
||
isFollowUp := attemptCount > 0
|
||
|
||
// 构建提示词
|
||
prompt := dp.buildPrompt(chunk, previousBlockDemands, currentDemandModels, isFollowUp)
|
||
|
||
// 添加到对话历史
|
||
history = append(history, openai.UserMessage(prompt))
|
||
|
||
// 调用LLM API
|
||
dp.logger.Printf("发送文本块到LLM进行需求提取 (尝试 %d/%d)", attemptCount+1, maxAttempts)
|
||
|
||
// 创建流式响应
|
||
var responseBuilder strings.Builder
|
||
var err error
|
||
maxRetries := 3
|
||
dp.logger.Printf("发送的提示词: %s", prompt)
|
||
for retry := 0; retry < maxRetries; retry++ {
|
||
responseBuilder.Reset()
|
||
stream := dp.analyzer.client.Chat.Completions.NewStreaming(ctx, openai.ChatCompletionNewParams{
|
||
Messages: openai.F(history),
|
||
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 {
|
||
break
|
||
}
|
||
|
||
dp.logger.Printf("尝试 %d 失败: %v, 重试中...", retry+1, err)
|
||
time.Sleep(time.Second * 5)
|
||
}
|
||
|
||
if err != nil {
|
||
return nil, fmt.Errorf("调用LLM API失败,已重试%d次: %v", maxRetries, err)
|
||
}
|
||
|
||
response := responseBuilder.String()
|
||
dp.logger.Printf("LLM响应:\n%s", response)
|
||
history = append(history, openai.AssistantMessage(response))
|
||
|
||
// 清理并验证响应
|
||
cleanedJSON, err := cleanDemandJSONResponse(response)
|
||
if err != nil {
|
||
// 尝试修复JSON
|
||
dp.logger.Printf("尝试修复JSON: %v", err)
|
||
fixed, repairErr := dp.analyzer.repairDemandJSON(ctx, response, err)
|
||
if repairErr != nil {
|
||
return nil, fmt.Errorf("JSON修复失败: %v (原始错误: %v)", repairErr, err)
|
||
}
|
||
dp.logger.Printf("JSON已修复:\n%s", fixed)
|
||
cleanedJSON = fixed
|
||
}
|
||
|
||
// 解析响应
|
||
var result DemandTree
|
||
if err := json.Unmarshal([]byte(cleanedJSON), &result); err != nil {
|
||
return nil, fmt.Errorf("解析LLM响应失败: %v", err)
|
||
}
|
||
|
||
// 将新提取的需求添加到当前文本块的需求中
|
||
currentBlockDemands = append(currentBlockDemands, result.Demands...)
|
||
|
||
// 将当前所有需求转换为模型格式,用于下一次提示
|
||
currentDemandModels, err = dp.parseDemandsToModel(currentBlockDemands)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("转换需求格式失败: %v", err)
|
||
}
|
||
|
||
// 更新循环条件
|
||
hasMore = result.HasMore
|
||
attemptCount++
|
||
|
||
dp.logger.Printf("需求提取进度: 已提取 %d 个需求,hasMore=%v", len(currentBlockDemands), hasMore)
|
||
|
||
// 如果已经是最后一次尝试且仍有更多需求,记录警告
|
||
if attemptCount == maxAttempts && hasMore {
|
||
dp.logger.Printf("警告:达到最大尝试次数 (%d),但模型表示还有更多需求未提取", maxAttempts)
|
||
}
|
||
}
|
||
|
||
// 解析为我们的模型格式
|
||
demands, err := dp.parseDemandsToModel(currentBlockDemands)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("转换需求格式失败: %v", err)
|
||
}
|
||
|
||
return demands, nil
|
||
}
|
||
|
||
// handleHasMoreFollowUp 处理有更多需求的情况
|
||
func (dp *AgenticDemandProcessor) handleHasMoreFollowUp(ctx context.Context, chunk string, existingDemands []*M.Demand, history []openai.ChatCompletionMessageParamUnion, currentDemands []DemandNode) ([]*M.Demand, error) {
|
||
var allDemands []DemandNode
|
||
allDemands = append(allDemands, currentDemands...)
|
||
|
||
// 最多连续请求5次,避免无限循环
|
||
maxFollowUpAttempts := 5
|
||
|
||
for attempt := 0; attempt < maxFollowUpAttempts; attempt++ {
|
||
// 将已提取的需求转换为M.Demand格式
|
||
extractedSoFar, err := dp.parseDemandsToModel(allDemands)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("转换已提取需求失败: %v", err)
|
||
}
|
||
|
||
// 构建后续提示,明确区分两类需求
|
||
followUpPrompt := dp.buildPrompt(chunk, existingDemands, extractedSoFar, true)
|
||
|
||
// 添加后续提示到历史记录
|
||
history = append(history, openai.UserMessage(followUpPrompt))
|
||
|
||
// 创建流式响应
|
||
var responseBuilder strings.Builder
|
||
dp.logger.Printf("发送后续请求到LLM (尝试 %d/%d)", attempt+1, maxFollowUpAttempts)
|
||
dp.logger.Printf("发送的提示词: %s", followUpPrompt)
|
||
stream := dp.analyzer.client.Chat.Completions.NewStreaming(ctx, openai.ChatCompletionNewParams{
|
||
Messages: openai.F(history),
|
||
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 nil, fmt.Errorf("后续请求失败: %v", err)
|
||
}
|
||
|
||
response := responseBuilder.String()
|
||
dp.logger.Printf("收到的响应: %s", response)
|
||
history = append(history, openai.AssistantMessage(response))
|
||
|
||
// 清理并验证响应
|
||
cleanedJSON, err := cleanDemandJSONResponse(response)
|
||
if err != nil {
|
||
fixed, repairErr := dp.analyzer.repairDemandJSON(ctx, response, err)
|
||
if repairErr != nil {
|
||
return nil, fmt.Errorf("JSON修复失败: %v", repairErr)
|
||
}
|
||
cleanedJSON = fixed
|
||
}
|
||
|
||
// 解析响应
|
||
var result DemandTree
|
||
if err := json.Unmarshal([]byte(cleanedJSON), &result); err != nil {
|
||
return nil, fmt.Errorf("解析后续响应失败: %v", err)
|
||
}
|
||
|
||
// 合并需求
|
||
allDemands = append(allDemands, result.Demands...)
|
||
|
||
// 如果没有更多需求,退出循环
|
||
if !result.HasMore {
|
||
break
|
||
}
|
||
|
||
// 如果这是最后一次尝试并且仍然has_more=true,记录警告
|
||
if attempt == maxFollowUpAttempts-1 && result.HasMore {
|
||
dp.logger.Printf("警告:达到最大后续请求次数(%d),可能还有未提取的需求", maxFollowUpAttempts)
|
||
}
|
||
}
|
||
|
||
// 解析为我们的模型格式
|
||
demands, err := dp.parseDemandsToModel(allDemands)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("转换需求格式失败: %v", err)
|
||
}
|
||
|
||
return demands, nil
|
||
}
|
||
|
||
// 新增方法:将DemandNode转换为M.Demand
|
||
func (dp *AgenticDemandProcessor) parseDemandsToModel(nodes []DemandNode) ([]*M.Demand, error) {
|
||
var demands []*M.Demand
|
||
|
||
// 递归处理需求及其子需求
|
||
var processDemand func(node DemandNode, parentReqID string) *M.Demand
|
||
processDemand = func(node DemandNode, parentReqID string) *M.Demand {
|
||
// 设置默认值
|
||
priority := node.Priority
|
||
if priority == "" {
|
||
priority = "中"
|
||
}
|
||
|
||
demandType := node.Type
|
||
if demandType == "" {
|
||
demandType = "功能需求"
|
||
}
|
||
|
||
status := node.Status
|
||
if status == "" {
|
||
status = "待实现"
|
||
}
|
||
|
||
demand := &M.Demand{
|
||
Tree: M.Tree{
|
||
Name: node.Title,
|
||
},
|
||
Description: node.Description,
|
||
ReqID: node.ReqID,
|
||
Priority: priority,
|
||
Type: demandType,
|
||
Status: status,
|
||
ParentReqID: parentReqID,
|
||
}
|
||
|
||
demands = append(demands, demand)
|
||
|
||
// 处理子需求
|
||
for _, child := range node.Children {
|
||
childDemand := processDemand(child, node.ReqID)
|
||
// 子需求的父需求ID直接设置为当前需求的ID
|
||
childDemand.ParentReqID = node.ReqID
|
||
}
|
||
|
||
return demand
|
||
}
|
||
|
||
// 处理所有顶级需求
|
||
for _, node := range nodes {
|
||
processDemand(node, node.ParentReqID)
|
||
}
|
||
|
||
return demands, nil
|
||
}
|
||
|
||
// buildPrompt 构建提示词,用于初始提取或后续提取
|
||
func (dp *AgenticDemandProcessor) buildPrompt(chunk string, previousDemands []*M.Demand, currentChunkDemands []*M.Demand, isFollowUp bool) string {
|
||
// 获取JSON Schema - 复用成熟版本的函数
|
||
schema, err := generateDemandJSONSchema()
|
||
if err != nil {
|
||
dp.logger.Printf("生成Schema失败: %v", err)
|
||
return ""
|
||
}
|
||
|
||
var sb strings.Builder
|
||
|
||
// 设置标题和基本介绍
|
||
if isFollowUp {
|
||
sb.WriteString("# 继续提取需求\n\n")
|
||
sb.WriteString("请继续从当前文本块中提取剩余的需求信息,保持相同的输出格式。\n\n")
|
||
} else {
|
||
sb.WriteString("# 需求文档分析任务\n\n")
|
||
sb.WriteString("你是一个需求文档分析助手。你的任务是从需求规格说明书中提取需求信息并构建需求树,需求包含功能需求和非功能需求等。\n\n")
|
||
}
|
||
|
||
// 显示先前文本块提取的需求
|
||
if len(previousDemands) > 0 {
|
||
sb.WriteString("## 先前文本块中提取的需求\n\n")
|
||
sb.WriteString("以下是从先前文本块中提取的需求。你可以更新这些需求或建立与它们的关系:\n\n")
|
||
|
||
for i, demand := range previousDemands {
|
||
sb.WriteString(fmt.Sprintf("%d. **%s** (ID: %s)\n", i+1, demand.Name, demand.ReqID))
|
||
sb.WriteString(fmt.Sprintf(" - 描述: %s\n", demand.Description))
|
||
sb.WriteString(fmt.Sprintf(" - 优先级: %s\n", demand.Priority))
|
||
sb.WriteString(fmt.Sprintf(" - 类型: %s\n", demand.Type))
|
||
sb.WriteString(fmt.Sprintf(" - 状态: %s\n", demand.Status))
|
||
if demand.ParentReqID != "" {
|
||
sb.WriteString(fmt.Sprintf(" - 父需求ID: %s\n", demand.ParentReqID))
|
||
}
|
||
sb.WriteString("\n")
|
||
}
|
||
}
|
||
|
||
// 显示当前文本块已提取的需求(在has_more=true的情况下)
|
||
if len(currentChunkDemands) > 0 {
|
||
sb.WriteString("## 当前文本块已提取的需求\n\n")
|
||
sb.WriteString("以下是从当前文本块中已经提取的需求。请不要重复这些需求,继续提取未捕获的需求:\n\n")
|
||
|
||
for i, demand := range currentChunkDemands {
|
||
sb.WriteString(fmt.Sprintf("%d. **%s** (ID: %s)\n", i+1, demand.Name, demand.ReqID))
|
||
sb.WriteString(fmt.Sprintf(" - 描述: %s\n", demand.Description))
|
||
sb.WriteString(fmt.Sprintf(" - 优先级: %s\n", demand.Priority))
|
||
sb.WriteString(fmt.Sprintf(" - 类型: %s\n", demand.Type))
|
||
sb.WriteString(fmt.Sprintf(" - 状态: %s\n", demand.Status))
|
||
if demand.ParentReqID != "" {
|
||
sb.WriteString(fmt.Sprintf(" - 父需求ID: %s\n", demand.ParentReqID))
|
||
}
|
||
sb.WriteString("\n")
|
||
}
|
||
}
|
||
|
||
// 当前任务说明
|
||
sb.WriteString("## 当前任务\n\n")
|
||
if len(previousDemands) == 0 && !isFollowUp {
|
||
sb.WriteString("这是文档的第一个文本块,请提取所有需求信息,包括需求ID、标题、描述、优先级、类型和状态。\n")
|
||
sb.WriteString("请确保完整提取每个需求的所有信息,并正确构建需求的层级关系。\n\n")
|
||
} else {
|
||
sb.WriteString("你需要对当前文本块执行以下操作:\n")
|
||
sb.WriteString("1. 提取此文本块中的**新需求**\n")
|
||
if len(previousDemands) > 0 {
|
||
sb.WriteString("2. **更新已有需求**:如果发现对先前文本块需求的补充信息,请使用相同的reqID更新它们\n")
|
||
sb.WriteString("3. 建立需求之间的关系:如果发现已有需求的子需求,请正确设置父子关系\n")
|
||
}
|
||
sb.WriteString("4. 所有需求必须有唯一的ID、标题、描述、优先级、类型和状态\n")
|
||
sb.WriteString("5. 如果还有更多需求要提取但未能在当前输出中包含,设置 has_more 为 true\n\n")
|
||
|
||
if len(previousDemands) > 0 {
|
||
sb.WriteString("注意:当你更新已有需求时,请保持相同的req_id,这样我们就知道它是对已有需求的更新而不是新需求。\n\n")
|
||
}
|
||
}
|
||
if !isFollowUp {
|
||
sb.WriteString(`注意你要总结、提取需求规格说明书中的需求而不是自己创造需求,所以如果是无关内容(比如作者、单位等),你直接输出空数组,并且has_more设置成false就可以
|
||
注意has_more为true表示的是当前给你提供的文档中还有需求没有提取出来,而不是完整文档中还有内容没有提取出来,当前待分析文档中没有更多需求的时候,你要把has_more设置成false
|
||
`)
|
||
sb.WriteString("优先级、状态、ID等、描述等信息不是必须的,只要你能总结出名称就可以算一个需求")
|
||
// sb.WriteString("如果文本内容是目录,你也可以提取出来需求,因为之后给你提供详细内容的时候,你可以使用相同的req_id给它补充详细内容\n\n")
|
||
}
|
||
|
||
// 待分析文本
|
||
sb.WriteString("## 待分析文本\n\n")
|
||
sb.WriteString(chunk)
|
||
|
||
// 输出要求
|
||
sb.WriteString("\n\n## 输出要求\n\n")
|
||
sb.WriteString("请以JSON格式输出需求列表,严格遵循以下JSON Schema:\n\n")
|
||
sb.WriteString(string(schema))
|
||
sb.WriteString("\n\n请注意:\n")
|
||
sb.WriteString("1. 对于新需求,请分配新的req_id\n")
|
||
if len(previousDemands) > 0 {
|
||
sb.WriteString("2. 对于已有需求的更新,请保持相同的req_id\n")
|
||
}
|
||
sb.WriteString("3. 确保输出的JSON符合上述schema格式\n")
|
||
if len(previousDemands) > 0 {
|
||
sb.WriteString("4. 如果此文本块包含已有需求的补充信息,请使用相同的req_id并提供完整的更新后的需求\n")
|
||
}
|
||
sb.WriteString("5. 如果当前文本块还有更多需求未处理完,设置 \"has_more\": true;否则设置 \"has_more\": false\n")
|
||
|
||
return sb.String()
|
||
}
|
||
|
||
// saveDemands 保存需求到数据库
|
||
func (dp *AgenticDemandProcessor) saveDemands(doc *M.Doc, demands []*M.Demand) error {
|
||
dp.logger.Printf("开始保存需求到数据库,文档ID: %s,需求数量: %d", doc.ID, len(demands))
|
||
|
||
if len(demands) == 0 {
|
||
dp.logger.Printf("警告:没有需求需要保存")
|
||
return nil
|
||
}
|
||
|
||
// 开始事务
|
||
tx := cfg.DB().Begin()
|
||
dp.logger.Printf("数据库事务已开始")
|
||
|
||
defer func() {
|
||
if r := recover(); r != nil {
|
||
dp.logger.Printf("保存过程中发生panic: %v", r)
|
||
tx.Rollback()
|
||
}
|
||
}()
|
||
|
||
// 先获取当前文档中的所有已存在需求,用于去重和更新
|
||
var existingDemands []*M.Demand
|
||
if err := tx.Where("doc_id = ?", doc.ID).Find(&existingDemands).Error; err != nil {
|
||
tx.Rollback()
|
||
return fmt.Errorf("获取现有需求失败: %v", err)
|
||
}
|
||
|
||
// 创建req_id到数据库需求的映射,用于快速查找
|
||
existingReqIDMap := make(map[string]*M.Demand)
|
||
for _, d := range existingDemands {
|
||
existingReqIDMap[d.ReqID] = d
|
||
}
|
||
|
||
// 为了处理父子关系,先进行两轮处理:
|
||
// 1. 第一轮:更新/插入所有需求,记录新旧ID对应关系
|
||
// 2. 第二轮:更新父子关系
|
||
|
||
// 记录req_id到数据库ID的映射
|
||
reqIDToDBID := make(map[string]string)
|
||
for _, ed := range existingDemands {
|
||
reqIDToDBID[ed.ReqID] = ed.ID
|
||
}
|
||
|
||
// 第一轮:更新或插入需求
|
||
for i, demand := range demands {
|
||
demand.DocID = doc.ID
|
||
|
||
// 设置默认值
|
||
if demand.Status == "" {
|
||
demand.Status = "待实现"
|
||
}
|
||
if demand.Type == "" {
|
||
demand.Type = "功能需求"
|
||
}
|
||
if demand.Priority == "" {
|
||
demand.Priority = "中"
|
||
}
|
||
|
||
// 检查是否已存在相同req_id的需求
|
||
if existingDemand, exists := existingReqIDMap[demand.ReqID]; exists {
|
||
dp.logger.Printf("需求 #%d (reqID=%s) 已存在,进行更新", i+1, demand.ReqID)
|
||
|
||
// 保留原始ID和创建时间,更新其他字段
|
||
demand.ID = existingDemand.ID
|
||
demand.CreatedAt = existingDemand.CreatedAt
|
||
|
||
// 临时清除父子关系字段,稍后单独处理
|
||
tempParentReqID := demand.ParentReqID
|
||
demand.ParentReqID = ""
|
||
demand.Tree.ParentID = ""
|
||
|
||
// 更新需求
|
||
if err := tx.Model(existingDemand).Updates(demand).Error; err != nil {
|
||
dp.logger.Printf("更新需求失败: %v", err)
|
||
tx.Rollback()
|
||
return fmt.Errorf("更新需求失败: %v", err)
|
||
}
|
||
|
||
// 恢复父需求ID以便第二轮处理
|
||
demand.ParentReqID = tempParentReqID
|
||
|
||
dp.logger.Printf("需求 #%d 更新成功", i+1)
|
||
} else {
|
||
dp.logger.Printf("需求 #%d (reqID=%s) 是新需求,创建记录", i+1, demand.ReqID)
|
||
|
||
// 临时清除父子关系字段,稍后单独处理
|
||
tempParentReqID := demand.ParentReqID
|
||
demand.ParentReqID = ""
|
||
demand.Tree.ParentID = ""
|
||
|
||
// 创建新需求
|
||
if err := tx.Create(demand).Error; err != nil {
|
||
dp.logger.Printf("创建需求失败: %v", err)
|
||
tx.Rollback()
|
||
return fmt.Errorf("保存需求失败: %v", err)
|
||
}
|
||
|
||
// 恢复父需求ID以便第二轮处理
|
||
demand.ParentReqID = tempParentReqID
|
||
|
||
dp.logger.Printf("需求 #%d 创建成功,ID: %s", i+1, demand.ID)
|
||
|
||
// 更新映射
|
||
reqIDToDBID[demand.ReqID] = demand.ID
|
||
}
|
||
}
|
||
|
||
// 第二轮:更新父子关系
|
||
for i, demand := range demands {
|
||
if demand.ParentReqID != "" {
|
||
// 查找父需求的数据库ID
|
||
parentDBID, exists := reqIDToDBID[demand.ParentReqID]
|
||
if !exists {
|
||
dp.logger.Printf("警告:需求 #%d 的父需求 (reqID=%s) 未找到", i+1, demand.ParentReqID)
|
||
continue
|
||
}
|
||
|
||
// 更新父子关系
|
||
updateFields := map[string]interface{}{
|
||
"parent_req_id": demand.ParentReqID,
|
||
"parent_id": parentDBID,
|
||
"level": demand.Tree.Level,
|
||
}
|
||
|
||
if err := tx.Model(&M.Demand{}).Where("id = ?", reqIDToDBID[demand.ReqID]).Updates(updateFields).Error; err != nil {
|
||
dp.logger.Printf("更新需求 #%d 的父子关系失败: %v", i+1, err)
|
||
tx.Rollback()
|
||
return fmt.Errorf("更新父子关系失败: %v", err)
|
||
}
|
||
|
||
dp.logger.Printf("需求 #%d 的父子关系更新成功", i+1)
|
||
}
|
||
}
|
||
|
||
// 提交事务
|
||
dp.logger.Printf("所有需求保存完成,提交事务")
|
||
if err := tx.Commit().Error; err != nil {
|
||
dp.logger.Printf("提交事务失败: %v", err)
|
||
return fmt.Errorf("提交事务失败: %v", err)
|
||
}
|
||
|
||
dp.logger.Printf("事务提交成功,总共处理了 %d 个需求", len(demands))
|
||
return nil
|
||
}
|
||
|
||
// func (dp *AgenticDemandProcessor) saveDemands(doc *M.Doc, demands []*M.Demand) error {
|
||
// // 开始事务
|
||
// tx := cfg.DB().Begin()
|
||
// defer func() {
|
||
// if r := recover(); r != nil {
|
||
// tx.Rollback()
|
||
// }
|
||
// }()
|
||
|
||
// // 为每个需求设置文档ID并保存
|
||
// for _, demand := range demands {
|
||
// demand.DocID = doc.ID
|
||
|
||
// // 设置默认值
|
||
// if demand.Status == "" {
|
||
// demand.Status = "待实现"
|
||
// }
|
||
// if demand.Type == "" {
|
||
// demand.Type = "功能需求"
|
||
// }
|
||
// if demand.Priority == "" {
|
||
// demand.Priority = "中"
|
||
// }
|
||
|
||
// // 需要添加:计算当前节点的层级
|
||
// level := 0
|
||
// var parentID string
|
||
|
||
// // 如果有父需求ID,查找父需求并设置正确的ParentID和Level
|
||
// if demand.ParentReqID != "" {
|
||
// var parentDemand M.Demand
|
||
// if err := tx.Where("req_id = ? AND doc_id = ?", demand.ParentReqID, doc.ID).First(&parentDemand).Error; err == nil {
|
||
// parentID = parentDemand.ID
|
||
// level = parentDemand.Level + 1
|
||
// }
|
||
// }
|
||
|
||
// // 设置正确的Tree结构
|
||
// demand.Tree.ParentID = parentID
|
||
// demand.Tree.Level = level
|
||
|
||
// if err := tx.Create(demand).Error; err != nil {
|
||
// tx.Rollback()
|
||
// return fmt.Errorf("保存需求失败: %v", err)
|
||
// }
|
||
// }
|
||
|
||
// // 提交事务
|
||
// if err := tx.Commit().Error; err != nil {
|
||
// return fmt.Errorf("提交事务失败: %v", err)
|
||
// }
|
||
|
||
// return nil
|
||
// }
|
||
|
||
// mergeDemands 合并新旧需求树,处理重复和补充关系
|
||
// mergeDemands 合并新旧需求树,处理重复和补充关系
|
||
func (dp *AgenticDemandProcessor) mergeDemands(oldDemands, newDemands []*M.Demand) []*M.Demand {
|
||
result := make([]*M.Demand, len(oldDemands))
|
||
copy(result, oldDemands)
|
||
|
||
// 创建reqID到需求索引的映射,用于快速查找
|
||
reqIDToIndex := make(map[string]int)
|
||
for i, demand := range result {
|
||
reqIDToIndex[demand.ReqID] = i
|
||
}
|
||
|
||
// 遍历新需求
|
||
for _, newDemand := range newDemands {
|
||
merged := false
|
||
|
||
// 首先检查是否有相同的reqID(优先使用reqID进行匹配)
|
||
if idx, exists := reqIDToIndex[newDemand.ReqID]; exists {
|
||
// 找到相同reqID的需求,进行合并
|
||
dp.logger.Printf("找到相同reqID的需求: %s,进行更新", newDemand.ReqID)
|
||
|
||
// 合并描述(如果新描述更详细)
|
||
if len(newDemand.Description) > len(result[idx].Description) {
|
||
result[idx].Description = newDemand.Description
|
||
}
|
||
|
||
// 更新标题(如果提供了更详细的标题)
|
||
if len(newDemand.Name) > len(result[idx].Name) {
|
||
result[idx].Name = newDemand.Name
|
||
}
|
||
|
||
// 更新优先级(如果有新的优先级)
|
||
if newDemand.Priority != "" && newDemand.Priority != "中" {
|
||
result[idx].Priority = newDemand.Priority
|
||
}
|
||
|
||
// 更新类型(如果有新的类型)
|
||
if newDemand.Type != "" && newDemand.Type != "功能需求" {
|
||
result[idx].Type = newDemand.Type
|
||
}
|
||
|
||
// 更新状态(如果有新的状态)
|
||
if newDemand.Status != "" && newDemand.Status != "待实现" {
|
||
result[idx].Status = newDemand.Status
|
||
}
|
||
|
||
// 更新父需求ID(如果有新的父需求ID)
|
||
if newDemand.ParentReqID != "" {
|
||
result[idx].ParentReqID = newDemand.ParentReqID
|
||
}
|
||
|
||
merged = true
|
||
} else {
|
||
// 如果没有相同的reqID,检查标题相似度
|
||
for i, oldDemand := range result {
|
||
if dp.isSimilarTitle(oldDemand.Name, newDemand.Name) {
|
||
dp.logger.Printf("找到标题相似的需求: %s 和 %s,进行合并", oldDemand.Name, newDemand.Name)
|
||
|
||
// 合并描述(如果新描述更详细)
|
||
if len(newDemand.Description) > len(oldDemand.Description) {
|
||
result[i].Description = newDemand.Description
|
||
}
|
||
|
||
// 更新优先级(如果有新的优先级)
|
||
if newDemand.Priority != "" && newDemand.Priority != "中" {
|
||
result[i].Priority = newDemand.Priority
|
||
}
|
||
|
||
// 更新类型(如果有新的类型)
|
||
if newDemand.Type != "" && newDemand.Type != "功能需求" {
|
||
result[i].Type = newDemand.Type
|
||
}
|
||
|
||
// 更新状态(如果有新的状态)
|
||
if newDemand.Status != "" && newDemand.Status != "待实现" {
|
||
result[i].Status = newDemand.Status
|
||
}
|
||
|
||
// 更新父需求ID(如果有新的父需求ID)
|
||
if newDemand.ParentReqID != "" {
|
||
result[i].ParentReqID = newDemand.ParentReqID
|
||
}
|
||
|
||
merged = true
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
// 如果是新需求,添加到结果中
|
||
if !merged {
|
||
dp.logger.Printf("添加新需求: %s (ID: %s)", newDemand.Name, newDemand.ReqID)
|
||
result = append(result, newDemand)
|
||
// 更新索引映射
|
||
reqIDToIndex[newDemand.ReqID] = len(result) - 1
|
||
}
|
||
}
|
||
|
||
// 建立需求间的关系
|
||
dp.establishRelationships(result)
|
||
|
||
return result
|
||
}
|
||
|
||
// isSimilarTitle 判断两个标题是否相似
|
||
func (dp *AgenticDemandProcessor) isSimilarTitle(title1, title2 string) bool {
|
||
// 简单实现:如果标题包含关系或相似度高于阈值,则认为相似
|
||
// 可以使用更复杂的算法,如编辑距离、词向量相似度等
|
||
title1 = strings.ToLower(strings.TrimSpace(title1))
|
||
title2 = strings.ToLower(strings.TrimSpace(title2))
|
||
|
||
// 检查包含关系
|
||
if strings.Contains(title1, title2) || strings.Contains(title2, title1) {
|
||
return true
|
||
}
|
||
|
||
// TODO: 实现更复杂的相似度算法
|
||
return false
|
||
}
|
||
|
||
// establishRelationships 建立需求间的关系
|
||
func (dp *AgenticDemandProcessor) establishRelationships(demands []*M.Demand) {
|
||
// 创建标题到需求的映射,用于快速查找
|
||
titleToReqID := make(map[string]string)
|
||
for _, demand := range demands {
|
||
titleToReqID[demand.Name] = demand.ReqID
|
||
}
|
||
|
||
// 遍历临时结构中的需求,设置父需求ID
|
||
for i, demand := range demands {
|
||
// 尝试从parseDemandsFromResponse中的临时结构获取父需求标题
|
||
// 这里假设我们有一个方式获取父需求标题,例如通过额外字段或解析描述
|
||
// 在实际实现中,可能需要调整这部分逻辑
|
||
|
||
// 示例:从描述中提取父需求信息
|
||
parentTitle := extractParentTitleFromDescription(demand.Description)
|
||
if parentTitle != "" {
|
||
if parentReqID, exists := titleToReqID[parentTitle]; exists {
|
||
demands[i].ParentReqID = parentReqID
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// extractParentTitleFromDescription 从描述中提取父需求标题
|
||
func extractParentTitleFromDescription(description string) string {
|
||
// 这里实现一个简单的逻辑,从描述中提取父需求信息
|
||
// 例如,如果描述中包含"父需求:"或"Parent:"等标记
|
||
|
||
// 简单示例实现
|
||
parentPrefixes := []string{"父需求:", "Parent:", "父级需求:", "上级需求:"}
|
||
lines := strings.Split(description, "\n")
|
||
|
||
for _, line := range lines {
|
||
line = strings.TrimSpace(line)
|
||
for _, prefix := range parentPrefixes {
|
||
if strings.HasPrefix(line, prefix) {
|
||
return strings.TrimSpace(line[len(prefix):])
|
||
}
|
||
}
|
||
}
|
||
|
||
return ""
|
||
}
|
||
|
||
// 以下是直接复用成熟版本的函数
|