initial public release
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user