This commit is contained in:
Wyle.Gong-巩文昕
2025-04-22 16:42:48 +08:00
commit 67b0ad2723
95 changed files with 10508 additions and 0 deletions
+619
View File
@@ -0,0 +1,619 @@
package doc
import (
"app/cfg"
M "app/models"
"bytes"
"context"
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/invopop/jsonschema"
"github.com/ledongthuc/pdf"
"github.com/openai/openai-go"
"github.com/openai/openai-go/option"
"github.com/xeipuuv/gojsonschema"
)
type Parameter struct {
Name string `json:"name" jsonschema:"required,description=参数名称"`
Location string `json:"location" jsonschema:"enum=query,enum=header,enum=body,enum=path,description=参数位置"`
Type string `json:"type,omitempty" jsonschema:"default=string,description=参数类型"`
ParamType string `json:"param_type,omitempty" jsonschema:"enum=application/json,enum=text/plain,enum=multipart/form-data,enum=application/x-www-form-urlencoded,enum=,description=只有position为body时有效,其他时候为空即可"`
Description string `json:"description" jsonschema:"required,description=参数描述"`
Value string `json:"value,omitempty" jsonschema:"description=参数示例值,统一用字符串形式,不要用json,如果是对象,需要转换成json字符串,一定要是纯字符串,绝对不要有字符串加字符串这种字符串间的拼接运算,还要注意字符串中不要给{}[]加转义字符,这样会导致错误"`
}
type Response struct {
StatusCode int `json:"status_code" jsonschema:"required,description=HTTP状态码"`
Example string `json:"example" jsonschema:"description=响应示例,是json字符串,一定要是纯字符串,绝对不要有字符串加字符串这种字符串间的拼接运算,还要注意字符串中不要给{}[]加转义字符,这样会导致错误"`
}
// APISpec represents an API specification
type APISpec struct {
Name string `json:"name" jsonschema:"required,description=API名称"`
Description string `json:"description" jsonschema:"required,description=API描述"`
Inputs []Parameter `json:"inputs" jsonschema:"description=输入参数列表"`
Outputs []Parameter `json:"outputs" jsonschema:"description=输出参数列表"`
Method string `json:"method" jsonschema:"required,enum=GET,enum=POST,enum=PUT,enum=DELETE,enum=PATCH,description=HTTP方法"`
Path string `json:"path" jsonschema:"required,description=API路径"`
BodyType string `json:"body_type,omitempty" jsonschema:"default=application/json,description=请求体类型"`
Response Response `json:"response" jsonschema:"required,description=API响应信息"`
}
// APISpecList represents a list of API specifications
type APISpecList struct {
APIs []APISpec `json:"apis" jsonschema:"required,description=API列表"`
HasMore bool `json:"has_more" jsonschema:"required,description=是否还有更多API需要继续在下一次输出中继续输出"`
AnalysisPercent int `json:"analysis_percent" jsonschema:"required,description=估计的分析进度百分比,范围是0-100,不一定递增,可以调整,比如发现增长过快的时候可以减少,只有全部解析完毕才可以到100"`
}
// ChatHistory represents the chat history
type ChatHistory struct {
Messages []openai.ChatCompletionMessageParamUnion
DocContent string
Schema string
}
// setupLogger creates and configures the logger
func setupLogger() *log.Logger {
if err := os.MkdirAll("logs", 0755); err != nil {
log.Fatal(err)
}
timestamp := time.Now().Format("20060102_150405")
logFilename := filepath.Join("logs", fmt.Sprintf("llm_output_%s.log", timestamp))
file, err := os.OpenFile(logFilename, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
if err != nil {
log.Fatal(err)
}
return log.New(file, "", log.LstdFlags)
}
// DocumentAnalyzer handles document analysis
type DocumentAnalyzer struct {
client *openai.Client
logger *log.Logger
}
// NewDocumentAnalyzer creates a new DocumentAnalyzer instance
func NewDocumentAnalyzer() (*DocumentAnalyzer, error) {
client := openai.NewClient(
option.WithAPIKey("sk-0213c70194624703a1d0d80e0f762b0e"),
option.WithBaseURL("https://dashscope.aliyuncs.com/compatible-mode/v1/"),
// option.WithBaseURL("http://127.0.0.1:11434/"),
)
fmt.Println("API client initialized successfully")
return &DocumentAnalyzer{
client: client,
logger: setupLogger(),
}, nil
}
// generateJSONSchema generates JSON schema from the APISpecList struct
func generateJSONSchema() ([]byte, error) {
reflector := jsonschema.Reflector{
RequiredFromJSONSchemaTags: true,
AllowAdditionalProperties: true,
DoNotReference: true,
}
schema := reflector.Reflect(&APISpecList{})
return json.MarshalIndent(schema, "", " ")
}
// validateJSON validates the JSON response against the schema
func validateJSON(data []byte) error {
schema, err := generateJSONSchema()
if err != nil {
return fmt.Errorf("failed to generate schema: %v", err)
}
schemaLoader := gojsonschema.NewBytesLoader(schema)
documentLoader := gojsonschema.NewBytesLoader(data)
result, err := gojsonschema.Validate(schemaLoader, documentLoader)
if err != nil {
return fmt.Errorf("validation error: %v", err)
}
if !result.Valid() {
var errors []string
for _, desc := range result.Errors() {
errors = append(errors, desc.String())
}
return fmt.Errorf("invalid JSON: %v", errors)
}
return nil
}
// cleanJSONResponse cleans and validates the LLM response
func cleanJSONResponse(response string) (string, error) {
// Find the first { and last }
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("invalid JSON structure")
}
jsonStr := response[start:end]
// Validate the JSON
if err := validateJSON([]byte(jsonStr)); err != nil {
return "", fmt.Errorf("JSON validation failed: %v", err)
}
return jsonStr, nil
}
func (da *DocumentAnalyzer) repairJSON(ctx context.Context, malformedJSON string, originalError error) (string, error) {
prompt := fmt.Sprintf(`Fix this malformed JSON that had error: %v
JSON to fix:
%s
Return only the fixed JSON with no explanations.`, 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("failed to send message: %v", err)
}
fixed := responseBuilder.String()
if fixed == "" {
return "", fmt.Errorf("no repair response")
}
return fixed, nil
}
// extractAPIs extracts API information from document content
func (da *DocumentAnalyzer) extractAPIs(ctx context.Context, docContent string, doc *M.Doc) error {
schema, err := generateJSONSchema()
if err != nil {
return fmt.Errorf("failed to generate schema: %v", err)
}
da.logger.Printf("JSON Schema:\n%s", schema)
// 初始化对话历史
history := &ChatHistory{
Messages: make([]openai.ChatCompletionMessageParamUnion, 0),
}
// 初始化对话,发送文档内容和schema
initialPrompt := fmt.Sprintf(`你是一个API文档分析助手。你的任务是从文档中提取API信息。
请从以下文档中提取所有API接口信息,包括页面、功能操作名称、功能描述、请求类型、接口地址、输入和输出参数。
请确保完整提取每个接口的所有信息,不要遗漏或截断,如果有不确定的项,可以设置为空。
如果文档内容较多,你可以分批次输出,每次输出一部分API信息,并设置has_more为true表示还有更多API需要在下一次输出。
当所有API都输出完成时,设置has_more为false。
文档内容:
%s
JSON Schema:
%s
请严格按照schema格式输出,确保是有效的JSON格式。输出的JSON必须符合以上schema的规范。`, docContent, string(schema))
totalAPIs := 0 // 用于跟踪总共处理的API数量
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("Failed to send initial message after retries: %v", streamErr)
return fmt.Errorf("failed to send initial message: %v", streamErr)
}
response := responseBuilder.String()
// 添加模型的响应到历史记录
history.Messages = append(history.Messages, openai.AssistantMessage(response))
for {
// sleep 30s to avoid rate limiting
time.Sleep(time.Second * 30)
da.logger.Printf("LLM Response:\n%s", response)
// Clean and validate the response
cleanedJSON, err := cleanJSONResponse(response)
if err != nil {
// Try to repair the JSON
da.logger.Printf("JSON repair attempt: %v", err)
fixed, repairErr := da.repairJSON(ctx, response, err)
if repairErr != nil {
return fmt.Errorf("JSON repair failed: %v (original: %v)", repairErr, err)
}
da.logger.Printf("JSON repaired:\n%s", fixed)
cleanedJSON, err = cleanJSONResponse(fixed)
if err != nil {
return fmt.Errorf("JSON validation failed: %v", err)
}
}
// Parse the response
var result APISpecList
if err := json.Unmarshal([]byte(cleanedJSON), &result); err != nil {
return fmt.Errorf("failed to parse LLM response: %v", err)
}
// 处理这一批次的APIs
for _, api := range result.APIs {
totalAPIs++
// 创建新的endpoint
endpoint := &M.Endpoint{
DocID: doc.ID,
Name: api.Name,
Path: api.Path,
Method: api.Method,
Description: api.Description,
BodyType: api.BodyType,
Merged: false,
Node: "proxy",
}
if err := cfg.DB().Create(endpoint).Error; err != nil {
return fmt.Errorf("failed to create endpoint: %v", err)
}
// 创建响应记录
response := &M.Response{
EndpointID: endpoint.ID,
StatusCode: api.Response.StatusCode,
Example: api.Response.Example,
Name: "Default Response", // 默认值
ContentType: "application/json", // 默认值
Description: "API Response", // 默认值
}
if err := cfg.DB().Create(response).Error; err != nil {
return fmt.Errorf("failed to create response: %v", err)
}
// 存储参数
var jsonParams []*Parameter
var otherParams []*Parameter
for _, param := range api.Inputs {
jsonParams = append(jsonParams, &param)
otherParams = append(otherParams, &param)
}
// 如果有application/json类型的参数,将它们合并
if len(jsonParams) > 0 {
mergedValue := make(map[string]interface{})
var descriptions []string
for _, param := range jsonParams {
if param.Value != "" {
mergedValue[param.Name] = param.Value
}
if param.Description != "" {
descriptions = append(descriptions, param.Name+": "+param.Description)
}
}
// 创建合并后的参数
mergedValueJSON, _ := json.Marshal(mergedValue)
mergedParam := &M.Parameter{
EndpointID: endpoint.ID,
Name: "", // 空名称
Type: "body",
ParamType: "string",
Required: true,
Description: strings.Join(descriptions, "; "),
Example: "",
Value: string(mergedValueJSON),
}
if err := cfg.DB().Create(mergedParam).Error; err != nil {
return fmt.Errorf("failed to create merged json parameter: %v", err)
}
}
// 处理其他非application/json参数
locations := []string{"query", "path", "body"}
for _, location := range locations {
for _, param := range otherParams {
parameter := &M.Parameter{
EndpointID: endpoint.ID,
Name: param.Name,
Type: location,
ParamType: param.Type,
Required: true,
Description: param.Description,
Example: "",
Value: param.Value,
}
if err := cfg.DB().Create(parameter).Error; err != nil {
return fmt.Errorf("failed to create parameter: %v", err)
}
}
}
// 更新文档处理进度
progress := result.AnalysisPercent
if err := cfg.DB().Model(doc).Update("analysis_percent", progress).Error; err != nil {
da.logger.Printf("Failed to update progress: %v", err)
}
}
// 如果没有更多API要处理,退出循环
if !result.HasMore {
break
}
// 使用chat history继续对话
followUpPrompt := `请继续提取剩余的API信息,保持相同的输出格式。
请记住:
1. 不要重复之前已输出的接口
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("Attempt %d failed: %v, retrying...", retry+1, streamErr)
time.Sleep(time.Second * 30)
}
if streamErr != nil {
return fmt.Errorf("failed to send follow-up message after %d retries: %v", maxRetries, streamErr)
}
}
return nil
}
func readPDFLedong(path string) (string, error) {
f, r, err := pdf.Open(path)
// remember close file
defer func() {
err := f.Close()
if err != nil {
return
}
}()
if err != nil {
return "", err
}
var buf bytes.Buffer
b, err := r.GetPlainText()
if err != nil {
return "", err
}
buf.ReadFrom(b)
return buf.String(), nil
}
// DocProcessor handles document processing queue and analysis
type DocProcessor struct {
queue chan *DocTask
workers int
wg sync.WaitGroup
analyzer *DocumentAnalyzer
cancelTasks map[string]chan struct{}
taskMutex sync.RWMutex
}
// DocTask represents a document processing task
type DocTask struct {
Doc *M.Doc
FilePath string
}
var (
processor *DocProcessor
processorOnce sync.Once
)
// GetDocProcessor returns a singleton instance of DocProcessor
func GetDocProcessor() *DocProcessor {
processorOnce.Do(func() {
analyzer, err := NewDocumentAnalyzer()
if err != nil {
log.Fatalf("Failed to create document analyzer: %v", err)
}
processor = &DocProcessor{
queue: make(chan *DocTask, 100), // Buffer size of 100
workers: 1, // Number of concurrent workers
analyzer: analyzer,
cancelTasks: make(map[string]chan struct{}),
}
processor.Start()
})
return processor
}
func (p *DocProcessor) CancelDocProcessing(docID string) {
p.taskMutex.Lock()
defer p.taskMutex.Unlock()
if cancel, exists := p.cancelTasks[docID]; exists {
close(cancel)
delete(p.cancelTasks, docID)
}
}
// Start initializes the worker pool
func (p *DocProcessor) Start() {
for i := 0; i < p.workers; i++ {
p.wg.Add(1)
go p.worker()
}
}
// AddTask adds a new document to the processing queue
func (p *DocProcessor) AddTask(doc *M.Doc, filePath string) {
p.taskMutex.Lock()
// 为这个任务创建新的取消通道
cancelChan := make(chan struct{})
p.cancelTasks[doc.ID] = cancelChan
p.taskMutex.Unlock()
task := &DocTask{
Doc: doc,
FilePath: filePath,
}
p.queue <- task
}
// worker processes documents from the queue
func (p *DocProcessor) worker() {
defer p.wg.Done()
for task := range p.queue {
err := p.processDocument(task)
if err != nil {
log.Printf("Error processing document %s: %v", task.Doc.ID, err)
// Update document status to error
cfg.DB().Model(task.Doc).Updates(map[string]interface{}{
"analysis_completed": true,
"analysis_error": err.Error(),
})
}
}
}
// processDocument handles the actual document processing
func (p *DocProcessor) processDocument(task *DocTask) error {
defer func() {
p.taskMutex.Lock()
delete(p.cancelTasks, task.Doc.ID)
p.taskMutex.Unlock()
}()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
p.taskMutex.RLock()
cancelChan := p.cancelTasks[task.Doc.ID]
p.taskMutex.RUnlock()
select {
case <-cancelChan:
cancel() // 收到取消信号时取消上下文
case <-ctx.Done():
}
}()
doc := task.Doc
// Update initial status
if err := cfg.DB().Model(doc).Updates(map[string]interface{}{
"analysis_completed": false,
"analysis_percent": 0,
}).Error; err != nil {
return fmt.Errorf("failed to update initial status: %v", err)
}
// Read PDF content
content, err := readPDFLedong(task.FilePath)
if err != nil {
return fmt.Errorf("failed to read PDF: %v", err)
}
// Extract and process APIs
err = p.analyzer.extractAPIs(ctx, content, doc)
if err != nil {
if ctx.Err() != nil {
// 如果是因为取消导致的错误,记录日志但不返回错误
log.Printf("Document processing cancelled for doc ID: %s", task.Doc.ID)
return nil
}
return fmt.Errorf("failed to extract APIs: %v", err)
}
// Update final status
return cfg.DB().Model(doc).Updates(map[string]interface{}{
"analysis_completed": true,
"analysis_percent": 100,
}).Error
}
+760
View File
@@ -0,0 +1,760 @@
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)
}
+997
View File
@@ -0,0 +1,997 @@
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 ""
}
// 以下是直接复用成熟版本的函数
+250
View File
@@ -0,0 +1,250 @@
package doc
import (
"app/cfg"
"app/models"
"app/utils"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"github.com/veypi/OneBD/rest"
"gorm.io/gorm"
)
var _ = Router.Get("/:id", getHandle)
var _ = Router.Get("/:id/project", getProjectIDHandle)
var _ = Router.Get("", getHandle)
// 获取文档对应的项目ID
func getProjectIDHandle(x *rest.X) (any, error) {
utils.SetCORSHeaders(x)
// 获取文档ID
docID, ok := x.Params.Get("id")
if !ok {
return nil, errors.New("缺少文档ID")
}
// 查询文档信息
var doc models.Doc
if err := cfg.DB().Where("id = ?", docID).First(&doc).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("文档不存在")
}
return nil, fmt.Errorf("查询文档失败: %v", err)
}
return map[string]interface{}{
"doc_id": docID,
"project_id": doc.ProjectID,
}, nil
}
// 获取文档
func getHandle(x *rest.X) (any, error) {
utils.SetCORSHeaders(x)
id, ok := x.Params.Get("id")
// fmt.Println("getHandle", id)
if !ok {
// 获取所有文档
var docs []models.Doc
if err := cfg.DB().Find(&docs).Error; err != nil {
return nil, err
}
return docs, nil
}
// 获取特定文档
var docs []models.Doc
if err := cfg.DB().Where("project_id = ?", id).Find(&docs).Error; err != nil {
return nil, err
}
// fmt.Println("getHandle", doc)
return docs, nil
}
var _ = Router.Post("", postHandle)
// 创建文档
// 创建文档
func postHandle(x *rest.X) (any, error) {
utils.SetCORSHeaders(x)
// 解析表单数据
if err := x.Request.ParseMultipartForm(10 << 20); err != nil { // 10 MB
return nil, fmt.Errorf("解析表单失败: %v", err)
}
// 获取项目ID
projectID := x.Request.FormValue("project_id")
if projectID == "" {
return nil, errors.New("缺少project_id参数")
}
// 获取文档名称
name := x.Request.FormValue("name")
if name == "" {
return nil, errors.New("缺少name参数")
}
// 获取文档类型
docType := x.Request.FormValue("type")
if docType == "" {
docType = "other" // 默认类型
}
// 获取文档描述
description := x.Request.FormValue("description")
// 获取上传的文件
file, header, err := x.Request.FormFile("file")
if err != nil {
return nil, fmt.Errorf("获取文件失败: %v", err)
}
defer file.Close()
// 验证文件类型
fileExt := strings.ToLower(filepath.Ext(header.Filename))
allowedTypes := map[string]bool{
".docx": true,
".txt": true,
".pdf": true,
}
if !allowedTypes[fileExt] {
return nil, fmt.Errorf("不支持的文件类型: %s", fileExt)
}
// 创建上传目录
uploadDir := filepath.Join("uploads", "docs", projectID)
if err := os.MkdirAll(uploadDir, 0755); err != nil {
return nil, fmt.Errorf("创建上传目录失败: %v", err)
}
// 生成唯一文件名
fileName := fmt.Sprintf("%d_%s", time.Now().UnixNano(), header.Filename)
filePath := filepath.Join(uploadDir, fileName)
// 保存文件
dst, err := os.Create(filePath)
if err != nil {
return nil, fmt.Errorf("创建目标文件失败: %v", err)
}
defer dst.Close()
size, err := io.Copy(dst, file)
if err != nil {
os.Remove(filePath) // 清理失败的文件
return nil, fmt.Errorf("保存文件失败: %v", err)
}
// 创建文档记录
doc := &models.Doc{
ProjectID: projectID,
Name: name,
Description: description,
FilePath: filePath,
FileType: fileExt[1:], // 移除点号
FileSize: size,
FileName: header.Filename,
AnalysisCompleted: false,
AnalysisPercent: 0,
AnalysisError: "",
Merged: false,
}
if err := cfg.DB().Create(doc).Error; err != nil {
os.Remove(filePath) // 清理文件
return nil, fmt.Errorf("创建文档记录失败: %v", err)
}
// 将文档添加到处理队列
// GetDemandProcessor().AddTask(doc, filePath)
GetAgenticDemandProcessor().AddTask(doc, filePath)
return doc, nil
}
var _ = Router.Patch("/:id", patchHandle)
// 更新文档
func patchHandle(x *rest.X) (any, error) {
utils.SetCORSHeaders(x)
// 从URL获取ID
body, err := io.ReadAll(x.Request.Body)
if err != nil {
return nil, fmt.Errorf("读取请求体失败: %v", err)
}
id, ok := x.Params.Get("id")
if !ok {
return nil, errors.New("缺少id")
}
var updateMap map[string]interface{}
if err := json.Unmarshal(body, &updateMap); err != nil {
return nil, fmt.Errorf("解析JSON失败: %v", err)
}
// 先检查文档是否存在
var doc models.Doc
if err := cfg.DB().Where("id = ?", id).First(&doc).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("文档不存在")
}
return nil, err
}
// 删除不允许更新的字段
delete(updateMap, "id")
// 只更新提供的字段
if len(updateMap) > 0 {
if err := cfg.DB().Model(&doc).Updates(updateMap).Error; err != nil {
return nil, err
}
}
// 重新查询更新后的完整数据
if err := cfg.DB().Where("id = ?", id).First(&doc).Error; err != nil {
return nil, err
}
return doc, nil
}
var _ = Router.Delete("/:id", deleteHandle)
// 删除文档
func deleteHandle(x *rest.X) (any, error) {
utils.SetCORSHeaders(x)
id, ok := x.Params.Get("id")
if !ok {
return nil, errors.New("缺少id")
}
var doc models.Doc
if err := cfg.DB().Where("id = ?", id).First(&doc).Error; err != nil {
return nil, err
}
// 删除文档
if err := cfg.DB().Delete(&doc).Error; err != nil {
return nil, err
}
return map[string]interface{}{
"msg": "删除成功",
}, nil
}
var _ = Router.Any("/*", anyHandle)
func anyHandle(x *rest.X) (any, error) {
utils.SetCORSHeaders(x)
return nil, nil
}
+11
View File
@@ -0,0 +1,11 @@
package doc
import (
"github.com/veypi/OneBD/rest"
)
var Router = rest.NewRouter()
func init() {
// 初始化路由
}
View File