365 lines
8.3 KiB
Go
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}
|
|
}
|
|
}
|