243 lines
6.4 KiB
Go
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
|
|
}
|