Files
2026-07-06 11:05:50 -04:00

299 lines
9.4 KiB
Go

package eval
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
// slugRE strips anything outside [a-z0-9-] after lowercasing + dash replacement.
// Prevents path traversal and filesystem-unsafe characters from company names.
var slugRE = regexp.MustCompile(`[^a-z0-9-]+`)
// collapseDashRE collapses runs of dashes so "Acme & Co" → "acme-co" not "acme---co".
var collapseDashRE = regexp.MustCompile(`-+`)
// Slugify produces a filesystem-safe slug from a company or role name.
// Exported for reuse by any caller that needs to construct report paths.
func Slugify(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
s = strings.ReplaceAll(s, " ", "-")
s = strings.ReplaceAll(s, "_", "-")
s = slugRE.ReplaceAllString(s, "")
s = collapseDashRE.ReplaceAllString(s, "-")
s = strings.Trim(s, "-")
if s == "" {
return "unknown"
}
if len(s) > 64 {
s = s[:64]
}
return s
}
// Persistence is the shape ReportWriter needs from the DB layer. Declared as
// an interface so tests can stub without a real SQLite instance.
type Persistence interface {
BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error)
}
// ReportWriter renders Evaluation → markdown + persists to disk and SQLite.
// ReportsDir defaults to $HOME/.apex/reports if empty at Save time.
type ReportWriter struct {
DB Persistence
ReportsDir string
}
// Saved describes everything needed to reference a persisted report from the
// TUI or follow-up queries.
type Saved struct {
Seq int
FilePath string
ApplicationID int64
ReportID int64
}
// Save persists an Evaluation end-to-end:
// 1. Upsert the jobs row (URL unique key).
// 2. Insert/update the applications row (one per job).
// 3. Allocate the next sequential seq for the reports row.
// 4. Render markdown to $REPORTS_DIR/{seq}-{slug}-{YYYY-MM-DD}.md.
// 5. Insert the reports row with file_path + per-block columns.
//
// The filesystem write happens inside the DB transaction so a mid-way failure
// leaves neither partial disk artifact nor orphan DB row. Any pre-existing
// file at the target path is replaced atomically via temp+rename.
func (w *ReportWriter) Save(ctx context.Context, job JobContext, eval Evaluation) (Saved, error) {
if w.DB == nil {
return Saved{}, fmt.Errorf("report writer: nil DB")
}
dir := w.ReportsDir
if dir == "" {
home, err := os.UserHomeDir()
if err != nil {
return Saved{}, fmt.Errorf("resolve home: %w", err)
}
dir = filepath.Join(home, ".apex", "reports")
}
dir = filepath.Clean(dir)
if err := os.MkdirAll(dir, 0o755); err != nil {
return Saved{}, fmt.Errorf("mkdir reports: %w", err)
}
tx, err := w.DB.BeginTx(ctx, nil)
if err != nil {
return Saved{}, fmt.Errorf("begin tx: %w", err)
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback()
}
}()
jobID, err := upsertJob(ctx, tx, job)
if err != nil {
return Saved{}, err
}
appID, err := upsertApplication(ctx, tx, jobID, eval)
if err != nil {
return Saved{}, err
}
seq, err := nextReportSeq(ctx, tx)
if err != nil {
return Saved{}, err
}
slug := Slugify(job.Company)
date := time.Now().Format("2006-01-02")
filename := fmt.Sprintf("%03d-%s-%s.md", seq, slug, date)
fullPath := filepath.Join(dir, filename)
md := RenderMarkdown(job, eval, seq, date)
if err := atomicWrite(fullPath, []byte(md)); err != nil {
return Saved{}, fmt.Errorf("write report file: %w", err)
}
reportID, err := insertReport(ctx, tx, appID, seq, slug, fullPath, md, eval)
if err != nil {
// Best-effort cleanup of the disk artifact so we don't orphan it.
_ = os.Remove(fullPath)
return Saved{}, err
}
if err := tx.Commit(); err != nil {
_ = os.Remove(fullPath)
return Saved{}, fmt.Errorf("commit: %w", err)
}
committed = true
return Saved{Seq: seq, FilePath: fullPath, ApplicationID: appID, ReportID: reportID}, nil
}
// upsertJob ensures a row exists in jobs for this URL. Returns its id.
// Uses INSERT ... ON CONFLICT(url) DO UPDATE to keep title/description fresh.
func upsertJob(ctx context.Context, tx *sql.Tx, job JobContext) (int64, error) {
_, err := tx.ExecContext(ctx, `
INSERT INTO jobs (url, company, title, description, source)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(url) DO UPDATE SET
company = excluded.company,
title = excluded.title,
description = COALESCE(NULLIF(excluded.description, ''), jobs.description)
`, job.URL, job.Company, job.Title, job.JDText, job.Source)
if err != nil {
return 0, fmt.Errorf("upsert job: %w", err)
}
var id int64
if err := tx.QueryRowContext(ctx, `SELECT id FROM jobs WHERE url = ?`, job.URL).Scan(&id); err != nil {
return 0, fmt.Errorf("lookup job id: %w", err)
}
return id, nil
}
// upsertApplication inserts or updates the single applications row for a job.
// Phase 3 marks the status Evaluated; later phases can transition it.
func upsertApplication(ctx context.Context, tx *sql.Tx, jobID int64, eval Evaluation) (int64, error) {
var id int64
err := tx.QueryRowContext(ctx, `SELECT id FROM applications WHERE job_id = ?`, jobID).Scan(&id)
if err == sql.ErrNoRows {
res, ierr := tx.ExecContext(ctx, `
INSERT INTO applications (job_id, status, score, archetype, legitimacy, evaluated_at)
VALUES (?, 'Evaluated', ?, ?, ?, CURRENT_TIMESTAMP)
`, jobID, eval.Score, eval.Archetype, string(eval.Legitimacy))
if ierr != nil {
return 0, fmt.Errorf("insert application: %w", ierr)
}
newID, ierr := res.LastInsertId()
if ierr != nil {
return 0, fmt.Errorf("last insert id: %w", ierr)
}
return newID, nil
}
if err != nil {
return 0, fmt.Errorf("lookup application: %w", err)
}
if _, err := tx.ExecContext(ctx, `
UPDATE applications
SET status = CASE WHEN status IN ('Applied','Responded','Interview','Offer','Rejected','Discarded','SKIP')
THEN status ELSE 'Evaluated' END,
score = ?, archetype = ?, legitimacy = ?, evaluated_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`, eval.Score, eval.Archetype, string(eval.Legitimacy), id); err != nil {
return 0, fmt.Errorf("update application: %w", err)
}
return id, nil
}
// nextReportSeq returns max(seq)+1, or 1 if no reports exist yet. Runs inside
// the same tx as the insert so two concurrent evals cannot collide (SQLite
// serializes writers via the one-conn pool configured in store/db.go).
func nextReportSeq(ctx context.Context, tx *sql.Tx) (int, error) {
var max sql.NullInt64
if err := tx.QueryRowContext(ctx, `SELECT MAX(seq) FROM reports`).Scan(&max); err != nil {
return 0, fmt.Errorf("max seq: %w", err)
}
if !max.Valid {
return 1, nil
}
return int(max.Int64) + 1, nil
}
func insertReport(ctx context.Context, tx *sql.Tx, appID int64, seq int, slug, path, md string, eval Evaluation) (int64, error) {
var storiesJSON *string
if len(eval.Stories) > 0 {
b, err := json.Marshal(eval.Stories)
if err != nil {
return 0, fmt.Errorf("marshal stories: %w", err)
}
s := string(b)
storiesJSON = &s
}
res, err := tx.ExecContext(ctx, `
INSERT INTO reports (
application_id, report_md, seq, slug, legitimacy, score, file_path,
block_a, block_b, block_c, block_d, block_e, block_f, block_g, stories
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, appID, md, seq, slug, string(eval.Legitimacy), eval.Score, path,
eval.BlockA, eval.BlockB, eval.BlockC, eval.BlockD, eval.BlockE, eval.BlockF, eval.BlockG, storiesJSON)
if err != nil {
return 0, fmt.Errorf("insert report: %w", err)
}
return res.LastInsertId()
}
// atomicWrite writes to a sibling temp file and renames into place so a crash
// mid-write never leaves a half-written report on disk.
func atomicWrite(path string, data []byte) error {
dir := filepath.Dir(path)
tmp, err := os.CreateTemp(dir, ".report-*.tmp")
if err != nil {
return err
}
tmpName := tmp.Name()
if _, err := tmp.Write(data); err != nil {
tmp.Close()
os.Remove(tmpName)
return err
}
if err := tmp.Close(); err != nil {
os.Remove(tmpName)
return err
}
if err := os.Rename(tmpName, path); err != nil {
os.Remove(tmpName)
return err
}
return nil
}
// RenderMarkdown formats an Evaluation into the career-ops-compatible report
// layout expected under reports/{seq}-{slug}-{date}.md.
func RenderMarkdown(job JobContext, eval Evaluation, seq int, date string) string {
var b strings.Builder
fmt.Fprintf(&b, "# Evaluation: %s — %s\n\n", job.Company, job.Title)
fmt.Fprintf(&b, "**Seq:** %03d\n", seq)
fmt.Fprintf(&b, "**Date:** %s\n", date)
fmt.Fprintf(&b, "**Archetype:** %s\n", eval.Archetype)
fmt.Fprintf(&b, "**Score:** %.1f/5\n", eval.Score)
fmt.Fprintf(&b, "**Legitimacy:** %s\n", eval.Legitimacy)
if job.URL != "" {
fmt.Fprintf(&b, "**URL:** %s\n", job.URL)
}
b.WriteString("\n---\n\n")
writeBlock := func(label, content string) {
if strings.TrimSpace(content) == "" {
return
}
fmt.Fprintf(&b, "%s\n\n", content)
}
// Block G is written first to reflect the G-first evaluation order.
writeBlock("G", eval.BlockG)
writeBlock("A", eval.BlockA)
writeBlock("B", eval.BlockB)
writeBlock("C", eval.BlockC)
writeBlock("D", eval.BlockD)
writeBlock("E", eval.BlockE)
writeBlock("F", eval.BlockF)
// If every block is empty, fall back to the raw stream so the disk artifact
// is never empty — useful for debugging a prompt or parse failure.
if eval.BlockA == "" && eval.BlockB == "" && eval.BlockG == "" && eval.Raw != "" {
b.WriteString("## Raw Output\n\n")
b.WriteString(eval.Raw)
b.WriteString("\n")
}
return b.String()
}