Files
apex-public/internal/cv/cv.go
T
2026-07-06 11:05:50 -04:00

243 lines
6.4 KiB
Go

package cv
import (
"bytes"
"context"
"database/sql"
"embed"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/cobr-ai/apex/internal/eval"
)
//go:embed prompts/tailor.md templates/cv-template.html
var embeddedFS embed.FS
// CVResult holds the generated file paths.
type CVResult struct {
HTMLPath string
PDFPath string
}
// HTMLExporter generates a tailored HTML CV + PDF via Chromium.
type HTMLExporter struct {
DB *sql.DB
CVPath string // path to cv.md (defaults to ~/.apex/cv.md)
OutputDir string // defaults to ~/.apex/cv/
}
// Generate creates a tailored HTML CV (and optionally PDF) for the given job URL.
// Flow: query DB for JD → read cv.md → load profile → read tailor prompt →
// call Claude → render HTML → call Chromium for PDF.
func (e *HTMLExporter) Generate(ctx context.Context, jobURL string) (CVResult, error) {
cvPath := e.CVPath
if cvPath == "" {
home, err := os.UserHomeDir()
if err != nil {
return CVResult{}, fmt.Errorf("expand home: %w", err)
}
cvPath = filepath.Join(home, ".apex", "cv.md")
}
outputDir := e.OutputDir
if outputDir == "" {
home, err := os.UserHomeDir()
if err != nil {
return CVResult{}, fmt.Errorf("expand home: %w", err)
}
outputDir = filepath.Join(home, ".apex", "cv")
}
// Ensure output directory exists
if err := os.MkdirAll(outputDir, 0700); err != nil {
return CVResult{}, fmt.Errorf("mkdir %s: %w", outputDir, err)
}
// Query jobs table for company, title, description by URL
var company, title, description string
err := e.DB.QueryRowContext(ctx, `
SELECT company, title, COALESCE(description, '')
FROM jobs
WHERE url = ?
LIMIT 1
`, jobURL).Scan(&company, &title, &description)
if err != nil {
if err == sql.ErrNoRows {
return CVResult{}, fmt.Errorf("job not found: %s", jobURL)
}
return CVResult{}, fmt.Errorf("query job: %w", err)
}
// Read cv.md
cvContent, err := os.ReadFile(cvPath)
if err != nil {
return CVResult{}, fmt.Errorf("read cv.md: %w", err)
}
// Load profile from DB
var name, email, location string
err = e.DB.QueryRowContext(ctx, `
SELECT name, email, location FROM profile LIMIT 1
`).Scan(&name, &email, &location)
if err != nil && err != sql.ErrNoRows {
return CVResult{}, fmt.Errorf("query profile: %w", err)
}
// Read tailor.md prompt template
promptTpl, err := embeddedFS.ReadFile("prompts/tailor.md")
if err != nil {
return CVResult{}, fmt.Errorf("read tailor prompt: %w", err)
}
// Substitute placeholders in prompt
prompt := strings.NewReplacer(
"{{CV}}", string(cvContent),
"{{JD}}", description,
"{{NAME}}", name,
"{{EMAIL}}", email,
"{{LOCATION}}", location,
).Replace(string(promptTpl))
// Call Claude CLI to generate tailored CV HTML
html, err := e.callClaude(ctx, prompt)
if err != nil {
return CVResult{}, fmt.Errorf("call claude: %w", err)
}
// Generate filenames
now := time.Now().Format("2006-01-02")
companySlug := eval.Slugify(company)
nameSlug := eval.Slugify(name)
baseFilename := fmt.Sprintf("cv-%s-%s-%s", nameSlug, companySlug, now)
htmlPath := filepath.Join(outputDir, baseFilename+".html")
pdfPath := filepath.Join(outputDir, baseFilename+".pdf")
// Write HTML file
if err := os.WriteFile(htmlPath, []byte(html), 0600); err != nil {
return CVResult{}, fmt.Errorf("write html: %w", err)
}
// Generate PDF via Chromium if available
if err := e.generatePDF(htmlPath, pdfPath); err != nil {
// PDF generation is best-effort; don't fail the entire operation
fmt.Fprintf(os.Stderr, "pdf generation failed (continuing with HTML only): %v\n", err)
}
return CVResult{
HTMLPath: htmlPath,
PDFPath: pdfPath,
}, nil
}
// callClaude runs the Claude CLI with the given prompt and returns raw HTML output.
// Timeout: 10 minutes for full document generation.
func (e *HTMLExporter) callClaude(ctx context.Context, prompt string) (string, error) {
timeoutCtx, cancel := context.WithTimeout(ctx, 10*time.Minute)
defer cancel()
cmd := exec.CommandContext(timeoutCtx, "claude", "--print", "--output-format", "text")
stdin, err := cmd.StdinPipe()
if err != nil {
return "", fmt.Errorf("stdin pipe: %w", err)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return "", fmt.Errorf("stdout pipe: %w", err)
}
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Start(); err != nil {
return "", fmt.Errorf("start claude: %w", err)
}
// Feed prompt via stdin in a goroutine
go func() {
defer stdin.Close()
_, _ = io.WriteString(stdin, prompt)
}()
// Read output
output, err := io.ReadAll(stdout)
if err != nil {
return "", fmt.Errorf("read stdout: %w", err)
}
if err := cmd.Wait(); err != nil {
if timeoutCtx.Err() == context.DeadlineExceeded {
return "", errors.New("claude timeout (10 minutes)")
}
return "", fmt.Errorf("claude exit: %w (stderr: %s)", err, stderr.String())
}
html := string(output)
if strings.TrimSpace(html) == "" {
return "", errors.New("claude returned empty output")
}
return html, nil
}
// generatePDF uses Chromium to convert HTML to PDF.
// Returns early if chromium not found; it's optional.
func (e *HTMLExporter) generatePDF(htmlPath, pdfPath string) error {
// Resolve symlinks on the output directory (file doesn't exist yet so we
// can only canonicalize the dir). This prevents symlink traversal bypasses
// that filepath.Clean alone would miss.
realDir, err := filepath.EvalSymlinks(filepath.Dir(htmlPath))
if err != nil {
realDir = filepath.Clean(filepath.Dir(htmlPath))
}
absPDF := filepath.Clean(pdfPath)
if !strings.HasPrefix(absPDF, realDir+string(os.PathSeparator)) {
return fmt.Errorf("pdf path escapes output directory")
}
chromiumPath := "/usr/bin/chromium"
if _, err := os.Stat(chromiumPath); err != nil {
return fmt.Errorf("chromium not found at %s", chromiumPath)
}
// Convert to absolute path if needed
absHTMLPath, err := filepath.Abs(htmlPath)
if err != nil {
absHTMLPath = htmlPath
}
// Chromium needs file:// URL
fileURL := "file://" + absHTMLPath
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx,
chromiumPath,
"--headless=new",
"--disable-gpu",
"--no-sandbox",
fmt.Sprintf("--print-to-pdf=%s", pdfPath),
fileURL,
)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("chromium: %w (stderr: %s)", err, stderr.String())
}
return nil
}