123 lines
4.4 KiB
Go
123 lines
4.4 KiB
Go
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
|
|
}
|