package services import ( "bytes" "context" "encoding/json" "fmt" "io" "os" "strings" "github.com/dslipak/pdf" "github.com/openai/openai-go/v3" "github.com/openai/openai-go/v3/option" "git.gophernest.net/azpect/ResumeLens/internal/models" ) // AnalyzeResume extracts text from the uploaded PDF, sends it along with the // job description to the OpenAI API, and returns the structured analysis result. // Trace: SDD_HLD_0002 - Extract full textual content from resume PDF // Trace: SDD_HLD_0005 - Provide structured inputs to AI grader func AnalyzeResume(resume io.Reader, jobDescription string) (*models.AnalysisResult, error) { // Trace: SDD_HLD_0003 - Convert resume contents into string format for AI processing resumeText, err := extractPDFText(resume) if err != nil { return nil, fmt.Errorf("extracting PDF text: %w", err) } // Trace: SDD_HLD_0008 - Generate graded evaluation output result, err := callLLM(resumeText, jobDescription) if err != nil { return nil, fmt.Errorf("calling LLM: %w", err) } return result, nil } // extractPDFText reads all pages of the PDF and returns the concatenated plain text. // Trace: SDD_LLD_0001 - Confirm file extensions and MIME types (PDF validation) // Trace: SDD_LLD_0003 - Iteratively read through every page of the document // Trace: SDD_HLD_0002 - Read all pages of the input PDF resume // Trace: SDD_HLD_0003 - Read all words on pages and transfer to string func extractPDFText(r io.Reader) (string, error) { // dslipak/pdf requires io.ReaderAt and a size, so we buffer into memory first. data, err := io.ReadAll(r) if err != nil { return "", fmt.Errorf("reading PDF bytes: %w", err) } // Trace: SDD_LLD_0001 - Parse and validate PDF format rs, err := pdf.NewReader(bytes.NewReader(data), int64(len(data))) if err != nil { return "", fmt.Errorf("parsing PDF: %w", err) } // Trace: SDD_LLD_0003 - Iteratively read through every page and concatenate text var sb strings.Builder for i := 1; i <= rs.NumPage(); i++ { page := rs.Page(i) if page.V.IsNull() { continue } text, err := page.GetPlainText(nil) if err != nil { return "", fmt.Errorf("reading page %d: %w", i, err) } sb.WriteString(text) } return sb.String(), nil } // callLLM sends the resume text and job description to OpenAI and unmarshals // the JSON response into an AnalysisResult. // Trace: SDD_LLD_0010 - Securely load/store API key from environment variables // Trace: SDD_LLD_0012 - Submit prompt to OpenAI completions endpoint via HTTPS POST // Trace: SDD_HLD_0015 - Accept API Key to run system // Trace: SDD_HLD_0007 - Remain hidden and never expose API key to user func callLLM(resumeText, jobDescription string) (*models.AnalysisResult, error) { // Trace: SDD_LLD_0010 - Verifies requests using credentials loaded from environment variables // Trace: SDD_HLD_0007 - Securely manage API credentials apiKey := os.Getenv("OPENAI_API_KEY") if apiKey == "" { return nil, fmt.Errorf("OPENAI_API_KEY environment variable is not set") } // Trace: SDD_HLD_0015 - Authenticate requests to AI service through credential client := openai.NewClient(option.WithAPIKey(apiKey)) // Trace: SDD_LLD_0012 - Execute OpenAI API requests // Trace: SDD_LLD_0007 - Merge user-provided resume text and job description into query // Trace: SDD_HLD_0005 - Accept string inputs from Resume, Job Description, and Grading Prompt completion, err := client.Chat.Completions.New(context.Background(), openai.ChatCompletionNewParams{ Messages: []openai.ChatCompletionMessageParamUnion{ openai.SystemMessage(SystemPrompt), openai.UserMessage("Job Description:\n" + jobDescription), openai.UserMessage("Resume:\n" + resumeText), }, Model: openai.ChatModelGPT4oMini, }) if err != nil { // Trace: SDD_LLD_0013 - Catches and translates external API errors return nil, fmt.Errorf("OpenAI request: %w", err) } if len(completion.Choices) == 0 { return nil, fmt.Errorf("OpenAI returned no choices") } // Trace: SDD_HLD_0008 - Produce string text output of graded resume raw := completion.Choices[0].Message.Content // Trace: SDD_LLD_0015 - Parse AI output into structured fields // Trace: SDD_LLD_0019 - Marshal completed evaluation data into JSON var result models.AnalysisResult if err := json.Unmarshal([]byte(raw), &result); err != nil { return nil, fmt.Errorf("parsing LLM response as JSON: %w\nraw response: %s", err, raw) } return &result, nil }