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

365 lines
8.3 KiB
Go

package tui
import (
"context"
"database/sql"
"fmt"
"os"
"os/exec"
"strconv"
"time"
"github.com/cobr-ai/apex/internal/cv"
"github.com/cobr-ai/apex/internal/eval"
"github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type EvalModel struct {
db *sql.DB
client *eval.Client
writer *eval.ReportWriter
cvPath string
cvExporter *cv.HTMLExporter
table table.Model
rows []evalRow
running bool
cvGenning bool
cancel context.CancelFunc
eventsCh <-chan eval.Event
status string
width int
height int
}
type evalRow struct {
URL string
Company string
Title string
Description string
Source string
Status string
Score float64
Legitimacy eval.LegitimacyTier
Err string
Seq int
ReportPath string
}
func NewEvalModel(db *sql.DB, cvPath string) *EvalModel {
m := &EvalModel{
db: db,
cvPath: cvPath,
client: eval.NewClient(),
writer: &eval.ReportWriter{DB: db},
cvExporter: &cv.HTMLExporter{
DB: db,
CVPath: cvPath,
},
status: "[r] refresh [e] evaluate [c] cv [enter] open report [esc] cancel",
}
m.loadPending()
m.table = newEvalTable(m.rows, 0, 0)
return m
}
func (m *EvalModel) loadPending() {
m.rows = nil
if m.db == nil {
m.status = "no database"
return
}
rows, err := m.db.Query(`
SELECT j.url, j.company, j.title, COALESCE(j.description, ''), COALESCE(j.source, '')
FROM jobs j
LEFT JOIN applications a ON a.job_id = j.id
WHERE a.id IS NULL AND j.archived = 0
ORDER BY j.discovered_at DESC
LIMIT 50
`)
if err != nil {
m.status = fmt.Sprintf("query failed: %v", err)
return
}
defer rows.Close()
for rows.Next() {
var r evalRow
if err := rows.Scan(&r.URL, &r.Company, &r.Title, &r.Description, &r.Source); err != nil {
continue
}
r.Status = "pending"
m.rows = append(m.rows, r)
}
}
func evalColumns() []table.Column {
return []table.Column{
{Title: "Company", Width: 22},
{Title: "Title", Width: 30},
{Title: "Status", Width: 8},
{Title: "Score", Width: 5},
{Title: "Legit", Width: 10},
{Title: "Notes", Width: 24},
}
}
func newEvalTable(rows []evalRow, w, h int) table.Model {
cols := flexColumns(w, evalColumns(), []int{0, 1, 5})
trows := make([]table.Row, 0, len(rows))
for _, r := range rows {
trows = append(trows, evalRowToTable(r))
}
t := table.New(
table.WithColumns(cols),
table.WithRows(trows),
table.WithFocused(true),
table.WithHeight(tableHeight(h)),
)
s := table.DefaultStyles()
s.Header = s.Header.BorderStyle(lipgloss.NormalBorder()).
BorderForeground(ColorBorder).BorderBottom(true).Bold(true).Foreground(ColorAccentAlt)
s.Selected = s.Selected.Bold(true).Foreground(ColorText).Background(ColorHighlight)
t.SetStyles(s)
return t
}
func evalRowToTable(r evalRow) table.Row {
score := "-"
if r.Score > 0 {
score = fmt.Sprintf("%.1f", r.Score)
}
legit := "-"
if r.Legitimacy != "" {
legit = string(r.Legitimacy)
}
notes := r.Err
if notes == "" && r.Seq > 0 {
notes = fmt.Sprintf("report #%03d", r.Seq)
}
return table.Row{
r.Company,
r.Title,
r.Status, score, legit, notes,
}
}
func (m *EvalModel) Init() tea.Cmd { return nil }
func (m *EvalModel) Resize(w, h int) {
m.width = w
m.height = h
m.table.SetHeight(tableHeight(h))
m.table.SetWidth(w - 2)
m.table.SetColumns(flexColumns(w-2, evalColumns(), []int{0, 1, 5}))
}
type evalEventMsg eval.Event
type evalDoneMsg struct{}
type cvGenDoneMsg struct {
path string
err error
}
func (m *EvalModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "r":
if !m.running {
m.loadPending()
m.refreshTable()
m.status = fmt.Sprintf("%d pending", len(m.rows))
}
case "e":
if !m.running && len(m.rows) > 0 {
return m, m.startBatch()
}
case "c":
if !m.running && !m.cvGenning && len(m.rows) > 0 {
cur := m.table.Cursor()
if cur < len(m.rows) {
return m, m.startCVGen(m.rows[cur].URL)
}
}
case "esc":
if m.running && m.cancel != nil {
m.cancel()
}
case "enter":
if cur := m.table.Cursor(); cur < len(m.rows) && m.rows[cur].Seq > 0 {
path := m.rows[cur].ReportPath
if path == "" {
path = m.lookupReportPath(m.rows[cur].URL)
}
if path != "" {
m.openInPager(path)
}
}
}
case evalEventMsg:
m.applyEvent(eval.Event(msg))
m.refreshTable()
return m, m.pump()
case evalDoneMsg:
m.running = false
m.status = m.summary()
m.refreshTable()
case cvGenDoneMsg:
m.cvGenning = false
if msg.err != nil {
m.status = fmt.Sprintf("cv error: %v", msg.err)
} else {
m.status = fmt.Sprintf("cv: %s", msg.path)
}
}
var cmd tea.Cmd
m.table, cmd = m.table.Update(msg)
return m, cmd
}
func (m *EvalModel) lookupReportPath(url string) string {
if m.db == nil {
return ""
}
var path string
err := m.db.QueryRow(`
SELECT r.file_path FROM reports r
JOIN applications a ON a.id = r.application_id
JOIN jobs j ON j.id = a.job_id
WHERE j.url = ? ORDER BY r.id DESC LIMIT 1
`, url).Scan(&path)
if err != nil {
return ""
}
return path
}
func (m *EvalModel) openInPager(path string) {
pager := os.Getenv("PAGER")
if pager == "" {
pager = "less"
}
cmd := exec.Command(pager, path)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
_ = cmd.Run()
}
func (m *EvalModel) startBatch() tea.Cmd {
if err := m.client.Available(); err != nil {
m.status = fmt.Sprintf("claude unavailable: %v", err)
return nil
}
jobs := make([]eval.JobContext, 0, len(m.rows))
for _, r := range m.rows {
jobs = append(jobs, eval.JobContext{
URL: r.URL, Company: r.Company, Title: r.Title, Source: r.Source, JDText: r.Description,
})
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
m.cancel = cancel
minScore := 0.0
if raw := os.Getenv("APEX_MIN_SCORE"); raw != "" {
if v, err := strconv.ParseFloat(raw, 64); err == nil {
minScore = v
}
}
b := &eval.Batch{
Client: m.client, Writer: m.writer, DB: m.db, CVPath: m.cvPath,
Concurrency: eval.DefaultConcurrency, MinScore: minScore,
}
m.eventsCh = b.Run(ctx, jobs)
m.running = true
m.status = "evaluating…"
return m.pump()
}
func (m *EvalModel) pump() tea.Cmd {
return func() tea.Msg {
ev, ok := <-m.eventsCh
if !ok {
return evalDoneMsg{}
}
return evalEventMsg(ev)
}
}
func (m *EvalModel) applyEvent(ev eval.Event) {
for i := range m.rows {
if m.rows[i].URL != ev.Job.URL {
continue
}
switch ev.Kind {
case eval.EventStart:
m.rows[i].Status = "running"
case eval.EventDone:
m.rows[i].Status = "done"
m.rows[i].Score = ev.Eval.Score
m.rows[i].Legitimacy = ev.Eval.Legitimacy
m.rows[i].Seq = ev.Saved.Seq
case eval.EventSkipped:
m.rows[i].Status = "skipped"
m.rows[i].Score = ev.Eval.Score
m.rows[i].Legitimacy = ev.Eval.Legitimacy
m.rows[i].Seq = ev.Saved.Seq
m.rows[i].Err = "below min-score"
case eval.EventError:
m.rows[i].Status = "error"
if ev.Err != nil {
m.rows[i].Err = ev.Err.Error()
}
}
return
}
}
func (m *EvalModel) refreshTable() {
trows := make([]table.Row, 0, len(m.rows))
for _, r := range m.rows {
trows = append(trows, evalRowToTable(r))
}
m.table.SetRows(trows)
}
func (m *EvalModel) summary() string {
var done, errs int
for _, r := range m.rows {
switch r.Status {
case "done":
done++
case "error":
errs++
}
}
return fmt.Sprintf("done — %d evaluated · %d errors", done, errs)
}
func (m *EvalModel) View() string {
header := StyleTitle.Render("Evaluate — A-G pipeline")
sub := StyleSubtitle.Render(fmt.Sprintf("%d pending · [r] refresh · [e] evaluate · [c] cv · [enter] open report · [esc] cancel", len(m.rows)))
status := StyleStatusMuted.Render(m.status)
return lipgloss.JoinVertical(lipgloss.Left, header, sub, "", m.table.View(), "", status)
}
// startCVGen initiates CV generation for the given job URL in a goroutine.
func (m *EvalModel) startCVGen(jobURL string) tea.Cmd {
if m.cvGenning || m.cvExporter == nil || m.cvExporter.DB == nil {
return nil
}
m.cvGenning = true
m.status = "generating cv…"
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
defer cancel()
result, err := m.cvExporter.Generate(ctx, jobURL)
if err != nil {
return cvGenDoneMsg{err: err}
}
return cvGenDoneMsg{path: result.HTMLPath}
}
}