本网站可以通过分类标签帮助你快速筛选出你想看的文章,记住地址:www.Facec.cc

Eino ADK 学习小记

package main

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"log"
	"strings"
	"time"

	"code.byted.org/wangdefeng.1/wangdefeng/app/enio/cmd/pkg/stream"
	"code.byted.org/wangdefeng.1/wangdefeng/app/enio/cmd/pkg/tool"
	"code.byted.org/wangdefeng.1/wangdefeng/conf"
	"github.com/cloudwego/eino-ext/components/model/deepseek"
	"github.com/cloudwego/eino-ext/components/model/openai"
	"github.com/cloudwego/eino/adk"
	"github.com/cloudwego/eino/components/model"
	einoTool "github.com/cloudwego/eino/components/tool"
	"github.com/cloudwego/eino/compose"
	"github.com/cloudwego/eino/schema"
)

// ResumeParseResult 简历解析结果
type ResumeParseResult struct {
	BasicInfo struct {
		Name      string `json:"name"`
		WorkYears string `json:"work_years"`
		Contact   string `json:"contact"`
	} `json:"basic_info"`
	Education []struct {
		School         string `json:"school"`
		Major          string `json:"major"`
		Degree         string `json:"degree"`
		GraduationYear string `json:"graduation_year"`
	} `json:"education"`
	WorkExperience []struct {
		Company          string `json:"company"`
		Position         string `json:"position"`
		Duration         string `json:"duration"`
		Responsibilities string `json:"responsibilities"`
	} `json:"work_experience"`
	TechStack                   []string      `json:"tech_stack"`
	Projects                    []interface{} `json:"projects"`
	Skills                      []string      `json:"skills"`
	Certifications              []string      `json:"certifications"`
	Strengths                   string        `json:"strengths"`
	PotentialWeaknesses         string        `json:"potential_weaknesses"`
	RecommendedDifficulty       string        `json:"recommended_difficulty"`
	InterviewFocusAreas         []string      `json:"interview_focus_areas"`
	SuggestedQuestionDirections []string      `json:"suggested_questions_directions"`
}

func main() {
	//TestTool()
	//TestChatCallTool()
	TestAgent()
}

func TestTool() {
	ctx := context.Background()

	res, err := tool.ConvertPDFToText(ctx, &tool.PDFToTextRequest{
		FilePath: "/Users/bytedance/Desktop/bytedance/code/wangdefeng/wangdefeng/app/enio/cmd/12-adk/wangdefeng.pdf",
		ToPages:  false,
	})
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(res)
}

func TestChatCallTool() {
	ctx := context.Background()
	chatModel, err := deepseek.NewChatModel(ctx, &deepseek.ChatModelConfig{
		APIKey:  conf.DeepseekAPIKey,
		BaseURL: "https://api.deepseek.com",
		Model:   "deepseek-chat",
	})

	pdfTool := tool.CreatePDFToTextTool()
	todoToolInfo, _ := pdfTool.Info(ctx)

	err = chatModel.BindTools([]*schema.ToolInfo{todoToolInfo})
	if err != nil {
		log.Fatal(err)
	}

	// 创建 tools 节点
	todoToolsNode, err := compose.NewToolNode(context.Background(), &compose.ToolsNodeConfig{
		Tools: []einoTool.BaseTool{pdfTool},
	})
	if err != nil {
		log.Fatal(err)
	}

	// 构建完整的处理链
	chain := compose.NewChain[[]*schema.Message, []*schema.Message]()
	chain.
		AppendChatModel(chatModel, compose.WithNodeName("chat_model")). // []*schema.Message    ->  *schema.Message
		AppendToolsNode(todoToolsNode, compose.WithNodeName("tools"))   // *schema.Message    ->  []*schema.Message

	// 编译并运行 chain
	agent, err := chain.Compile(ctx)
	if err != nil {
		log.Fatal(err)
	}

	// 运行示例
	resp, err := agent.Invoke(ctx, []*schema.Message{
		{
			Role:    schema.System,
			Content: "请调用工具pdf_to_text,将本地PDF文件转换为纯文本,路径 /Users/bytedance/Desktop/bytedance/code/wangdefeng/wangdefeng/app/enio/cmd/12-adk/wangdefeng.pdf",
		},
	})
	if err != nil {
		log.Fatal(err)
	}

	// 输出结果
	for _, msg := range resp {
		fmt.Println(msg.Content)
	}
}

func TestAgent() {
	ctx := context.Background()
	ParseResumeAndSave(ctx, "/Users/bytedance/Desktop/bytedance/code/wangdefeng/wangdefeng/app/enio/cmd/12-adk/wangdefeng.pdf", 0)
}

func ParseResumeAndSave(ctx context.Context, resumeFilePath string, fileSize int64) (*ResumeParseResult, error) {
	var userId uint = 0

	// 添加 120 秒超时
	timeoutCtx, cancel := context.WithTimeout(ctx, 120*time.Second)
	defer cancel()

	// 创建简历解析智能体
	agent, err := NewResumeParserAgent(userId)
	if err != nil {
		log.Printf("[ParseResumeAndSave] 创建简历解析智能体失败: %v", err)
		return nil, err
	}

	// 创建 runner
	runner := adk.NewRunner(timeoutCtx, adk.RunnerConfig{
		Agent:           agent,
		EnableStreaming: true, //开启流式输出,开启必须使用MessageStream接受并处理
	})

	// 构建查询消息,包含简历文件路径
	query := fmt.Sprintf(`【重要】请立即解析以下简历文件并提取关键信息:

简历文件路径:%s

【必须执行的步骤】:
1. 【第一步】立即使用 pdf_to_text 工具解析简历文件,获取完整的简历文本内容
2. 【第二步】从解析的简历文本中提取所有关键信息(姓名、工作年限、联系方式、教育背景、工作经历、技术栈、项目经验、技能、证书等)
3. 【第三步】分析候选人的背景特点和核心竞争力
4. 【第四步】生成面试建议和推荐难度

【重要提示】:
- 不要跳过 pdf_to_text 工具调用
- 必须从简历内容中提取真实的信息,不要返回空数据
- 所有JSON字段都必须填充实际内容
- 只返回JSON格式,不要返回其他文本

请返回完整的 JSON 格式结果。`, resumeFilePath)

	// 创建用户消息
	userMsg := &schema.Message{
		Role:    schema.System,
		Content: query,
	}

	messages := []adk.Message{
		userMsg,
	}

	// 运行智能体
	iter := runner.Run(timeoutCtx, messages)

	var lastMessage string
	for {
		select {
		case <-timeoutCtx.Done():
			log.Printf("[ParseResumeAndSave] 超时:等待智能体响应超过 120 秒")
			return nil, fmt.Errorf("timeout waiting for resume parsing (120s)")
		default:
		}

		event, ok := iter.Next()
		if !ok {
			break
		}

		if event.Err != nil {
			log.Printf("[ParseResumeAndSave] 错误: %v", event.Err)
			return nil, fmt.Errorf("error during resume parsing: %w", event.Err)
		}

		// 收集最后一条消息
		if event.Output != nil && event.Output.MessageOutput != nil {
			if event.Output.MessageOutput.Message != nil {
				lastMessage = event.Output.MessageOutput.Message.Content
			}

			if event.Output.MessageOutput.MessageStream != nil {
				lastMessage, err = stream.RecvStreamData(event.Output.MessageOutput.MessageStream)
			}
		}
	}

	// 解析智能体响应
	if lastMessage == "" {
		log.Printf("[ParseResumeAndSave] 智能体未返回任何响应")
		return nil, fmt.Errorf("agent returned empty response")
	}

	log.Printf("[ParseResumeAndSave] 智能体响应内容: %s", lastMessage)
	parseResult := parseResumeResponse(lastMessage)
	if parseResult == nil {
		log.Printf("[ParseResumeAndSave] 无法解析简历响应")
		return nil, fmt.Errorf("failed to parse resume response")
	}

	// 验证解析结果是否有效(不能全是空数据)
	if !isValidResumeResult(parseResult) {
		log.Printf("[ParseResumeAndSave] 解析结果无效(全是空数据),请检查简历文件是否正确")
		return nil, fmt.Errorf("resume parsing result is empty or invalid")
	}

	return parseResult, nil
}

func NewResumeParserAgent(userId uint) (adk.Agent, error) {
	ctx := context.Background()
	toolChatModel, err := CreatOpenAiChatModel(ctx, userId)
	if err != nil {
		return nil, fmt.Errorf("failed to create OpenAI chat model: %w", err)
	}

	baseAgent, err := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{
		Name:        "ResumeParserAgent",
		Description: "一个专业的简历解析智能体,用于提取简历中的关键信息",
		Instruction: `你是一个专业的简历分析专家。你的任务是解析候选人的简历,提取关键信息用于面试准备。

重要提示:
- 你必须使用 pdf_to_text 工具来解析简历文件
- 不要跳过工具调用,直接返回空数据
- 必须从简历内容中提取真实的信息
- 只返回JSON格式,不要返回其他文本

任务步骤(必须按顺序执行):
1. 【必须】使用 pdf_to_text 工具解析提供的简历文件路径,获取简历的完整文本内容
2. 从解析的简历文本中提取以下关键信息:
   - 基本信息(姓名、联系方式、工作年限等)
   - 教育背景(学校、专业、学位等)
   - 工作经历(公司、职位、工作时间、主要职责等)
   - 技术栈(编程语言、框架、工具等)
   - 项目经验(项目名称、技术栈、个人贡献等)
   - 技能特长(核心竞争力、专业技能等)
   - 证书资格(获得的证书、资格认证等)

3. 分析候选人的背景特点:
   - 主要技术方向
   - 行业经验
   - 职业发展轨迹
   - 核心竞争力

4. 生成面试建议:
   - 重点关注的技术领域
   - 可能的深入提问方向
   - 候选人的优势和潜在弱点
   - 推荐的面试难度级别

5. 返回完整的JSON结果,确保所有字段都有实际内容

必须返回的JSON格式(所有字段都必须填充实际数据):
{
  "basic_info": {
    "name": "从简历中提取的真实姓名",
    "work_years": "从简历中提取的工作年限",
    "contact": "从简历中提取的联系方式"
  },
  "education": [
    {
      "school": "学校名称",
      "major": "专业",
      "degree": "学位",
      "graduation_year": "毕业年份"
    }
  ],
  "work_experience": [
    {
      "company": "公司名称",
      "position": "职位",
      "duration": "工作时间段",
      "responsibilities": "主要职责"
    }
  ],
  "tech_stack": ["技术1", "技术2", "技术3"],
  "projects": [
    {
      "name": "项目名称",
      "description": "项目描述",
      "tech_stack": ["技术1", "技术2"],
      "contribution": "个人贡献"
    }
  ],
  "skills": ["技能1", "技能2", "技能3"],
  "certifications": ["证书1", "证书2"],
  "strengths": "候选人的核心优势",
  "potential_weaknesses": "可能的弱点或不足",
  "recommended_difficulty": "推荐面试难度(初级/中级/高级)",
  "interview_focus_areas": ["重点关注领域1", "重点关注领域2"],
  "suggested_questions_directions": ["提问方向1", "提问方向2"]
}`,

		Model: toolChatModel,
		ToolsConfig: adk.ToolsConfig{
			ToolsNodeConfig: compose.ToolsNodeConfig{
				Tools: []einoTool.BaseTool{
					tool.CreatePDFToTextTool(),
				},
			},
		},
		MaxIterations: 20,
	})
	if err != nil {
		return nil, fmt.Errorf("failed to create resume parser agent: %w", err)
	}
	return baseAgent, nil
}

func CreatOpenAiChatModel(ctx context.Context, userId uint) (model.ToolCallingChatModel, error) {
	//deepseek 支持function call
	chatModel, err := openai.NewChatModel(ctx, &openai.ChatModelConfig{
		APIKey:  conf.DeepseekAPIKey,
		Model:   "deepseek-chat",
		BaseURL: "https://api.deepseek.com",
	})

	if err != nil {
		// Check for specific error types and wrap them appropriately
		errMsg := strings.ToLower(err.Error())
		switch {
		case strings.Contains(errMsg, "insufficient_quota") ||
			strings.Contains(errMsg, "billing_not_active") ||
			strings.Contains(errMsg, "quota_exceeded") ||
			strings.Contains(errMsg, "insufficient tokens"):
			return nil, errors.New("Model API: Insufficient tokens or quota exceeded. Please check your account balance." + err.Error())

		case strings.Contains(errMsg, "rate_limit_exceeded") ||
			strings.Contains(errMsg, "too_many_requests") ||
			strings.Contains(errMsg, "rate limit"):
			return nil, errors.New("Model API: Rate limit exceeded. Please try again later." + err.Error())

		case strings.Contains(errMsg, "context_length_exceeded") ||
			strings.Contains(errMsg, "maximum context length") ||
			strings.Contains(errMsg, "token limit"):
			return nil, errors.New("Model API: Context length exceeded. Please try with shorter input." + err.Error())
		default:
			return nil, errors.New("Failed to create OpenAI chat model" + err.Error())
		}
	}
	return &LoggingToolCallingChatModel{inner: chatModel}, nil
}

func parseResumeResponse(agentResponse string) *ResumeParseResult {
	result := &ResumeParseResult{}

	// 尝试直接解析 JSON
	if err := json.Unmarshal([]byte(agentResponse), result); err != nil {
		log.Printf("[parseResumeResponse] 直接解析 JSON 失败: %v,尝试提取 JSON", err)
		// 尝试从文本中提取 JSON
		jsonStr := ExtractJSONFromResponse(agentResponse)
		if jsonStr == "" {
			log.Printf("[parseResumeResponse] 无法提取 JSON,原始响应: %s", agentResponse)
			return nil
		}

		log.Printf("[parseResumeResponse] 提取的 JSON: %s", jsonStr)
		// 尝试解析提取的 JSON
		if err := json.Unmarshal([]byte(jsonStr), result); err != nil {
			log.Printf("[parseResumeResponse] 解析提取的 JSON 失败: %v", err)
			return nil
		}
	}

	return result
}
func ExtractJSONFromResponse(text string) string {
	// 查找对象格式 {...}
	start := -1
	braceCount := 0

	for i := 0; i < len(text); i++ {
		if text[i] == '{' {
			if start == -1 {
				start = i
			}
			braceCount++
		} else if text[i] == '}' {
			braceCount--
			if start != -1 && braceCount == 0 {
				jsonStr := text[start : i+1]
				// 尝试验证 JSON 是否有效
				var temp interface{}
				if err := json.Unmarshal([]byte(jsonStr), &temp); err == nil {
					return jsonStr
				}
			}
		}
	}

	return ""
}

func isValidResumeResult(result *ResumeParseResult) bool {
	if result == nil {
		return false
	}

	// 检查基本信息是否有内容
	if result.BasicInfo.Name != "" || result.BasicInfo.WorkYears != "" || result.BasicInfo.Contact != "" {
		return true
	}

	// 检查教育背景
	if len(result.Education) > 0 {
		return true
	}

	// 检查工作经历
	if len(result.WorkExperience) > 0 {
		return true
	}

	// 检查技术栈
	if len(result.TechStack) > 0 {
		return true
	}

	// 检查项目经验
	if len(result.Projects) > 0 {
		return true
	}

	// 检查技能
	if len(result.Skills) > 0 {
		return true
	}

	// 检查证书
	if len(result.Certifications) > 0 {
		return true
	}

	// 检查其他字段
	if result.Strengths != "" || result.PotentialWeaknesses != "" || result.RecommendedDifficulty != "" {
		return true
	}

	// 检查面试关注领域
	if len(result.InterviewFocusAreas) > 0 {
		return true
	}

	// 检查建议的提问方向
	if len(result.SuggestedQuestionDirections) > 0 {
		return true
	}

	// 如果所有字段都是空的,返回 false
	return false
}

// xxx
type LoggingToolCallingChatModel struct {
	inner model.ToolCallingChatModel
}

func (m *LoggingToolCallingChatModel) Generate(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.Message, error) {
	// 打印原始输入
	b, _ := json.MarshalIndent(input, "", "  ")
	log.Printf("[LLM GENERATE INPUT]\n%s\n", string(b))

	out, err := m.inner.Generate(ctx, input, opts...)

	// 打印原始输出
	if out != nil {
		ob, _ := json.MarshalIndent(out, "", "  ")
		log.Printf("[LLM GENERATE OUTPUT]\n%s\n", string(ob))
	}
	if err != nil {
		log.Printf("[LLM GENERATE ERROR] %v", err)
	}
	return out, err
}

func (m *LoggingToolCallingChatModel) Stream(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.StreamReader[*schema.Message], error) {
	b, _ := json.MarshalIndent(input, "", "  ")
	log.Printf("[LLM STREAM INPUT]\n%s\n", string(b))

	sr, err := m.inner.Stream(ctx, input, opts...)
	if err != nil {
		log.Printf("[LLM STREAM ERROR] %v", err)
		return nil, err
	}
	// 这里如果你也想看到流式输出,可以再包一层 StreamReader,在 Recv 时打印 chunk
	return sr, nil
}

func (m *LoggingToolCallingChatModel) WithTools(tools []*schema.ToolInfo) (model.ToolCallingChatModel, error) {
	// 工具绑定直接透传
	newInner, err := m.inner.WithTools(tools)
	if err != nil {
		return nil, err
	}
	return &LoggingToolCallingChatModel{inner: newInner}, nil
}

评论