2026-04-07 13:14:52 -07:00

134 lines
4.6 KiB
Go

package services
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"strings"
"time"
"github.com/dslipak/pdf"
"github.com/openai/openai-go/v3"
"github.com/openai/openai-go/v3/option"
"git.gophernest.net/azpect/ResumeLens/internal/models"
)
var errOpenAINoChoices = errors.New("OpenAI returned no choices")
var chatCompletionRequest = func(ctx context.Context, apiKey string, params openai.ChatCompletionNewParams) (*openai.ChatCompletion, error) {
client := openai.NewClient(option.WithAPIKey(apiKey))
return client.Chat.Completions.New(ctx, params)
}
// 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")
}
params := openai.ChatCompletionNewParams{
Messages: []openai.ChatCompletionMessageParamUnion{
openai.SystemMessage(SystemPrompt),
openai.UserMessage("Job Description:\n" + jobDescription),
openai.UserMessage("Resume:\n" + resumeText),
},
Model: openai.ChatModelGPT4oMini,
}
// 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
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
completion, err := chatCompletionRequest(ctx, apiKey, params)
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, errOpenAINoChoices
}
// 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
}