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} } }