init
This commit is contained in:
@@ -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, ¶m)
|
||||
otherParams = append(otherParams, ¶m)
|
||||
}
|
||||
|
||||
// 如果有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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
// 以下是直接复用成熟版本的函数
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package doc
|
||||
|
||||
import (
|
||||
"github.com/veypi/OneBD/rest"
|
||||
)
|
||||
|
||||
var Router = rest.NewRouter()
|
||||
|
||||
func init() {
|
||||
// 初始化路由
|
||||
}
|
||||
Reference in New Issue
Block a user