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
}