From 2bd333192b93b2c864647a270080e112beff11d3 Mon Sep 17 00:00:00 2001 From: voidReq <100312344+voidReq@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:52:06 -0400 Subject: [PATCH 1/4] Apply screen: the job's hiring stage instead of "interviewing (NUworks)" The old chip showed the job's hidden is_interviewing flag as if it were the applicant's status. Read-only checks on NUworks: the flag is on the job (also on jobs nobody applied to), nearly always alongside its Hiring Status "Interviewing"; students never see whom an employer picked. Each update now keeps the job's Hiring Status and when it starts and stops interviewing or turns Filled (jobs.application_answer). Applied rows show "job interviewing", then "job stopped interviewing" or "job filled" (sheet.maybe_filled), and the update sends one message per change, since withdrawing frees the row's place in the total. Co-Authored-By: Claude Opus 5.5 --- docs/ARCHITECTURE.md | 17 +++++++++-------- docs/DEPLOY.md | 2 ++ docs/GUI.md | 9 +++++++-- src/nuauto/daily.py | 9 +++++++++ src/nuauto/demo.py | 13 +++++++++++++ src/nuauto/gui.py | 7 +++++-- src/nuauto/gui_static/app.js | 10 +++++++++- src/nuauto/jobs.py | 35 ++++++++++++++++++++++++++++++----- src/nuauto/sheet.py | 23 +++++++++++++++++++++-- tests/test_daily.py | 21 +++++++++++++++++++++ tests/test_gui.py | 4 ++++ tests/test_jobs.py | 23 +++++++++++++++++++++++ tests/test_sheet.py | 9 +++++++++ 13 files changed, 162 insertions(+), 20 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1244d52..c58dc01 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -27,9 +27,9 @@ Command line and plumbing Sheet and NUworks sheet.py the Google Sheet. open_worksheet() / open_other() (kept OPEN_FOR s: OPEN, forget(), HTTPClient retries), read_rows(); limits: max_per_week(), week_window(), applied_dates(), total_count() (not_on_nuworks, - rejected_not_withdrawn), check_limits(); writers: set_status, mark_submit_started / resolve_submit, - mark_applied_by_hand, mark_site_done, set_outcome, drop, approve_proposed, unapprove, mark_on_laptop, - append_note, add_proposed; create_sheet(), google_login(). + rejected_not_withdrawn, maybe_filled), check_limits(); writers: set_status, mark_submit_started / + resolve_submit, mark_applied_by_hand, mark_site_done, set_outcome, drop, approve_proposed, unapprove, + mark_on_laptop, append_note, add_proposed; create_sheet(), google_login(). manage.py rows by hand: add(), move(), remove() (cells cleared, never deleted), lookup(), check_url(), movable(). setup_sheet.py one-time setup (refuses a sheet with data: HasData); format_sheet(); ensure_dropdown(). browser.py Playwright Firefox. launch() (lock_profile(): one process on the profile), install_domain_lock(), @@ -46,7 +46,8 @@ Job pool jobs.py the NUworks pool. PREFS / set_prefs(), cmd_list, cmd_triage_*, cmd_details (hard_rules), cmd_score_*, cmd_cat_*, build_pool(), ranked_pool() (taste model), review_queue(), job_view(), rate_viewer(), cmd_rate / cmd_approve / cmd_suggest / cmd_stats, set_rating(), check_applied(), check_applications() - (what NUworks says about sent rows -> data/applications.json). + (what NUworks says about sent rows -> data/applications.json; application_answer(): applied, and the + job's hiring stage tracked across checks). fields.py per-field starting preferences (the wizard's "What do you study?"). intern.py Summer internships (Simplify's and vanshb03's lists, one job on both kept once): the second pool, built like jobs.py (refresh, triage, fetch, score, category, build_pool); approve() -> manage.add("other"); @@ -198,14 +199,14 @@ REPO LAYOUT (what is not a module) TESTS (`nuauto test` runs every tests/test_*.py, 4 at a time: offline, no sheet, no real NUworks, no network; browser tests use headless Firefox on local pages or demo mode. Run it before pushing; CI runs it on Ubuntu and macOS.) test_sync sync.CODE is complete (a new module or prompt must be in it), absolute imports, rsync options, units - test_sheet sheet.py: parsing, week and total limits, max_per_week, not_on_nuworks, submit safety, drop, set_outcome, - mark_on_laptop, retries, the kept sheet (OPEN) - test_jobs hard rules, pool, model; sheet.add_proposed / approve_proposed + test_sheet sheet.py: parsing, week and total limits, max_per_week, not_on_nuworks, maybe_filled, submit safety, + drop, set_outcome, mark_on_laptop, retries, the kept sheet (OPEN) + test_jobs hard rules, pool, model; sheet.add_proposed / approve_proposed; application_answer (the hiring stage) test_apply apply.check_popup, run_rows and its queue, BETWEEN test_answers answers.py, apply.fill_extra_fields (a local form), JsonIO, json_ui_allowed test_browser domain lock, goto retry, profile lock test_demo the real apply.py on demo mode's fake pages (stops, cap, Ctrl+C, GUI gone, CLAUDECODE refusals) - test_daily daily.py's summary, to-do, Claude errors + test_daily daily.py's summary, to-do, Claude errors, the applications check's messages test_web Mark-done links (signatures; GET never changes the sheet) test_deploy deploy.py on a throwaway git repo test_doctor_remote doctor.check_remote_assist test_health health.py's checks diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 78a2b86..d478a37 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -60,6 +60,8 @@ Unit files live in this repo (deploy/systemd/) and are installed in ~/.config/sy - the applications check: NUworks is asked about every sent row (jobs.check_applications -> data/applications.json), so the total limit counts what NUworks has; the Status dropdown is set again (Accepted / Rejected reach an older sheet); a Rejected row NUworks still has is in the log, and in the morning message until you withdraw it there. + An Applied row whose job looks filled (its Hiring Status turned Filled, or it was interviewing and stopped: + sheet.maybe_filled) is in the log every run, and in a message once, the run that first sees it. - the internship update (when "internships" is on). If the job list fails (NUworks slow), it is read once more 2 minutes later (daily.RETRY_WAIT), and a page load gets one retry after 60 s; the morning reminders still go out, from the sheet and the last saved pool. Why a run stopped goes diff --git a/docs/GUI.md b/docs/GUI.md index db1da59..e82afe1 100644 --- a/docs/GUI.md +++ b/docs/GUI.md @@ -107,8 +107,13 @@ A question from a run (an unknown form field, an option to pick, "did it submit? Human rows only you can finish there: a cover letter, a transcript; the assistant can work them on NUworks with you, or a chip says why not), Still owed (submitted on NUworks, the company site still wants you: Mark the company site done; the NUworks side to send again: Submit on NUworks too), Applied ("not on - NUworks" chip when NUworks has no application for it: it doesn't count toward the total; "interviewing - (NUworks)"), Accepted, Rejected, Proposed, Dropped or failed, Other rows; Recent runs here at the end. The + NUworks" chip when NUworks has no application for it: it doesn't count toward the total; the job's own + hiring stage at the last check (jobs.application_answer): "job interviewing", then amber "job stopped + interviewing" or "job filled" (sheet.maybe_filled: it may be filled; withdrawing on NUworks frees its + place in the total). Until 2026-10-26 an "interviewing (NUworks)" chip showed the job's is_interviewing + flag as if it were yours: it is about the job, set also on jobs you never applied to, and NUworks never + shows a student whom an employer picked), Accepted, Rejected, Proposed, Dropped or failed, Other rows; + Recent runs here at the end. The other tab: Ready to apply (Approved: the assistant), Applied, Accepted, Rejected, Proposed, Dropped or failed, Other rows (Sort: score, match %, pay, sheet order, and on the homelab's page the queue's order first; it changes the view only). diff --git a/src/nuauto/daily.py b/src/nuauto/daily.py index d1516eb..625c483 100644 --- a/src/nuauto/daily.py +++ b/src/nuauto/daily.py @@ -193,6 +193,7 @@ def applications(): except Exception as e: # (the morning reminders say so when it is the Google login) return log(f"applications check skipped: the sheet can't be read ({type(e).__name__})") log("applications on NUworks") + before = {(r.number, why) for r, why in sheet.maybe_filled(rows)} # (the message tells each change once) ok, seen = step(jobs.check_applications, rows) if not ok: return log(f"applications check stopped: {seen}") @@ -206,6 +207,14 @@ def applications(): notify(f"{len(waiting)} rejected application{'s' if len(waiting) > 1 else ''} still on NUworks", "\n".join(f"{r.title[:40]} | {r.company[:22]}" for r in waiting) + "\nWithdraw on NUworks: until you do they count toward the total.") + done = sheet.maybe_filled(rows, seen) + if done: + log("job looks filled: " + ", ".join(f"row {r.number} ({why})" for r, why in done)) + new = [(r, why) for r, why in done if (r.number, why) not in before] + if new: + notify(f"{len(new)} job{'s' if len(new) > 1 else ''} you applied to may be filled", + "\n".join(f"{r.title[:40]} | {r.company[:22]}: {why}" for r, why in new) + + "\nIf you haven't heard back, you can withdraw on NUworks: until you do they count toward the total.") def internships(): diff --git a/src/nuauto/demo.py b/src/nuauto/demo.py index 833337a..397868e 100644 --- a/src/nuauto/demo.py +++ b/src/nuauto/demo.py @@ -534,6 +534,19 @@ def setup(state_names=()): _write(config.LOCAL_CONFIG_PATH, {**cfg, "remote_assist": True, "assist_slots": 3, "web_listen_host": "127.0.0.1", "remote_auto": {"nuworks": False, "other": True}}, mode=0o644) _write(_path(f"demo_sheet_{SHEET_ID}.json"), {"title": "NUauto jobs (demo)", "rows": rows}) + # what the last NUworks check said about three of those (jobs.application_answer): the job chips on the Apply screen + checked = f"{today.isoformat()}T08:10" + week_ago = f"{(today - timedelta(days=7)).isoformat()}T08:10" + seen = {} + for r, (hiring, more) in zip(sheet.parse_rows(rows), [ + ("Interviewing", {"interviewing": True, "interviewing_since": week_ago}), + ("Available", {"interviewing_since": week_ago, "interviewing_ended": checked}), + ("Filled", {"interviewing_since": week_ago, "interviewing_ended": checked, "filled_since": checked})]): + seen[jobs.job_id(r.url)] = {"applied": True, "status": "applied", "expired": False, "interviewing": False, + "hiring": hiring, "interviewing_since": None, "interviewing_ended": None, + "filled_since": None, **more, "checked": checked, "row": r.number, + "fingerprint": sheet.fingerprint(r)} + _write(os.path.join(config.DATA_DIR, sheet.APPLICATIONS), seen) _write(_path(f"demo_sheet_{SHEET_ID}.other.json"), {"rows": [list(sheet.HEADERS)] + other}) diff --git a/src/nuauto/gui.py b/src/nuauto/gui.py index b80feba..38f82cc 100644 --- a/src/nuauto/gui.py +++ b/src/nuauto/gui.py @@ -1663,11 +1663,14 @@ def locks(v): # what Move and Remove allow on this row return {**v, "locked": manage.movable(r, tab, "move"), "remove_locked": manage.movable(r, tab, "remove")} seen = {} if other else sheet.nuworks_seen() + done = {r.number: why for r, why in sheet.maybe_filled(rows, seen)} # (Applied rows whose job looks filled) def on_nuworks(r): # what NUworks said about a sent row at the last check (current: about the row as it is now) a = seen.get(jobs.job_id(r.url)) if r.status in sheet.SENT else None - return {"nuworks": {**{k: a.get(k) for k in ("applied", "status", "expired", "interviewing", "checked")}, - "current": a.get("fingerprint") == sheet.fingerprint(r)}} if a else {} + keys = ("applied", "status", "expired", "interviewing", "hiring", "interviewing_since", "interviewing_ended", + "filled_since", "checked") + return {"nuworks": {**{k: a.get(k) for k in keys}, "current": a.get("fingerprint") == sheet.fingerprint(r), + "done": done.get(r.number)}} if a else {} def view(r, **extra): more = facts.get(intern.clean_url(r.url), {}) if other else {**closing(r), **job_facts(r, data), **on_nuworks(r)} diff --git a/src/nuauto/gui_static/app.js b/src/nuauto/gui_static/app.js index b743d78..692ecdf 100644 --- a/src/nuauto/gui_static/app.js +++ b/src/nuauto/gui_static/app.js @@ -1267,7 +1267,15 @@ screens.apply = async (view) => { const nu = r.nuworks; // what NUworks said at the last check (the twice-daily update) if (nu && nu.current && nu.applied === false) chips.push(el("span", { class: "chip warn", testid: `not-on-nuworks-${r.row}`, text: "not on NUworks", title: `NUworks has no application for this job (checked ${nu.checked}): it doesn't count toward your total.` })); - if (nu && nu.applied && nu.interviewing) chips.push(el("span", { class: "chip accent", text: "interviewing (NUworks)" })); + // the job's own hiring stage (sheet.maybe_filled, jobs.application_answer), never yours: NUworks doesn't show that + const day = (t) => (t || "").slice(0, 10); + if (nu && nu.done === "filled") chips.push(el("span", { class: "chip warn", testid: `job-filled-${r.row}`, text: "job filled", + title: `NUworks' Hiring Status for this job is Filled (since ${day(nu.filled_since)}). If you haven't heard back, you can withdraw on NUworks: until you do it counts toward your total.` })); + else if (nu && nu.done) chips.push(el("span", { class: "chip warn", testid: `job-stopped-${r.row}`, text: "job stopped interviewing", + title: `The employer was interviewing for this job (from ${day(nu.interviewing_since)}) and stopped (${day(nu.interviewing_ended)}): it may be filled. If you haven't heard back, you can withdraw on NUworks: until you do it counts toward your total.` })); + else if (nu && nu.applied && r.status === "Applied" && (nu.interviewing || nu.hiring === "Interviewing")) chips.push(el("span", { + class: "chip", testid: `job-interviewing-${r.row}`, text: "job interviewing", + title: `The employer is interviewing for this job (NUworks, checked ${day(nu.checked)}). That is about the job, not your application: NUworks doesn't show students whom an employer picked.` })); } const facts = r.match != null || r.score != null || r.pay ? jobFacts(r) : null; return el("li", { class: "job-row", testid: `job-${tab}-${r.row}` }, diff --git a/src/nuauto/jobs.py b/src/nuauto/jobs.py index 9ad135c..a88b754 100644 --- a/src/nuauto/jobs.py +++ b/src/nuauto/jobs.py @@ -674,9 +674,35 @@ def check_applied(ids): return found +def application_answer(raw, prev, now): + """What NUworks' job record (/api/v3/jobs/) says about a sent row: applied, application_status, expired, and the + job's own hiring stage, tracked across checks (prev: the last answer). All of it is about the job, not you: NUworks + never shows a student whom an employer picked (checked 2026-10-26: your application record has no status, and + My Interviews lists only interviews booked through NUworks). + "hiring": its Hiring Status (internal_status: Available, Interviewing, Filled, Expired...); "interviewing": its + is_interviewing flag (hidden on NUworks' pages; set on about 1 job in 5, also jobs nobody here applied to, nearly + always on those whose Hiring Status is Interviewing). "interviewing_since" / "interviewing_ended": when the job was + first seen interviewing (either of the two) and when it stopped (it may be filled); "filled_since": when its + Hiring Status was first seen Filled.""" + prev = prev or {} + stage = raw.get("internal_status") + hiring = (stage.get("_label") if isinstance(stage, dict) else stage) or None + flag = str(raw.get("is_interviewing")) == "1" + # (answers saved before 2026-10-26 have only the flag: one that had it was interviewing at its check) + was = prev.get("interviewing_since") or (prev.get("checked") if prev.get("interviewing") else None) + if flag or hiring == "Interviewing": + since, ended = (now if not was or prev.get("interviewing_ended") else was), None + else: + since, ended = was, (prev.get("interviewing_ended") or now) if was else None + return {"applied": bool(raw.get("applied")), "status": raw.get("application_status") or None, + "expired": bool(raw.get("expired")), "interviewing": flag, "hiring": hiring, + "interviewing_since": since, "interviewing_ended": ended, + "filled_since": (prev.get("filled_since") or now) if hiring == "Filled" else None, "checked": now} + + def check_applications(rows): - """Ask NUworks about every sent row of the NUworks tab (Applied, Accepted, Rejected; its job's own data: applied, - application_status, expired, is_interviewing) and keep its answers in data/applications.json, each with the row's + """Ask NUworks about every sent row of the NUworks tab (Applied, Accepted, Rejected; its job's own data, see + application_answer) and keep its answers in data/applications.json, each with the row's fingerprint (sheet.not_on_nuworks: an answer counts only for the row as it was then). So the total limit counts what NUworks has: a company-site application whose NUworks side never went out doesn't. Returns {job id: answer}. Read-only on NUworks; rows it can't read keep their last answer.""" @@ -700,9 +726,8 @@ def check_applications(rows): except Exception: raw = None if raw and raw.get("job_id") == i: - seen[i] = {"applied": bool(raw.get("applied")), "status": raw.get("application_status") or None, - "expired": bool(raw.get("expired")), "interviewing": str(raw.get("is_interviewing")) == "1", - "checked": now, "row": r.number, "fingerprint": sheet.fingerprint(r)} + seen[i] = {**application_answer(raw, seen.get(i), now), "row": r.number, + "fingerprint": sheet.fingerprint(r)} browser.pause(page, 1.5, 3) log.write(f"asked NUworks about {len(sent)} sent rows: " f"{sum(1 for r in sent if (seen.get(job_id(r.url)) or {}).get('applied'))} it has") diff --git a/src/nuauto/sheet.py b/src/nuauto/sheet.py index 9c6581c..a771496 100644 --- a/src/nuauto/sheet.py +++ b/src/nuauto/sheet.py @@ -113,8 +113,9 @@ def fingerprint(row): def nuworks_seen(): - """{job id: {"applied", "status", "expired", "interviewing", "checked", "row", "fingerprint"}}: NUworks' answers from - the last check (the twice-daily update); {} when there is none (or it can't be read).""" + """{job id: {"applied", "status", "expired", "interviewing", "hiring", "interviewing_since", "interviewing_ended", + "filled_since", "checked", "row", "fingerprint"}} (jobs.application_answer): NUworks' answers from the last check + (the twice-daily update); {} when there is none (or it can't be read).""" try: with open(os.path.join(config.DATA_DIR, APPLICATIONS)) as f: data = json.load(f) @@ -150,6 +151,24 @@ def rejected_not_withdrawn(rows, seen=None): return out +def maybe_filled(rows, seen=None): + """[(row, "filled" | "stopped interviewing")]: the Applied rows of the NUworks tab whose job looks done hiring + at the last check (jobs.application_answer: its Hiring Status is Filled, or it was interviewing and isn't any + more) while NUworks still has your application (about the row as it is now). If you haven't heard back, + withdrawing there frees its place in the total. Never asked = not listed.""" + from nuauto import jobs + seen = nuworks_seen() if seen is None else seen + out = [] + for r in rows: + s = seen.get(jobs.job_id(r.url)) if r.status == "Applied" and r.url else None + if s and s.get("applied") and s.get("fingerprint") == fingerprint(r): + if s.get("filled_since"): + out.append((r, "filled")) + elif s.get("interviewing_ended"): + out.append((r, "stopped interviewing")) + return out + + def total_count(rows, seen=None): """What counts toward the total limit: the sent rows NUworks has (or hasn't been asked about yet). The NUworks tab's rows only (the Other jobs tab has no limits). Until 2026-10-16 every Applied row counted, also those never sent diff --git a/tests/test_daily.py b/tests/test_daily.py index b274182..b4e4c76 100644 --- a/tests/test_daily.py +++ b/tests/test_daily.py @@ -2,6 +2,7 @@ Uses a temp data dir, temp log file and a stubbed notify: no sheet, no Discord, no network. """ +import json import os import tempfile from datetime import datetime @@ -39,6 +40,26 @@ log_text = open(daily.LOG_PATH).read() assert "applications: 1 count toward the total; NUworks has none for row 2" in log_text, log_text[-300:] assert "rejected, not withdrawn on NUworks: row 3" in log_text, log_text[-300:] # NUworks still has the Rejected row's application +# an Applied row whose job stopped interviewing / got filled: in the log every run, in a message once per change +R_APPS.append(sheet.Row(4, "https://n/jobs/detail/c", "Filled Co", "Firmware", "Applied", "", "2026-10-03")) +saved = os.path.join(config.DATA_DIR, sheet.APPLICATIONS) +answer = {"applied": True, "fingerprint": sheet.fingerprint(R_APPS[2]), "interviewing_ended": "t2"} +daily.jobs.check_applications = lambda rows: {"c": answer} +sent.clear() +daily.applications() +assert "job looks filled: row 4 (stopped interviewing)" in open(daily.LOG_PATH).read() +assert [t for t, _ in sent] == ["1 job you applied to may be filled"] and "Firmware | Filled Co: stopped interviewing" in sent[0][1] +os.makedirs(config.DATA_DIR, exist_ok=True) +with open(saved, "w") as f: # (what that check saved) + json.dump({"c": answer}, f) +sent.clear() +daily.applications() +assert sent == [] # the same news: told once +daily.jobs.check_applications = lambda rows: {"c": {**answer, "filled_since": "t3"}} +daily.applications() +assert [t for t, _ in sent] == ["1 job you applied to may be filled"] and ": filled" in sent[0][1] +os.remove(saved) +R_APPS.pop() def no_nuworks(rows): raise SystemExit(daily.jobs.NOT_LOGGED_IN) daily.jobs.check_applications = no_nuworks diff --git a/tests/test_gui.py b/tests/test_gui.py index 4451314..86ff45e 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -562,6 +562,10 @@ def eventually(check, what, seconds=20): approved_one = get("/api/jobs?tab=nuworks")["sections"]["approved"][0] post("/api/mark", {"action": "outcome", "outcome": "Accepted", "row": approved_one["row"], "url": approved_one["url"]}, expect=409) post("/api/mark", {"action": "outcome", "outcome": "Failed", "row": sent["row"], "url": sent["url"]}, expect=409) + # the job's hiring stage at the last check (demo.setup's answers; sheet.maybe_filled): the chips on Applied rows + stage = {r["row"]: (r["nuworks"]["hiring"], r["nuworks"]["done"]) + for r in get("/api/jobs?tab=nuworks")["sections"]["applied"] if r.get("nuworks")} + assert stage == {2: ("Interviewing", None), 3: ("Available", "stopped interviewing"), 4: ("Filled", "filled")}, stage # NUworks' answer about a sent row (the twice-daily update: data/applications.json): "not on NUworks" when it has none with open(os.path.join(STATE, "data", "applications.json"), "w") as f: json.dump({sent["url"].rstrip("/").split("/")[-1]: {"applied": False, "status": None, "expired": False, diff --git a/tests/test_jobs.py b/tests/test_jobs.py index 4117dc4..a5a49e8 100644 --- a/tests/test_jobs.py +++ b/tests/test_jobs.py @@ -216,4 +216,27 @@ def update(self, range_name, values, value_input_option=None): # a single Statu assert [r["id"] for r in rate] == ["902"] # the rating viewer is unchanged jobs.load, jobs.ranked_pool = real_load, real_ranked +# what the applications check keeps about a sent row's job: its hiring stage, and when it started / stopped interviewing +def job(flag="0", stage="Available", applied=True): + return {"job_id": "j", "applied": applied, "application_status": "applied" if applied else None, "expired": False, + "is_interviewing": flag, "internal_status": {"_id": "1", "_label": stage}} +a = jobs.application_answer(job(), None, "t1") +assert (a["hiring"], a["interviewing"], a["interviewing_since"], a["interviewing_ended"], a["filled_since"]) == \ + ("Available", False, None, None, None) and a["applied"] and a["checked"] == "t1" +a = jobs.application_answer(job("1"), a, "t2") # the flag alone is interviewing +assert a["interviewing"] and a["interviewing_since"] == "t2" and not a["interviewing_ended"] +a = jobs.application_answer(job(None, "Interviewing"), a, "t3") # so is the Hiring Status alone; since stays +assert a["interviewing_since"] == "t2" and not a["interviewing_ended"] +a = jobs.application_answer(job(), a, "t4") # neither: it stopped (it may be filled) +assert a["interviewing_since"] == "t2" and a["interviewing_ended"] == "t4" and not a["filled_since"] +a = jobs.application_answer(job(stage="Filled"), a, "t5") +assert a["interviewing_ended"] == "t4" and a["filled_since"] == "t5" +assert jobs.application_answer(job(stage="Filled"), a, "t6")["filled_since"] == "t5" # the first time seen stays +a = jobs.application_answer(job("1"), a, "t7") # interviewing again: a new period +assert a["interviewing_since"] == "t7" and not a["interviewing_ended"] and not a["filled_since"] +old = {"applied": True, "interviewing": True, "checked": "t0"} # an answer saved before the tracking: only the flag +assert jobs.application_answer(job(), old, "t1")["interviewing_ended"] == "t1" +assert jobs.application_answer(job(), {**old, "interviewing": False}, "t1")["interviewing_ended"] is None +assert jobs.application_answer({**job(), "internal_status": None}, None, "t1")["hiring"] is None + print("All job-pool checks passed.") diff --git a/tests/test_sheet.py b/tests/test_sheet.py index 7fa6d31..3c99566 100644 --- a/tests/test_sheet.py +++ b/tests/test_sheet.py @@ -94,6 +94,15 @@ def expect(exc, fn): assert [r.number for r in sheet.rejected_not_withdrawn(R3, on)] == [4] on["c"]["applied"] = False assert sheet.rejected_not_withdrawn(R3, on) == [] and sheet.rejected_not_withdrawn(R3, {}) == [] # withdrawn / never asked +# an Applied row whose job looks filled (its Hiring Status, or it stopped interviewing) while NUworks has the application +jobs_now = {"b": {"applied": True, "fingerprint": fp["b"], "interviewing_ended": "t2"}, + "c": {"applied": True, "fingerprint": fp["c"], "filled_since": "t2"}} # (c is Rejected: not listed) +assert [(r.number, why) for r, why in sheet.maybe_filled(R3, jobs_now)] == [(3, "stopped interviewing")] +jobs_now["b"]["filled_since"] = "t3" +assert [(r.number, why) for r, why in sheet.maybe_filled(R3, jobs_now)] == [(3, "filled")] +jobs_now["b"]["applied"] = False # withdrawn: nothing left to do +assert sheet.maybe_filled(R3, jobs_now) == [] and sheet.maybe_filled(R3, {}) == [] +assert sheet.maybe_filled(R3, {"b": {"applied": True, "fingerprint": "older", "filled_since": "t3"}}) == [] R3[0].notes += " NUworks side submitted." # it changed since NUworks was asked: it counts assert sheet.total_count(R3, seen) == 3 and sheet.total_count(R3, {}) == 3 # (never asked: counts) many = rows_with([("Applied", "2026-09-01")] * sheet.MAX_TOTAL) From 50d3d55d2ee1231880dc864e0c232d4f9ea13e7b Mon Sep 17 00:00:00 2001 From: voidReq <100312344+voidReq@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:15:59 -0400 Subject: [PATCH 2/4] Discord: one short morning message, problems at most once a day Every update, the 2-hourly internship run, deploys and the Sunday check-in each sent messages of their own, several a day. Now the morning run sends one message with a short line for each thing that applies (new jobs with the best one, what closes, to-dos with their Mark links, what to withdraw, what other runs kept for it, the week's count, what is wrong), and a problem is told when it happens, at most once a day per problem (daily.alert); the morning run's own problems go in its message. The assistant's pings, a failed deploy and device pairing are unchanged. The weekly timer stays installed and sends nothing. Co-Authored-By: Claude Opus 5.5 --- docs/ARCHITECTURE.md | 10 +- docs/DEPLOY.md | 51 ++++--- docs/PIPELINE.md | 4 +- src/nuauto/daily.py | 353 +++++++++++++++++++++++++------------------ src/nuauto/deploy.py | 7 +- src/nuauto/intern.py | 40 ++--- tests/test_daily.py | 119 +++++++++------ tests/test_deploy.py | 8 +- tests/test_intern.py | 23 ++- 9 files changed, 347 insertions(+), 268 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c58dc01..69e9c55 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -54,8 +54,9 @@ Job pool update(), review_queue(), stats(). Short links -> the posting's own (real_links, real_url; used by assist.other_target). Data in data/intern/. postings.py reads one posting from a company's own job site, no login; public addresses only (allowed_host). - daily.py what the homelab timers run. main() -> scan() (via step()), reminders(), record_hand_applications(), - applications(), internships(); run_claude(), notify() / discord(), weekly(). + daily.py what the homelab timers run. main() -> scan() (via step()), applications(), reminders() (the morning + message: morning(), its lines; record_hand_applications()), internships(); run_claude(); messages: + notify() / discord(), alert() (a problem, once a day each), later() (a line for the next morning). insights.py summarize(): pay, places, kinds of work, sheet statuses; text() for `nuauto insights`. web.py Mark-done page (homelab, port 8765). link() signs URLs, Handler.do_GET (confirm) / do_POST; main() also starts gui.serve_remote() when the homelab's page is on. @@ -150,7 +151,8 @@ MAIN FLOWS STATE ON DISK (STATE_DIR = repo root; app-data folder when packaged; a temp folder in demo mode and tests) data/ the pool, owned by the homelab (the laptop pulls a copy): list.json, triage.json, scores.json, categories.json, - category_sets.json, pool.json, details/.json (jobs.py); scans.json, last_run.json (daily.py); + category_sets.json, pool.json, details/.json (jobs.py); scans.json, last_run.json, later.json, told.json + (daily.py); applications.json (jobs.check_applications); ratings.json (jobs.set_rating, merged between machines); intern/ (intern.py: listings, source, failed, unread, triage, scores, categories, pool, details/, update.lock). work/ Claude's batches: _in_NNN.json / _out_NNN.json, resume.txt (jobs.py writes the inputs, the claude @@ -206,7 +208,7 @@ use headless Firefox on local pages or demo mode. Run it before pushing; CI runs test_answers answers.py, apply.fill_extra_fields (a local form), JsonIO, json_ui_allowed test_browser domain lock, goto retry, profile lock test_demo the real apply.py on demo mode's fake pages (stops, cap, Ctrl+C, GUI gone, CLAUDECODE refusals) - test_daily daily.py's summary, to-do, Claude errors, the applications check's messages + test_daily daily.py's morning message, alerts once a day, Claude errors, the applications check test_web Mark-done links (signatures; GET never changes the sheet) test_deploy deploy.py on a throwaway git repo test_doctor_remote doctor.check_remote_assist test_health health.py's checks diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index d478a37..de3f8f9 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -50,34 +50,46 @@ WHAT RUNS ON THE HOMELAB Unit files live in this repo (deploy/systemd/) and are installed in ~/.config/systemd/user/. All run with TZ New York. - nuauto-daily.timer -> nuauto-daily.service: `nuauto daily` at 08:00 and 18:00 New York time (+0-15 min random, Persistent=true so a missed run happens at boot). Every run, in order: - - the scan (docs/PIPELINE.md): list -> Claude triage -> details -> Claude score -> Claude category -> pool; new pool - jobs are announced; counts go to data/scans.json. - - the morning run only (before noon): the reminders. First, if the scan worked, jobs you applied to by hand on - NUworks become Applied rows (jobs.check_applied). Then messages: Approved rows closing today or tomorrow, pool jobs - closing within 3 days that are not in the sheet, the Google login warning (once 1 day is left), and "NUauto - morning" (always sent): new postings and new pool jobs in the last 24h, then the to-do list (company-site - applications owed, external Needs Human rows) with signed Mark links. + - the scan (docs/PIPELINE.md): list -> Claude triage -> details -> Claude score -> Claude category -> pool; counts + and the best new job go to data/scans.json. - the applications check: NUworks is asked about every sent row (jobs.check_applications -> data/applications.json), so the total limit counts what NUworks has; the Status dropdown is set again (Accepted / Rejected reach an older - sheet); a Rejected row NUworks still has is in the log, and in the morning message until you withdraw it there. - An Applied row whose job looks filled (its Hiring Status turned Filled, or it was interviewing and stopped: - sheet.maybe_filled) is in the log every run, and in a message once, the run that first sees it. + sheet). Rejected rows NUworks still has and Applied rows whose job looks filled (its Hiring Status turned Filled, or + it was interviewing and stopped: sheet.maybe_filled) are in the log, and in the morning message until you withdraw + them there. + - the morning run only (before noon): if the scan worked, jobs you applied to by hand on NUworks become Applied rows + (jobs.check_applied); then the morning message. - the internship update (when "internships" is on). + DISCORD (since 2026-10-26, my request: fewer, shorter messages, glanced at once a day). Two kinds only: + - "NUauto morning" (daily.morning), always sent, a line for each thing that applies: New (pool co-ops and + internships of the last 24h, each with the best one), Closing (Approved rows closing today or tomorrow by name, pool + jobs closing within 3 days not in the sheet), To do (company-site applications owed, external Needs Human rows, + with their job and signed Mark links), Withdraw on NUworks (Rejected / may be filled), what other runs kept for it + (daily.later -> data/later.json: applications recorded from NUworks, approved internships now closed, deploys), + Week (this week's count, the total, Approved waiting), Wrong (this run stopped, the sheet can't be read, the Google + login runs out), and NUauto's page when there is one. + - a problem when it happens (daily.alert: a run that stopped, a login to renew, the sheet unreadable, a crash): the + same title at most once a day (data/told.json, TELL_AGAIN), so the 2-hourly internship update failing the same way + is told once. The morning run's own problems go in the morning message instead (daily.QUIET). + Not on Discord any more: new pool jobs per update, new internships per 2-hourly update, the separate deadline and + Google login messages, recorded applications, closed internships, "Deployed", the Sunday check-in. Unchanged: the + assistant's messages (it waits for you, wants to Submit, ended: docs/REMOTE.md), a failed deploy, device pairing. If the job list fails (NUworks slow), it is read once more 2 minutes later (daily.RETRY_WAIT), and a page load gets one - retry after 60 s; the morning reminders still go out, from the sheet and the last saved pool. Why a run stopped goes + retry after 60 s; the morning message still goes out, from the sheet and the last saved pool. Why a run stopped goes to data/last_run.json (daily.record_run: nuworks_login, nuworks, claude or failed, in a few words); the laptop pulls it with data/, and the GUI's New jobs check shows it on Today with the fix (Log in to NUworks, Run it again, Log in to - Claude there). Discord says the same: a login only when NUworks really asks for one. + Claude there). Discord says the same (once a day at most): a login only when NUworks really asks for one. Claude runs as `claude -p --model sonnet`, one call per batch file, shut in work/ (it runs there and may read and write only there: no shell, web or MCP tools; the batches hold web text, job postings, so nothing in one can reach local/, the code or your home). Headless browser; never applies to anything. -- nuauto-weekly.timer -> nuauto-weekly.service: `nuauto daily weekly`, Sunday 19:00 New York time - (Discord check-in: this week's count, Approved rows waiting, pool jobs you haven't looked at). +- nuauto-weekly.timer -> nuauto-weekly.service: `nuauto daily weekly`, Sunday 19:00 New York time. Sends nothing + since 2026-10-26 (the morning message has the week's count); kept so the installed timer runs cleanly (deploy never + removes unit files: `systemctl --user disable --now nuauto-weekly.timer` turns it off). - nuauto-intern.timer -> nuauto-intern.service: `nuauto intern update` every 2 hours (:40 on even hours New York, +0-10 min). Needs "internships" in local_config.json (docs/PIPELINE.md, SUMMER INTERNSHIPS); without it the run stops at once saying so. Simplify's and vanshb03's lists, postings from the companies' own job sites, Claude triage / score / - category, Discord for new pool internships. `nuauto daily` runs the same update after the NUworks one; a lock keeps - it to one at a time. The laptop's `nuauto intern update` starts it here. + category; the morning message counts new pool internships. `nuauto daily` runs the same update after the NUworks + one; a lock keeps it to one at a time. The laptop's `nuauto intern update` starts it here. - nuauto-deploy.timer -> nuauto-deploy.service: `nuauto deploy` 2 min after boot, then every 5 minutes (deploy_from_git only). Looks at GitHub's main; a new commit is unpacked aside (work/deploy/), imported as a check (deploy.MODULES), then its changed files are copied in (the old ones kept to undo), the package is reinstalled if pyproject.toml @@ -87,7 +99,7 @@ Unit files live in this repo (deploy/systemd/) and are installed in ~/.config/sy import, or a web that does not come back, is undone and Discord says so once. It waits while an update (`nuauto daily`, `nuauto intern update`) or a NUworks run started from NUauto's page (`nuauto apply`, `nuauto assist nuworks`: nuauto-web's children, which a restart would cut off) runs; assistant sessions run in scopes of their own and are - not cut off. Record: local/deployed.json; log: logs/deploy.log; Discord: "Deployed " when files changed. + not cut off. Record: local/deployed.json; log: logs/deploy.log; the next morning message: "Updated: ". - nuauto-assist.timer -> nuauto-assist.service: `nuauto assist auto` every 10 minutes: the assistant's queue. Does nothing unless "remote_assist" and "remote_auto" are on (docs/REMOTE.md). - nuauto-web.service: `nuauto web`, always on (Restart=on-failure). Serves the signed Mark-done links on @@ -172,8 +184,9 @@ SYNC (sync.py; runs automatically inside `nuauto` on the laptop, homelab mode on DEPLOYING A CHANGE With deploy_from_git (the homelab pulls main; no unit file or dependency steps by hand): 1. Change code on the laptop (on a branch; merge to main when approved). -2. Merge to main. Within 5 minutes the homelab has it (Discord: "Deployed "). Now: `nuauto deploy` - (starts the run on the homelab and shows the end of its log). A branch on the laptop never reaches the homelab. +2. Merge to main. Within 5 minutes the homelab has it (the next morning message: "Updated: "). Now: + `nuauto deploy` (starts the run on the homelab and shows the end of its log). A branch on the laptop never reaches + the homelab. 3. `nuauto doctor` shows the deployed commit; a failed deploy shows as FAIL with the reason (logs/deploy.log). Roll back: revert the commit on main (a new commit), merge it; the homelab deploys the revert. What it copies: src/nuauto/, prompts/, docs/, deploy/systemd/ and the top-level pyproject.toml, README.md, CLAUDE.md, diff --git a/docs/PIPELINE.md b/docs/PIPELINE.md index 142eb61..ced011a 100644 --- a/docs/PIPELINE.md +++ b/docs/PIPELINE.md @@ -117,7 +117,7 @@ year's lists are new repos: changing the term moves the addresses with it); off runs work through the backlog, best titles (fit) and newest first (intern.priority). - Lists: a listing counts when it is for the term (Simplify's "terms"; vanshb03's has only a season, "Summer", on a list whose address says the term's year). One its list closes (or drops) stays, marked inactive; an approved one - that closes sends a Discord message (intern.closed_notice). Ids: "s..." Simplify's, "sv..." vanshb03's. + that closes is named in the next morning message (intern.closed_notice). Ids: "s..." Simplify's, "sv..." vanshb03's. - Short links (2026-10-26, my request): vanshb03's list gives go.vansh.nyc/ links, not the posting's. Each update asks where each new one leads (intern.real_links: its redirect only, the posting is not opened; 0.2 s apart; public addresses only; 5 failures in a row and the rest wait for the next run) and the listing keeps the posting's own link @@ -156,7 +156,7 @@ year's lists are new repos: changing the term moves the addresses with it); off as yes; one data/ratings.json. Approve = an Approved row in the Other jobs tab (Notes " internship (); match ..."), so no weekly or total cap; Undo = that row back to Proposed. Apply: `nuauto assist other `. - Data: data/intern/ (listings, source, links, triage, details/, failed, scores, categories, category_sets, pool, unread, - scans). Discord: new pool internships after a run; the morning message adds the last day's internship counts. + scans). No message per run: the morning message counts the last day's new pool internships, with the best one. - `nuauto intern fetch [--browser]` reads one posting now (nothing saved); `intern stats` shows each step. INSIGHTS (insights.py; `nuauto insights`, the GUI's Insights): read-only pay, places and kinds of work for the pool and diff --git a/src/nuauto/daily.py b/src/nuauto/daily.py index 625c483..6f8f3cb 100644 --- a/src/nuauto/daily.py +++ b/src/nuauto/daily.py @@ -1,8 +1,9 @@ """Pool update: list -> Claude triage -> details -> Claude score -> pool -> notifications (Discord + desktop). Runs on the homelab (systemd user timer nuauto-daily.timer, 08:00 and 18:00 New York time). - nuauto daily # the update; the morning run also sends deadline reminders - nuauto daily weekly # Sunday summary (nuauto-weekly.timer) + nuauto daily # the update; the morning run also sends the one message a day (morning()) + nuauto daily weekly # nuauto-weekly.timer (Sunday): nothing to send since 2026-10-26 +Messages: the morning message, and a problem when it happens (alert(): at most once a day each). From the laptop: `nuauto update` starts the homelab run and then syncs. Uses the hidden (headless) browser; never applies to anything and only reads the sheet. Claude runs as `claude -p` (Sonnet) with only Read/Write tools, one call per batch. @@ -28,6 +29,10 @@ AUTH_RE = re.compile(r"authenticat|logged out|/login|log in|oauth|401", re.I) SCANS = "scans.json" # per-run counts for the morning summary (data/) LAST_RUN = "last_run.json" # data/: how the last update went and why it stopped (the GUI's New jobs check, with a fix) +LATER = "later.json" # data/: what the next morning message adds (later(): {kind: [items]}), cleared once it is sent +TOLD = "told.json" # data/: when each problem was last told (alert(): at most once a day each) +TELL_AGAIN = timedelta(hours=20) # (a problem still there the next morning is told again) +QUIET = [] # set during the morning run's scan: its problems go in the morning message (wrong_line), not their own RETRY_WAIT = 120 # seconds before the job list is read once more: NUworks is sometimes slow for a while STOPPED = [] # this run's stops, in order: (reason, what stopped); main() keeps the first in LAST_RUN LOG_PATH = os.path.join(config.LOGS_DIR, f"daily-{datetime.now():%Y%m%d-%H%M}.txt") @@ -48,6 +53,27 @@ def notify(title, body=""): discord(f"**{title}**\n{body}" if body else f"**{title}**") +def alert(title, body=""): + """A problem worth a message now (a login to renew, a run that stopped). The same title at most once a day: the + 2-hourly internship update and the twice-daily update would otherwise repeat it every run.""" + told, now = jobs.load(TOLD, {}), datetime.now() + last = told.get(title) + if last and now - datetime.fromisoformat(last) < TELL_AGAIN: + return log(f"ALERT (told at {last}): {title} | {body}") + told[title] = now.isoformat(timespec="minutes") + jobs.save(TOLD, {k: v for k, v in told.items() if now - datetime.fromisoformat(v) < timedelta(days=7)}) + if QUIET: + return log(f"ALERT (in the morning message): {title} | {body}") + notify(title, body) + + +def later(kind, items): + """Things for the next morning message instead of a message now (kinds: "recorded", "closed", "deployed").""" + kept = jobs.load(LATER, {}) + kept[kind] = [*kept.get(kind, []), *(i for i in items if i not in kept.get(kind, []))][-10:] + jobs.save(LATER, kept) + + def discord(text): """Post to the Discord webhook in discord_webhook.txt (if present). Never logs the URL.""" try: @@ -68,7 +94,7 @@ def stop(reason, title, body, said=None): reason: nuworks_login (NUworks wants you to log in), nuworks (NUworks did not answer: the next run usually works), claude (Claude Code logged out or missing), failed (anything else). said: what stopped, in a few words.""" STOPPED.append((reason, said or title.removeprefix("NUauto: "))) - notify(title, body) + alert(title, body) def record_run(code): @@ -160,22 +186,31 @@ def step(fn, *args): def main(): - """The scan, then (morning run) the reminders. A failed scan still sends the reminders, from the sheet - and the last saved pool.""" + """The scan and the applications check, then (morning run) the morning message. A failed scan still sends it, from + the sheet and the last saved pool.""" before = {r["id"] for r in jobs.load("pool.json", [])} STOPPED.clear() + is_morning = datetime.now().hour < 12 # the one message a day, with the morning run: it carries this run's problems + if is_morning: + QUIET.append(True) try: - code = scan(before) - except Exception as e: # e.g. NUworks too slow, or the browser profile is busy because apply.py is running - traceback.print_exc() - what = f"{type(e).__name__}: {str(e)[:150]}" - # Playwright's TimeoutError: a NUworks page did not load (Claude's calls time out as TimeoutExpired) - stop("nuworks" if type(e).__name__ == "TimeoutError" else "failed", "NUauto: daily update failed", what, - said="NUworks did not load in time" if type(e).__name__ == "TimeoutError" else what[:120]) - code = 1 - if datetime.now().hour < 12: # reminders once a day, with the morning run + try: + code = scan(before) + except Exception as e: # e.g. NUworks too slow, or the browser profile is busy because apply.py is running + traceback.print_exc() + what = f"{type(e).__name__}: {str(e)[:150]}" + # Playwright's TimeoutError: a NUworks page did not load (Claude's calls time out as TimeoutExpired) + stop("nuworks" if type(e).__name__ == "TimeoutError" else "failed", "NUauto: daily update failed", what, + said="NUworks did not load in time" if type(e).__name__ == "TimeoutError" else what[:120]) + code = 1 + try: + applications() + except Exception as e: # never stops the morning message + log(f"applications check failed: {type(e).__name__}: {str(e)[:150]}") + finally: + QUIET.clear() + if is_morning: reminders(before, ok=code == 0) - applications() code = internships() or code record_run(code) return code @@ -183,7 +218,8 @@ def main(): def applications(): """Every run: ask NUworks about each sent row (jobs.check_applications), so the total limit counts what NUworks has. - Never changes the run's result: a sheet or NUworks that can't be read now is asked again next run.""" + Never changes the run's result: a sheet or NUworks that can't be read now is asked again next run. What to withdraw + (Rejected rows NUworks still has, jobs that look filled) goes in the log here and in the morning message.""" from nuauto import setup_sheet from nuauto import sheet try: @@ -193,7 +229,6 @@ def applications(): except Exception as e: # (the morning reminders say so when it is the Google login) return log(f"applications check skipped: the sheet can't be read ({type(e).__name__})") log("applications on NUworks") - before = {(r.number, why) for r, why in sheet.maybe_filled(rows)} # (the message tells each change once) ok, seen = step(jobs.check_applications, rows) if not ok: return log(f"applications check stopped: {seen}") @@ -203,18 +238,9 @@ def applications(): waiting = sheet.rejected_not_withdrawn(rows, seen) if waiting: log(f"rejected, not withdrawn on NUworks: row{'s' if len(waiting) > 1 else ''} {', '.join(str(r.number) for r in waiting)}") - if datetime.now().hour < 12: # the message once a day, with the morning run - notify(f"{len(waiting)} rejected application{'s' if len(waiting) > 1 else ''} still on NUworks", - "\n".join(f"{r.title[:40]} | {r.company[:22]}" for r in waiting) - + "\nWithdraw on NUworks: until you do they count toward the total.") done = sheet.maybe_filled(rows, seen) if done: log("job looks filled: " + ", ".join(f"row {r.number} ({why})" for r, why in done)) - new = [(r, why) for r, why in done if (r.number, why) not in before] - if new: - notify(f"{len(new)} job{'s' if len(new) > 1 else ''} you applied to may be filled", - "\n".join(f"{r.title[:40]} | {r.company[:22]}: {why}" for r, why in new) - + "\nIf you haven't heard back, you can withdraw on NUworks: until you do they count toward the total.") def internships(): @@ -286,44 +312,158 @@ def ready(): # claude_ready(), asked once per run, only when there is something new = [r for r in pool if r["id"] not in before] gone = len(before - {r["id"] for r in pool}) log(f"pool {len(pool)} ({len(new)} new, {gone} closed or dropped)") - record_scan(len(set(jobs.load("list.json", {})) - listed_before), len(new)) - if new: - top = "\n".join(f"{r['effective']}% {r['title'][:45]} | {r['company'][:25]}" for r in new[:5]) - notify(f"{len(new)} new job{'s' if len(new) > 1 else ''} in your NUworks pool", - top + "\nRun: nuauto approve") + best = max(new, key=lambda r: r["effective"], default=None) # (the morning message names it) + record_scan(len(set(jobs.load("list.json", {})) - listed_before), len(new), + best and {"score": best["effective"], "text": f"{best['title'][:40]} | {best['company'][:22]}"}) return 0 def reminders(before, ok): - """Morning reminders. After a good scan, first records jobs applied to by hand (needs the fresh list).""" - rows = sheet_rows() + """The morning message. After a good scan, first records jobs applied to by hand (needs the fresh list).""" + rows = sheet_rows(tell=False) # (the message itself says when the sheet can't be read) if rows is not None and ok: try: rows = record_hand_applications(before, rows) or rows - except Exception as e: # never let this block the reminders + except Exception as e: # never let this block the message log(f"hand applications check failed: {type(e).__name__}: {str(e)[:150]}") - pool = jobs.load("pool.json", []) - summary = scan_summary(jobs.load(SCANS, []), datetime.now()) + notify("NUauto morning", morning(rows, jobs.load("pool.json", []))) + jobs.save(LATER, {}) + + +def morning(rows, pool, now=None): + """The one message a day (since 2026-10-26: before, each of these was a message of its own, several a day). Short: + a line for each thing that applies: new jobs, what closes, what only you can do, what to withdraw, what was recorded + or updated, this week's count, what is wrong. rows: None when the sheet can't be read.""" + now = now or datetime.now() + lines = [] + for name, fn in [("new", lambda: new_line(now)), ("closing", lambda: closing_line(rows, pool, now.date())), + ("todo", lambda: todo_line(rows)), ("withdraw", lambda: withdraw_line(rows)), + ("later", later_lines), ("week", lambda: week_line(rows)), ("wrong", lambda: wrong_line(rows))]: + try: + got = fn() + except Exception as e: # one broken line must not stop the message + log(f"{name} line failed: {type(e).__name__}: {str(e)[:150]}") + continue + lines += got if isinstance(got, list) else [got] if got else [] + if config.REMOTE_APP: + from nuauto import review + lines.append(review.page_url()) + return "\n".join(lines) + + +def names(items, most=3, width=22): + """A few short names: "A, B, C +2".""" + items = [i[:width] for i in items] + return ", ".join(items[:most]) + (f" +{len(items) - most}" if len(items) > most else "") + + +def scan_part(scans, now, what, hours=24): + """How many jobs made a pool in the last `hours` (each run's counts: record_scan), with the best of them.""" + recent = [s for s in scans if now - datetime.fromisoformat(s["time"]) <= timedelta(hours=hours)] + n = sum(s["pool"] for s in recent) + best = max((s["best"] for s in recent if s.get("best")), key=lambda b: b["score"], default=None) + return f"{n} {what}{'s' if n != 1 else ''}" + (f" (best: {best['text']})" if best and n else "") + + +def new_line(now): from nuauto import intern + parts = [scan_part(jobs.load(SCANS, []), now, "co-op")] if intern.ENABLED: - summary += "\n" + intern.summary() - for name, fn in [("approved", lambda: approved_notice(rows)), ("urgent", lambda: urgent_notice(pool, rows)), - ("todo", lambda: todo_notice(rows, summary if ok else summary + " (this morning's scan failed)")), - ("google", google_notice)]: - try: - fn() - except Exception as e: # one broken reminder must not stop the others - log(f"{name} reminder failed: {type(e).__name__}: {str(e)[:150]}") + parts.append(scan_part(intern.load("scans.json", []), now, "internship")) + return "New: " + " · ".join(parts) -def google_notice(): - """Heads-up the day before the 7-day Google login runs out.""" +def closing_line(rows, pool, today, days=3): + """Approved rows that close today or tomorrow (by name: Start goes by closing date), and how many pool jobs close + within `days` that are not in the sheet yet and not rated no.""" + details = jobs.load_details() + due = [] + for r in rows or []: + d = details.get(jobs.job_id(r.url)) if r.status == "Approved" else None + day = jobs.closes(d) if d else None + if day is not None and 0 <= (day - today).days <= 1: + due.append((day, r)) + in_sheet = {jobs.job_id(r.url) for r in rows or []} + ratings = jobs.load("ratings.json", {}) + soon = [r for r in pool if r.get("closes") and r["id"] not in in_sheet and ratings.get(r["id"], {}).get("label") != 0 + and 0 <= (datetime.fromisoformat(r["closes"]).date() - today).days <= days] + log(f"approved closing today/tomorrow: {len(due)}; pool closing within {days} days, not in sheet: {len(soon)}") + parts = ([f"Approved {names([r.company for _, r in sorted(due, key=lambda x: x[0])])} by tomorrow"] if due else []) \ + + ([f"{len(soon)} pool job{'s' if len(soon) != 1 else ''} within {days} days (Review)"] if soon else []) + return "Closing: " + " · ".join(parts) if parts else None + + +def todo_line(rows): + """What only you can finish: company-site applications still owed and external jobs (Needs Human), the counts and + the first few: each name links to its job, its ✓ to the Mark-done page (when it is set up).""" from nuauto import sheet - age = sheet.google_login_age() - if age is not None and age >= config.GOOGLE_LOGIN_DAYS - 1: - left = config.GOOGLE_LOGIN_DAYS - age - title = "NUworks: Google login has expired" if left <= 0 else "NUworks: Google login expires today" - notify(title, "On the laptop run: `nuauto login google`") + from nuauto import web + site = [r for r in rows if r.status == "Applied" and r.notes.startswith(sheet.SITE_MARK)] if rows else [] + ext = [r for r in rows if r.status == "Needs Human" and r.notes.startswith("External application")] if rows else [] + log(f"todo: {len(site)} company-site, {len(ext)} external") + if not site and not ext: + return None + + def item(r, action): # no Mark-done page configured (web_base_url "") = no ✓ + mark = f" [✓]({web.link(action, r.number, jobs.job_id(r.url))})" if web.BASE_URL else "" + return f"[{r.company[:22]}]({r.url}){mark}" + counts = ([f"{len(site)} company site{'s' if len(site) != 1 else ''} owed"] if site else []) \ + + ([f"{len(ext)} external application{'s' if len(ext) != 1 else ''}"] if ext else []) + items = [item(r, "site") for r in site] + [item(r, "applied") for r in ext] + return (f"To do: {', '.join(counts)}: " + " · ".join(items[:3]) + (f" +{len(items) - 3}" if len(items) > 3 else "") + + (" (✓ marks one done)" if web.BASE_URL else "")) + + +def withdraw_line(rows): + """Applications NUworks still has that you may withdraw there, so they stop counting toward the total: Rejected + rows, and Applied rows whose job looks filled (the applications check just before: sheet.maybe_filled).""" + from nuauto import sheet + if rows is None: + return None + seen = sheet.nuworks_seen() + rejected = [r.company for r in sheet.rejected_not_withdrawn(rows, seen)] + filled = [r.company for r, _ in sheet.maybe_filled(rows, seen)] + parts = ([f"rejected {names(rejected)}"] if rejected else []) + ([f"may be filled {names(filled)}"] if filled else []) + return "Withdraw on NUworks to free room: " + " · ".join(parts) if parts else None + + +LATER_TEXT = {"recorded": "Recorded what you applied to on NUworks", "closed": "Approved internships now closed", + "deployed": "Updated"} + + +def later_lines(): + """What other runs kept for this message (later()).""" + return [f"{LATER_TEXT.get(k, k)}: {names(v, 2, 60) if k == 'deployed' else names(v)}" + for k, v in jobs.load(LATER, {}).items() if v] + + +def week_line(rows): + from nuauto import sheet + if rows is None: + return None + week, total = sheet.check_limits_safe(rows) + waiting = sum(1 for r in rows if r.status == "Approved") + return f"Week: {week}/{sheet.max_per_week()} applied · total {total}/{sheet.MAX_TOTAL} · {waiting} Approved waiting" + + +FIX = {"nuworks_login": "run `nuauto login` on the laptop", "claude": "run `claude auth login` on {where}", + "nuworks": "NUworks was slow; the next run usually works", "failed": "the update's log on {where} says why"} + + +def wrong_line(rows): + """What is wrong now: this run stopped (its message was held for this one: alert, QUIET), the sheet can't be read, + or the Google login runs out.""" + from nuauto import sheet + parts = [f"the update stopped: {said} ({FIX.get(reason, FIX['failed']).format(where=where())})" + for reason, said in STOPPED[:1]] + if rows is None: + parts.append("can't read the sheet (the Google login? on the laptop run `nuauto login google`)") + else: + age = sheet.google_login_age() + if age is not None and age >= config.GOOGLE_LOGIN_DAYS - 1: + parts.append(("the Google login has expired" if age >= config.GOOGLE_LOGIN_DAYS else + "the Google login expires today") + ": on the laptop run `nuauto login google`") + return "Wrong: " + " · ".join(parts) if parts else None def record_hand_applications(before, rows): @@ -348,114 +488,39 @@ def record_hand_applications(before, rows): n = len(ws.get_all_values()) + 1 ws.update(range_name=f"A{n}:F{n}", values=[[jobs.job_url(i), d.get("company", ""), d.get("title", ""), "Applied", "Applied by hand (seen on NUworks)", datetime.now().strftime(sheet.DATE_FMT)]], value_input_option="RAW") - done.append(f"{d.get('title', i)[:40]} | {d.get('company', '')[:22]}") - notify(f"Recorded {len(done)} application{'s' if len(done) > 1 else ''} you made on NUworks", "\n".join(done)) + done.append(d.get("company") or d.get("title", i)) + log(f"hand applications: recorded {', '.join(done)}") + later("recorded", done) return sheet.read_rows(ws) -def record_scan(listed, pooled): - """Remember this run's counts (new NUworks postings, new pool jobs) for the morning summary.""" +def record_scan(listed, pooled, best=None): + """Remember this run's counts (new NUworks postings, new pool jobs) and its best new job ({"score", "text"}) for the + morning message.""" scans = jobs.load(SCANS, []) - scans.append({"time": datetime.now().isoformat(timespec="minutes"), "listed": listed, "pool": pooled}) + scans.append({"time": datetime.now().isoformat(timespec="minutes"), "listed": listed, "pool": pooled, + **({"best": best} if best else {})}) jobs.save(SCANS, scans[-60:]) -def scan_summary(scans, now, hours=24): - recent = [s for s in scans if now - datetime.fromisoformat(s["time"]) <= timedelta(hours=hours)] - listed, pooled = sum(s["listed"] for s in recent), sum(s["pool"] for s in recent) - return (f"Last {hours}h ({len(recent)} scan{'s' if len(recent) != 1 else ''}): {listed} new posting{'s' if listed != 1 else ''}" - f" on NUworks, {pooled} made your pool" + (" (nuauto approve)" if pooled else "")) - - -def todo_notice(rows, summary): - """Morning message, always sent: scan counts, then things only you can do: company-site applications - still owed, and external jobs (Needs Human).""" - from nuauto import sheet - from nuauto import web - site = [r for r in rows if r.status == "Applied" and r.notes.startswith(sheet.SITE_MARK)] if rows else [] - ext = [r for r in rows if r.status == "Needs Human" and r.notes.startswith("External application")] if rows else [] - if not site and not ext: - log("todo: nothing owed") - return notify("NUauto morning", summary + ("\nNothing only you need to finish." if rows is not None else "")) - lines = [summary, "Only you can finish:"] - def mark(label, action, r): # no Mark-done page configured (web_base_url "") = no link - return f" · [{label}]({web.link(action, r.number, jobs.job_id(r.url))})" if web.BASE_URL else "" - for r in site: - lines.append(f"• {r.company[:25]}: apply on their site too ([job]({r.url})){mark('Mark done', 'site', r)}") - for r in ext: - lines.append(f"• {r.company[:25]}: external application ([job]({r.url})){mark('Mark applied', 'applied', r)}") - log(f"todo: {len(site)} company-site, {len(ext)} external") - notify("NUauto morning", "\n".join(lines)) - - -def sheet_rows(): - """Sheet rows, or None if the sheet can't be read (e.g. the Google token expired).""" +def sheet_rows(tell=True): + """Sheet rows, or None if the sheet can't be read (e.g. the Google token expired). tell: a message about it (the + morning message says it in its own line).""" try: from nuauto import sheet return sheet.read_rows(sheet.open_worksheet()) except BaseException as e: # gspread's login flow can sys.exit log(f"could not read the sheet ({type(e).__name__})") - notify("NUauto: can't read the Google sheet", - "Google login probably expired (it lasts 7 days). On the laptop run: `nuauto login google`") + if tell: + alert("NUauto: can't read the Google sheet", + "Google login probably expired (it lasts 7 days). On the laptop run: `nuauto login google`") return None -def approved_notice(rows): - """Approved rows (not applied yet) that close today or tomorrow.""" - if rows is None: - return - details = jobs.load_details() - today = datetime.now().date() - due = [] - for r in rows: - d = details.get(jobs.job_id(r.url)) if r.status == "Approved" else None - day = jobs.closes(d) if d else None - if day is not None and 0 <= (day - today).days <= 1: - due.append((day, r)) - log(f"approved closing today/tomorrow: {len(due)}") - if due: - body = "\n".join(f"{jobs.closes_text(day)} {r.title[:40]} | {r.company[:22]}" for day, r in sorted(due, key=lambda x: x[0])) - notify(f"{len(due)} Approved job{'s' if len(due) > 1 else ''} close today or tomorrow", body + "\nRun: nuauto apply") - - -def urgent_notice(pool, rows, days=3): - """Pool jobs closing within `days` that are not in the sheet yet and not rated no.""" - in_sheet = {jobs.job_id(r.url) for r in rows} if rows is not None else set() - ratings = jobs.load("ratings.json", {}) - today = datetime.now().date() - soon = sorted((r for r in pool if r.get("closes") and r["id"] not in in_sheet - and ratings.get(r["id"], {}).get("label") != 0 - and 0 <= (datetime.fromisoformat(r["closes"]).date() - today).days <= days), - key=lambda r: r["closes"]) - log(f"closing within {days} days, not in sheet: {len(soon)}") - if soon: - body = "\n".join(f"{r['closes'][5:]} {r['match']}% {r['title'][:40]} | {r['company'][:22]}" for r in soon[:5]) - notify(f"{len(soon)} pool job{'s' if len(soon) > 1 else ''} close within {days} days", body + "\nRun: nuauto approve") - - def weekly(): - """Sunday summary: this week's applications, Approved rows waiting, jobs to look at.""" - from nuauto import sheet - rows = sheet_rows() - if rows is None: - return 1 - week, total = sheet.check_limits_safe(rows) - waiting = [r for r in rows if r.status == "Approved"] - details, ratings = jobs.load_details(), jobs.load("ratings.json", {}) - today = datetime.now().date() - soon = [r for r in waiting if (d := details.get(jobs.job_id(r.url))) and jobs.closes(d) - and jobs.closes(d) <= today + timedelta(days=7)] - in_sheet = {jobs.job_id(r.url) for r in rows} - unseen = [r for r in jobs.load("pool.json", []) if r["id"] not in in_sheet and r["id"] not in ratings] - room = max(0, sheet.max_per_week() - week) - lines = [f"Applied {sheet.week_window()[1]}: {week}/{sheet.max_per_week()} (total {total}/{sheet.MAX_TOTAL}).", - f"Approved and waiting: {len(waiting)}" + (f" ({len(soon)} close within 7 days)" if soon else "") + ".", - f"Pool jobs you haven't looked at: {len(unseen)}."] - if waiting: - lines.append(f"Run `nuauto apply` (room for {room} this week).") - elif room: - lines.append(f"Nothing Approved. Run `nuauto approve` to pick up to {room}.") - notify("NUauto weekly check-in", "\n".join(lines)) + """`nuauto daily weekly` (Sunday, nuauto-weekly.timer). Since 2026-10-26 it sends nothing: the morning message has + this week's count every day. Kept so the installed timer still runs cleanly.""" + log("weekly: nothing to send (the morning message has the week's count)") return 0 @@ -463,5 +528,5 @@ def weekly(): try: sys.exit(weekly() if sys.argv[1:] == ["weekly"] else main()) except Exception as e: # main() handles scan failures itself; this catches the rest - notify("NUauto: daily update failed", f"{type(e).__name__}: {str(e)[:150]}") + alert("NUauto: daily update failed", f"{type(e).__name__}: {str(e)[:150]}") raise diff --git a/src/nuauto/deploy.py b/src/nuauto/deploy.py index 25eece3..240ab44 100644 --- a/src/nuauto/deploy.py +++ b/src/nuauto/deploy.py @@ -87,9 +87,10 @@ def install_deps(self): if r.returncode: raise DeployError("reinstalling the package failed: " + r.stderr.strip()[-300:]) - def announce(self, text): + def announce(self, text, later=False): + """A failed deploy: a message now. later: a deploy that worked, a line in the next morning message instead.""" from nuauto import daily - daily.discord(text) + daily.later("deployed", [text]) if later else daily.discord(text) # ---- helpers def say(self, msg): @@ -290,7 +291,7 @@ def run(self): self.say(f"deployed {sha[:7]} ({subject[:60]}): {what}" if changed else f"deployed {sha[:7]}: nothing to copy") if changed: try: - self.announce(f"**Deployed** {sha[:7]}: {subject[:80]}\n{what}") + self.announce(f"{sha[:7]} {subject[:50]}", later=True) except Exception: pass return 0 diff --git a/src/nuauto/intern.py b/src/nuauto/intern.py index 9d8c905..8afa704 100644 --- a/src/nuauto/intern.py +++ b/src/nuauto/intern.py @@ -28,7 +28,7 @@ import time import urllib.error import urllib.request -from datetime import date, datetime, timedelta +from datetime import date, datetime from urllib.parse import parse_qsl, urlencode, urljoin, urlparse, urlunparse from nuauto import config @@ -117,12 +117,6 @@ def list_name(i): return NAMES[list_of(i)] -def lists_text(): - """The lists read, for messages: "Simplify's list", or "Simplify's and vanshb03's lists".""" - on = [NAMES[k].removesuffix(" list") for k, _, _ in LISTS if LIST_URLS.get(k)] - return f"{on[0]} list" if len(on) == 1 else ", ".join(on[:-1]) + f" and {on[-1]} lists" if on else "no list" - - def clean_url(url): """The posting link without tracking parameters (utm_*, ref=Simplify); everything else stays (gh_jid...).""" u = urlparse(str(url or "").strip()) @@ -929,8 +923,9 @@ def run_lock(): def update(): - """The whole update: list -> triage -> postings -> score -> category -> pool, then a notification for new pool - jobs. Returns 0, or 1 if a step stopped (already notified).""" + """The whole update: list -> triage -> postings -> score -> category -> pool; the counts (and the best new one) go to + scans.json for the morning message (no message of its own since 2026-10-26). Returns 0, or 1 if a step stopped + (already told: daily.alert).""" from nuauto import daily lock = run_lock() if lock is None: @@ -963,20 +958,18 @@ def ready(): pool = save_pool() new = [r for r in pool if r["id"] not in before] daily.log(f"internship pool {len(pool)} ({len(new)} new)") + best = max(new, key=lambda r: r["match"], default=None) scans = load("scans.json", []) scans.append({"time": datetime.now().isoformat(timespec="minutes"), "listed": len(changed[0]) if changed else 0, - "pool": len(new)}) + "pool": len(new), **({"best": {"score": best["match"], "text": f"{best['title'][:40]} | " + f"{best['company'][:22]} ({best['match']}% match)"}} if best else {})}) save("scans.json", scans[-200:]) - if new: - top = "\n".join(f"{r['match']}% {r['title'][:45]} | {r['company'][:25]}" for r in new[:5]) - daily.notify(f"{len(new)} new internship{'s' if len(new) > 1 else ''} in your pool", - top + "\nReview: NUauto > Review > Summer internships (or nuauto intern approve)") return 0 def closed_notice(closed, daily): - """Internships you approved that their list now shows as closed: a message, so no assistant run is spent on - one. The sheet is only read.""" + """Internships you approved that their list now shows as closed: a line in the morning message (daily.later), so no + assistant run is spent on one. The sheet is only read.""" listings = load("listings.json", {}) urls = {listings[i]["url"]: listings[i] for i in closed if i in listings} try: @@ -985,19 +978,8 @@ def closed_notice(closed, daily): daily.log(f"closed internships: could not read the sheet ({type(e).__name__})") return if rows: - daily.notify(f"{len(rows)} approved internship{'s' if len(rows) > 1 else ''} closed on " - + (list_name(urls[real_url(rows[0].url)]["id"]) if len(rows) == 1 else "their lists"), - "\n".join(f"Other jobs row {r.number}: {r.title[:40]} | {r.company[:22]}" for r in rows) - + "\nCheck the posting before starting the assistant (or remove the row on the Sheet screen).") - - -def summary(now=None, hours=24): - """For the morning message: what the last day's internship updates found.""" - now = now or datetime.now() - recent = [s for s in load("scans.json", []) if now - datetime.fromisoformat(s["time"]) <= timedelta(hours=hours)] - listed, pooled = sum(s["listed"] for s in recent), sum(s["pool"] for s in recent) - return (f"Internships ({TERM}): {listed} new on {lists_text()}, {pooled} made your pool" - + (" (Review > Summer internships)" if pooled else "")) + daily.log(f"closed internships: Other jobs rows {', '.join(str(r.number) for r in rows)}") + daily.later("closed", [f"{r.company[:22]} (row {r.number})" for r in rows]) def stats(): diff --git a/tests/test_daily.py b/tests/test_daily.py index b4e4c76..475e91b 100644 --- a/tests/test_daily.py +++ b/tests/test_daily.py @@ -3,6 +3,7 @@ Uses a temp data dir, temp log file and a stubbed notify: no sheet, no Discord, no network. """ import json +import re import os import tempfile from datetime import datetime @@ -40,25 +41,22 @@ log_text = open(daily.LOG_PATH).read() assert "applications: 1 count toward the total; NUworks has none for row 2" in log_text, log_text[-300:] assert "rejected, not withdrawn on NUworks: row 3" in log_text, log_text[-300:] # NUworks still has the Rejected row's application -# an Applied row whose job stopped interviewing / got filled: in the log every run, in a message once per change +# an Applied row whose job stopped interviewing / got filled: in the log every run, no message (the morning one has it) R_APPS.append(sheet.Row(4, "https://n/jobs/detail/c", "Filled Co", "Firmware", "Applied", "", "2026-10-03")) saved = os.path.join(config.DATA_DIR, sheet.APPLICATIONS) -answer = {"applied": True, "fingerprint": sheet.fingerprint(R_APPS[2]), "interviewing_ended": "t2"} -daily.jobs.check_applications = lambda rows: {"c": answer} +answers = {"b": {"applied": True, "fingerprint": sheet.fingerprint(R_APPS[1])}, + "c": {"applied": True, "fingerprint": sheet.fingerprint(R_APPS[2]), "interviewing_ended": "t2"}} +daily.jobs.check_applications = lambda rows: answers sent.clear() daily.applications() -assert "job looks filled: row 4 (stopped interviewing)" in open(daily.LOG_PATH).read() -assert [t for t, _ in sent] == ["1 job you applied to may be filled"] and "Firmware | Filled Co: stopped interviewing" in sent[0][1] +assert "job looks filled: row 4 (stopped interviewing)" in open(daily.LOG_PATH).read() and sent == [], sent os.makedirs(config.DATA_DIR, exist_ok=True) -with open(saved, "w") as f: # (what that check saved) - json.dump({"c": answer}, f) -sent.clear() -daily.applications() -assert sent == [] # the same news: told once -daily.jobs.check_applications = lambda rows: {"c": {**answer, "filled_since": "t3"}} -daily.applications() -assert [t for t, _ in sent] == ["1 job you applied to may be filled"] and ": filled" in sent[0][1] +with open(saved, "w") as f: # (what that check saved: the morning message reads it) + json.dump(answers, f) +line = daily.withdraw_line(R_APPS) +assert line == "Withdraw on NUworks to free room: rejected Co · may be filled Filled Co", line os.remove(saved) +assert daily.withdraw_line(R_APPS) is None and daily.withdraw_line(None) is None R_APPS.pop() def no_nuworks(rows): raise SystemExit(daily.jobs.NOT_LOGGED_IN) @@ -73,34 +71,57 @@ def no_sheet(interactive=True): sheet.open_worksheet, sheet.read_rows, daily.jobs.check_applications, setup_sheet.ensure_dropdown = real_sheet_fns daily.applications = lambda: None # (main() below: never the real sheet or NUworks) -# record_scan keeps per-run counts; scan_summary adds up the last 24 hours only -daily.record_scan(12, 2) +# record_scan keeps per-run counts and the best new job; scan_part adds up the last 24 hours only +daily.record_scan(12, 2, {"score": 109, "text": "Firmware Co-op | Harbor (score 109)"}) scans = daily.jobs.load(daily.SCANS, []) -assert len(scans) == 1 and scans[0]["listed"] == 12 and scans[0]["pool"] == 2, scans -now = datetime(2026, 10, 5, 8, 5) -scans = [{"time": "2026-10-04T07:00", "listed": 99, "pool": 9}, # older than 24h: ignored - {"time": "2026-10-04T18:04", "listed": 5, "pool": 1}, - {"time": "2026-10-05T08:03", "listed": 3, "pool": 0}] -s = daily.scan_summary(scans, now) -assert s == "Last 24h (2 scans): 8 new postings on NUworks, 1 made your pool (nuauto approve)", s -s = daily.scan_summary([], now) -assert s == "Last 24h (0 scans): 0 new postings on NUworks, 0 made your pool", s +assert len(scans) == 1 and scans[0]["listed"] == 12 and scans[0]["pool"] == 2 and scans[0]["best"]["score"] == 109, scans for _ in range(70): daily.record_scan(1, 0) -assert len(daily.jobs.load(daily.SCANS, [])) == 60 # capped - -# morning message is always sent, with the summary first -row = lambda n, status, notes: sheet.Row(n, f"https://x/jobs/{n}", f"Co{n}", "Title", status, notes, "") -daily.todo_notice([row(2, "Applied", "")], "SUMMARY") -assert sent[-1] == ("NUauto morning", "SUMMARY\nNothing only you need to finish."), sent[-1] -daily.todo_notice(None, "SUMMARY") # sheet unreadable: still the summary, no claim about the to-do list -assert sent[-1] == ("NUauto morning", "SUMMARY"), sent[-1] -web.BASE_URL = "" # no Mark-done page: no links -daily.todo_notice([row(3, "Applied", sheet.SITE_MARK + " ..."), row(4, "Needs Human", "External application ...")], "SUMMARY") -title, body = sent[-1] -lines = body.split("\n") -assert title == "NUauto morning" and lines[:2] == ["SUMMARY", "Only you can finish:"], sent[-1] -assert "Co3" in lines[2] and "Co4" in lines[3] and "Mark" not in body, body +assert len(daily.jobs.load(daily.SCANS, [])) == 60 and "best" not in daily.jobs.load(daily.SCANS, [])[-1] # capped +now = datetime(2026, 10, 5, 8, 5) +scans = [{"time": "2026-10-04T07:00", "listed": 99, "pool": 9, "best": {"score": 200, "text": "old"}}, # older than 24h + {"time": "2026-10-04T18:04", "listed": 5, "pool": 1, "best": {"score": 90, "text": "A | X (score 90)"}}, + {"time": "2026-10-05T08:03", "listed": 3, "pool": 2, "best": {"score": 95, "text": "B | Y (score 95)"}}] +assert daily.scan_part(scans, now, "co-op") == "3 co-ops (best: B | Y (score 95))" +assert daily.scan_part([], now, "internship") == "0 internships" +daily.jobs.save(daily.SCANS, scans) + +# a problem: a message at once, then the same one at most once a day however often the runs fail +sent.clear() +daily.alert("NUauto: X broke", "why") +daily.alert("NUauto: X broke", "why") +assert sent == [("NUauto: X broke", "why")], sent +told = daily.jobs.load(daily.TOLD, {}) +daily.jobs.save(daily.TOLD, {**told, "NUauto: X broke": (datetime.now() - daily.TELL_AGAIN).isoformat(timespec="minutes")}) +daily.alert("NUauto: X broke", "why") +assert len(sent) == 2, sent # the next day: told again + +# the morning message: always sent, one short line for each thing that applies +row = lambda n, status, notes, day="2026-10-05": sheet.Row(n, f"https://x/jobs/{n}", f"Co{n}", "Title", status, notes, day) +web.BASE_URL = "" # no Mark-done page: no Mark links +config.REMOTE_APP = False # (your own settings may have the homelab's page: its link would end the message) +sheet.google_login_age = lambda: 1 +lines = daily.morning([row(2, "Applied", "")], [], now).split("\n") +assert lines[0] == "New: 3 co-ops (best: B | Y (score 95))" and len(lines) == 2, lines +assert re.fullmatch(r"Week: \d+/\d+ applied · total 1/99 · 0 Approved waiting", lines[1]), lines # (this week: by today) +lines = daily.morning([row(3, "Applied", sheet.SITE_MARK + " ..."), row(4, "Needs Human", "External application ...", "")], + [], now).split("\n") +assert lines[1] == "To do: 1 company site owed, 1 external application: [Co3](https://x/jobs/3) · [Co4](https://x/jobs/4)", lines +daily.later("recorded", ["Acme"]) +daily.later("recorded", ["Acme", "Beta"]) # (kept once each) +daily.later("deployed", ["abc1234 Shorter messages"]) +sheet.google_login_age = lambda: 7 +lines = daily.morning(None, [], now).split("\n") # the sheet can't be read: no to-do or week, said why +assert lines[1:] == ["Recorded what you applied to on NUworks: Acme, Beta", "Updated: abc1234 Shorter messages", + "Wrong: can't read the sheet (the Google login? on the laptop run `nuauto login google`)"], lines +lines = daily.morning([], [], now).split("\n") +assert lines[-1] == "Wrong: the Google login has expired: on the laptop run `nuauto login google`", lines +sheet.google_login_age = lambda: 1 +config.REMOTE_APP = True +from nuauto import review # noqa: E402 +real_page, review.page_url = review.page_url, lambda slot=None: "http://page/" +assert daily.morning([], [], now).split("\n")[-1] == "http://page/" # the homelab's page ends it +review.page_url, config.REMOTE_APP = real_page, False # a batch's Claude is shut in work/ (the batches hold web text: job postings, Simplify's list): it runs there, may read # and edit only there (Edit rules cover Write; never a bare Write, which would allow every path), no shell, web or MCP @@ -145,7 +166,8 @@ def missing(): # no batches: nothing to do, no login check assert daily.claude_step(lambda: 1 / 0, "TRIAGE_PROMPT.md", [], no_import, "T") is True -# a failed scan (e.g. NUworks too slow) still sends the morning reminders, from the sheet + last saved pool +# a failed scan (e.g. NUworks too slow) still sends the morning message, from the sheet + last saved pool; the +# problem is in it (no message of its own), and one broken line never stops the others class Morning(datetime): @classmethod def now(cls, tz=None): @@ -157,24 +179,23 @@ def never(*a): raise AssertionError("hand-application check needs a fresh list; must not run after a failed scan") real_scan = daily.scan daily.scan, daily.record_hand_applications = slow, never -daily.sheet_rows = lambda: [row(4, "Needs Human", "External application ...")] -daily.google_notice = lambda: 1 / 0 # one broken reminder must not stop the others +daily.sheet_rows = lambda tell=True: [row(4, "Needs Human", "External application ...", "")] +daily.closing_line = lambda *a: 1 / 0 sent.clear() -assert daily.main() == 1 -titles = [t for t, _ in sent] -assert titles[0] == "NUauto: daily update failed" and "TimeoutError" in sent[0][1], sent -assert "NUauto morning" in titles, sent -body = dict(sent)["NUauto morning"] -assert "(this morning's scan failed)" in body and "Co4" in body, body +assert daily.main() == 1 and not daily.QUIET +assert [t for t, _ in sent] == ["NUauto morning"], sent +body = sent[0][1] +assert "1 external application: [Co4]" in body and "Wrong: the update stopped: NUworks did not load in time (NUworks was slow" in body, body +assert daily.jobs.load(daily.LATER, {}) == {} # what it kept went out with it -# afternoon run: no reminders +# the evening run: no morning message, and the same problem was told today already: nothing class Evening(Morning): @classmethod def now(cls, tz=None): return datetime(2026, 10, 5, 18, 6) daily.datetime = Evening sent.clear() -assert daily.main() == 1 and [t for t, _ in sent] == ["NUauto: daily update failed"], sent +assert daily.main() == 1 and sent == [], sent # why a run stopped is kept for the GUI (last_run.json), and a slow NUworks gets a second try daily.scan = real_scan diff --git a/tests/test_deploy.py b/tests/test_deploy.py index a102bfa..3add29f 100644 --- a/tests/test_deploy.py +++ b/tests/test_deploy.py @@ -67,8 +67,8 @@ def update_running(self): def install_deps(self): self.installs += 1 - def announce(self, text): - self.said.append(text) + def announce(self, text, later=False): + self.said.append(("later: " if later else "") + text) deploy.time.sleep = lambda s: None # the restart check waits a few seconds @@ -95,12 +95,12 @@ def announce(self, text): n = len(d.calls) assert d.run() == 0 and len(d.calls) == n and not d.said, "the same commit again must do nothing" -# a docs-only commit: copied, no web restart, no reinstall, one Discord message, tests/ never copied +# a docs-only commit: copied, no web restart, no reinstall, a line for the morning message, tests/ never copied commit({"docs/DEPLOY.md": "v2\n", "tests/test_x.py": "changed\n"}, "docs only") assert d.run() == 0 assert read_live("docs/DEPLOY.md") == "v2\n" and not os.path.exists(os.path.join(LIVE, "tests")) assert ("restart", "nuauto-web.service") not in d.calls and d.installs == 0 -assert len(d.said) == 1 and "docs only" in d.said[0], d.said +assert len(d.said) == 1 and d.said[0].startswith("later: ") and "docs only" in d.said[0], d.said assert d.load_state()["sha"] != first_sha # web code changed + a new module: both copied, web restarted diff --git a/tests/test_intern.py b/tests/test_intern.py index c50c6e4..029401a 100644 --- a/tests/test_intern.py +++ b/tests/test_intern.py @@ -232,7 +232,6 @@ def sim(n, title="Firmware Intern", term="Summer 2027", active=True, degrees=("B # ---------------------------------------------------------------- vanshb03's list: a season, not terms; the same job on two lists assert set(intern.LIST_URLS) == {"simplify", "vansh"} and all(intern.LIST_URLS.values()) -assert intern.lists_text() == "Simplify's and vanshb03's lists" def van(n, title="Firmware Intern", season="Summer", active=True, url=None, places=("Boston, MA",), company=None): @@ -627,11 +626,7 @@ def __enter__(self): assert trains == [1, 1, 1] jobs.load_details, jobs.train = real_load, real_train -# summary for the morning message; stats run -intern.save("scans.json", [{"time": "2026-10-10T06:00", "listed": 4, "pool": 1}, {"time": "2026-10-08T06:00", "listed": 9, "pool": 9}]) -from datetime import datetime # noqa: E402 -assert intern.summary(datetime(2026, 10, 10, 8, 0)) == \ - "Internships (Summer 2027): 4 new on Simplify's and vanshb03's lists, 1 made your pool (Review > Summer internships)" +# stats run intern.stats() # the whole update, Claude's calls stood in for, the list read from the demo file @@ -660,18 +655,18 @@ def fake_claude(prompt_file, batch, extra=None): intern.save("demo_postings.json", {"https://jobs.example.com/11": TEXT}) assert intern.update() == 0 pool = intern.load("pool.json", []) -assert ID(11) in {r["id"] for r in pool} and sent and sent[-1][0] == "1 new internship in your pool", sent -assert "Robotics Intern" in sent[-1][1] -# an approved internship that Simplify closes: a message (the sheet is only read) +assert ID(11) in {r["id"] for r in pool} and sent == [], sent # no message: the morning one counts it +best = intern.load("scans.json", [])[-1]["best"] +assert best["score"] == 91 and best["text"].startswith("Robotics Intern") and "(91% match)" in best["text"], best +# an approved internship that Simplify closes: a line in the next morning message (the sheet is only read) real_rows = intern.other_rows intern.other_rows = lambda interactive=True: [R(4, "https://jobs.example.com/11", "Approved"), R(5, "https://jobs.example.com/6", "Applied")] -sent.clear() intern.closed_notice([ID(11), ID(6)], daily) -assert len(sent) == 1 and sent[0][0] == "1 approved internship closed on Simplify's list" and "Other jobs row 4" in sent[0][1], sent +kept = daily.jobs.load(daily.LATER, {})["closed"] +assert sent == [] and len(kept) == 1 and kept[0].endswith("(row 4)"), (sent, kept) intern.other_rows = lambda interactive=True: (_ for _ in ()).throw(sheet.NotLoggedIn("expired")) -sent.clear() -intern.closed_notice([ID(11)], daily) # the sheet can't be read: no message, no crash -assert sent == [] +intern.closed_notice([ID(11)], daily) # the sheet can't be read: nothing kept, no crash +assert sent == [] and daily.jobs.load(daily.LATER, {})["closed"] == kept intern.other_rows = real_rows # a crash in the 2-hourly run: Discord says so, the unit fails real_update = intern.update From 710489c557b00950d1828ff2c68f51cf35223e68 Mon Sep 17 00:00:00 2001 From: voidReq <100312344+voidReq@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:32:34 -0400 Subject: [PATCH 3/4] Close dates: internships show theirs, the morning message names what closes this week MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Internship rows on Apply showed only when they were posted, even when the company's posting gives a close date (the Review card had it). Now they show it like co-op rows do, else "no close date · posted N days ago". Oracle's posting end date and Greenhouse's deadline field are read too (postings.end_day, which drops far-off placeholders), and postings saved before from Oracle are read once more for the date alone (intern.redate). The morning message's "Closing soon" line now covers Approved rows on both tabs that close within a week, soonest first with the day, plus pool co-ops and internships closing within 3 days. Co-Authored-By: Claude Opus 5.5 --- docs/ARCHITECTURE.md | 9 +++--- docs/DEPLOY.md | 13 ++++---- docs/GUI.md | 3 +- docs/PIPELINE.md | 8 ++++- src/nuauto/daily.py | 58 ++++++++++++++++++++++++------------ src/nuauto/demo.py | 3 +- src/nuauto/gui.py | 15 +++++++--- src/nuauto/gui_static/app.js | 7 +++-- src/nuauto/intern.py | 37 +++++++++++++++++++++++ src/nuauto/postings.py | 18 +++++++---- tests/test_daily.py | 21 ++++++++++++- tests/test_gui.py | 3 ++ tests/test_intern.py | 26 ++++++++++++++++ 13 files changed, 175 insertions(+), 46 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 69e9c55..0d833a2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -50,9 +50,9 @@ Job pool job's hiring stage tracked across checks). fields.py per-field starting preferences (the wizard's "What do you study?"). intern.py Summer internships (Simplify's and vanshb03's lists, one job on both kept once): the second pool, built - like jobs.py (refresh, triage, fetch, score, category, build_pool); approve() -> manage.add("other"); - update(), review_queue(), stats(). Short links -> the posting's own (real_links, real_url; used by - assist.other_target). Data in data/intern/. + like jobs.py (refresh, triage, fetch, redate, score, category, build_pool); approve() -> + manage.add("other"); update(), review_queue(), closing_dates(), stats(). Short links -> the posting's + own (real_links, real_url; used by assist.other_target). Data in data/intern/. postings.py reads one posting from a company's own job site, no login; public addresses only (allowed_host). daily.py what the homelab timers run. main() -> scan() (via step()), applications(), reminders() (the morning message: morning(), its lines; record_hand_applications()), internships(); run_claude(); messages: @@ -213,7 +213,8 @@ use headless Firefox on local pages or demo mode. Run it before pushing; CI runs test_deploy deploy.py on a throwaway git repo test_doctor_remote doctor.check_remote_assist test_health health.py's checks test_insights Insights numbers test_selftest `nuauto selftest` test_setup the wizard (onboard.py) - test_intern internship pool, postings.py (links, parsing, the network guard, fetch tries), short links, Review queue + test_intern internship pool, postings.py (links, parsing, close dates, the network guard, fetch tries, redate), + short links, Review queue test_assist the agent's guard, answer bank, slots, passwords, uploads, by-hand rows test_accounts accounts.py: sites, Bitwarden export / import of your own logins test_mail mail.py: Gmail's format, which mail counts, the `email` command diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index de3f8f9..38d0abb 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -62,12 +62,13 @@ Unit files live in this repo (deploy/systemd/) and are installed in ~/.config/sy - the internship update (when "internships" is on). DISCORD (since 2026-10-26, my request: fewer, shorter messages, glanced at once a day). Two kinds only: - "NUauto morning" (daily.morning), always sent, a line for each thing that applies: New (pool co-ops and - internships of the last 24h, each with the best one), Closing (Approved rows closing today or tomorrow by name, pool - jobs closing within 3 days not in the sheet), To do (company-site applications owed, external Needs Human rows, - with their job and signed Mark links), Withdraw on NUworks (Rejected / may be filled), what other runs kept for it - (daily.later -> data/later.json: applications recorded from NUworks, approved internships now closed, deploys), - Week (this week's count, the total, Approved waiting), Wrong (this run stopped, the sheet can't be read, the Google - login runs out), and NUauto's page when there is one. + internships of the last 24h, each with the best one), Closing soon (Approved rows on both tabs closing within a week, + soonest first with the day, an internship's date from its posting; pool co-ops and internships closing within 3 + days not in the sheet, counted), To do (company-site applications owed, external Needs Human rows, the first three: + each name links to its job, its ✓ to the Mark-done page), Withdraw on NUworks (Rejected / may be filled), what + other runs kept for it (daily.later -> data/later.json: applications recorded from NUworks, approved internships + now closed, deploys), Week (this week's count, the total, Approved waiting), Wrong (this run stopped, the sheet + can't be read, the Google login runs out), and NUauto's page when there is one. - a problem when it happens (daily.alert: a run that stopped, a login to renew, the sheet unreadable, a crash): the same title at most once a day (data/told.json, TELL_AGAIN), so the 2-hourly internship update failing the same way is told once. The morning run's own problems go in the morning message instead (daily.QUIET). diff --git a/docs/GUI.md b/docs/GUI.md index e82afe1..ac92d19 100644 --- a/docs/GUI.md +++ b/docs/GUI.md @@ -119,7 +119,8 @@ A question from a run (an unknown form field, an option to pick, "did it submit? first; it changes the view only). Every row looks alike: who and what, score / match % and pay ("score 105 = 75% match + 10 Boston + 20 security" when bonuses add to it: Start goes by score), a line about it (site, notes), chips (when it - closes, or was posted: amber within a week, red once past; "may also want the company's site"; the queue's + closes, amber within a week, red once past; an internship's from its posting (gui.intern_facts), else + "no close date · posted N days ago"; "may also want the company's site"; the queue's place; "do it on the laptop" when the homelab's browser was refused), what to do next, Open, and More (⋯; on a phone in the row's top right corner). Next: Apply on an Approved NUworks row, after a confirm, just that row (`nuauto apply --row N`); while an diff --git a/docs/PIPELINE.md b/docs/PIPELINE.md index ced011a..0c82ae1 100644 --- a/docs/PIPELINE.md +++ b/docs/PIPELINE.md @@ -141,7 +141,13 @@ year's lists are new repos: changing the term moves the addresses with it); off page's schema.org JobPosting data, else the page's text (needs 1500+ characters that read like a posting), else the page in a hidden browser (fresh, no profile, no images, for pages built by JavaScript). Read-only GETs, 1 s apart per site, never to a non-public address (checked after redirects too). Pay and deadline are read when the - site gives them. A posting that says it is gone is marked closed: not scored. A posting no way could read in 3 runs + site gives them (the deadline: schema.org validThrough, Workday's endDate, iCIMS's page data, Oracle's + ExternalPostedEndDate, Greenhouse's application_deadline; postings.end_day drops a placeholder years away). Greenhouse, + Lever, Ashby, SmartRecruiters, Eightfold and Workable have no date in their data; dates written only in a posting's + text are not read (12/11/2026 can be either month: no guessing). Postings read before a site's date was read + (intern.REDATE: Oracle, 2026-10-26) are read once more for the date alone (intern.redate, 40 per run). The close date + shows on Review and Apply, and the morning message's Closing soon line uses it. A posting that says it is gone is + marked closed: not scored. A posting no way could read in 3 runs (each with the browser; not twice a day) shows in Review after the scored ones, unscored: none is dropped for being unreadable. 4. Claude score (prompts/INTERN_SCORE_PROMPT.md): the same match formula as the co-ops, plus year_req, who may apply compared with your standing DURING the internship (a sophomore now = a rising junior then): ok = diff --git a/src/nuauto/daily.py b/src/nuauto/daily.py index 6f8f3cb..d074cfd 100644 --- a/src/nuauto/daily.py +++ b/src/nuauto/daily.py @@ -326,17 +326,30 @@ def reminders(before, ok): rows = record_hand_applications(before, rows) or rows except Exception as e: # never let this block the message log(f"hand applications check failed: {type(e).__name__}: {str(e)[:150]}") - notify("NUauto morning", morning(rows, jobs.load("pool.json", []))) + notify("NUauto morning", morning(rows, jobs.load("pool.json", []), other=other_rows())) jobs.save(LATER, {}) -def morning(rows, pool, now=None): +def other_rows(): + """The Other jobs tab's rows when internships are on (their Approved ones can close too), else None.""" + from nuauto import intern + if not intern.ENABLED: + return None + try: + return intern.other_rows(interactive=False) + except BaseException as e: # gspread's login flow can sys.exit; the message still goes out + log(f"could not read the Other jobs tab ({type(e).__name__})") + return None + + +def morning(rows, pool, now=None, other=None): """The one message a day (since 2026-10-26: before, each of these was a message of its own, several a day). Short: a line for each thing that applies: new jobs, what closes, what only you can do, what to withdraw, what was recorded - or updated, this week's count, what is wrong. rows: None when the sheet can't be read.""" + or updated, this week's count, what is wrong. rows: None when the sheet can't be read; other: the Other jobs + tab's.""" now = now or datetime.now() lines = [] - for name, fn in [("new", lambda: new_line(now)), ("closing", lambda: closing_line(rows, pool, now.date())), + for name, fn in [("new", lambda: new_line(now)), ("closing", lambda: closing_line(rows, pool, now.date(), other)), ("todo", lambda: todo_line(rows)), ("withdraw", lambda: withdraw_line(rows)), ("later", later_lines), ("week", lambda: week_line(rows)), ("wrong", lambda: wrong_line(rows))]: try: @@ -373,24 +386,31 @@ def new_line(now): return "New: " + " · ".join(parts) -def closing_line(rows, pool, today, days=3): - """Approved rows that close today or tomorrow (by name: Start goes by closing date), and how many pool jobs close - within `days` that are not in the sheet yet and not rated no.""" - details = jobs.load_details() - due = [] - for r in rows or []: - d = details.get(jobs.job_id(r.url)) if r.status == "Approved" else None - day = jobs.closes(d) if d else None - if day is not None and 0 <= (day - today).days <= 1: - due.append((day, r)) - in_sheet = {jobs.job_id(r.url) for r in rows or []} +def closing_line(rows, pool, today, other=None, days=3): + """Approved rows on both tabs that close within a week (by name and day, soonest first: apply to those first; an + internship's date is its posting's, when it gives one), and how many pool jobs (co-ops, internships) close within + `days` that are not in the sheet yet and not rated no.""" + from nuauto import intern + details, dates = jobs.load_details(), intern.closing_dates() if other is not None else {} + due = [(jobs.closes(d), r) for r in rows or [] if r.status == "Approved" + and (d := details.get(jobs.job_id(r.url))) and jobs.closes(d)] + due += [(dates[intern.clean_url(r.url)], r) for r in other or [] if r.status == "Approved" + and intern.clean_url(r.url) in dates] + due = sorted(((day, r) for day, r in due if 0 <= (day - today).days <= jobs.URGENT_DAYS), key=lambda x: x[0]) + in_sheet = {jobs.job_id(r.url) for r in rows or []} | {intern.clean_url(r.url) for r in other or []} ratings = jobs.load("ratings.json", {}) - soon = [r for r in pool if r.get("closes") and r["id"] not in in_sheet and ratings.get(r["id"], {}).get("label") != 0 + pools = pool + (intern.load("pool.json", []) if other is not None else []) + listed = lambda r: r["id"] in in_sheet or bool(r.get("url")) and intern.clean_url(r["url"]) in in_sheet # noqa: E731 + soon = [r for r in pools if r.get("closes") and not listed(r) and ratings.get(r["id"], {}).get("label") != 0 and 0 <= (datetime.fromisoformat(r["closes"]).date() - today).days <= days] - log(f"approved closing today/tomorrow: {len(due)}; pool closing within {days} days, not in sheet: {len(soon)}") - parts = ([f"Approved {names([r.company for _, r in sorted(due, key=lambda x: x[0])])} by tomorrow"] if due else []) \ + log(f"approved closing within {jobs.URGENT_DAYS} days: {len(due)}; pool closing within {days} days: {len(soon)}") + + def when(day): + n = (day - today).days + return "today" if n == 0 else "tomorrow" if n == 1 else f"{day:%a}" + parts = ([names([f"{r.company[:22]} {when(day)}" for day, r in due], 4, 40)] if due else []) \ + ([f"{len(soon)} pool job{'s' if len(soon) != 1 else ''} within {days} days (Review)"] if soon else []) - return "Closing: " + " · ".join(parts) if parts else None + return "Closing soon: " + " · ".join(parts) if parts else None def todo_line(rows): diff --git a/src/nuauto/demo.py b/src/nuauto/demo.py index 397868e..50a339d 100644 --- a/src/nuauto/demo.py +++ b/src/nuauto/demo.py @@ -416,7 +416,8 @@ def intern_setup(today): skills="C, Python and Linux") posts[x["url"]] = text d = intern.details_of(x, {"how": "demo", "description": text, "location": "", "pay": "$38-$46 per hour", - "deadline": None, "closed": False}, today) + "deadline": (today + timedelta(days=20)).isoformat() if n == 5 else None, # (its own date) + "closed": False}, today) intern.save(f"details/{x['id']}.json", d) scores[x["id"]] = {"match": match, "year_req": year_req, "met": ["C", "Python"], "partial": ["testing"], "missing": ["industry tools", "a previous internship"], diff --git a/src/nuauto/gui.py b/src/nuauto/gui.py index 38f82cc..b0e93ce 100644 --- a/src/nuauto/gui.py +++ b/src/nuauto/gui.py @@ -552,7 +552,11 @@ def closing(r): """When the row's NUworks posting closes (from the saved job data): the date, its text, whether it is past or within a week. All empty/false when the job is unknown or lists no deadline.""" from nuauto import apply - day = apply.row_closes(r) + return closing_of(apply.row_closes(r)) + + +def closing_of(day): + """A close date for the page (closing(); an internship's in intern_facts): None = none listed.""" left = (day - date.today()).days if day else None return {"closes": day.isoformat() if day else None, "closes_text": jobs.closes_text(day), "past": left is not None and left < 0, "soon": left is not None and 0 <= left <= jobs.URGENT_DAYS} @@ -1559,7 +1563,8 @@ def homelab_task_view(r): def intern_facts(urls): - """For the Other jobs card: the match %, score and pay (from intern.py's data) of the internships among these links.""" + """For the Other jobs card: the match %, score, pay and close date (from intern.py's data; the posting's own date, + when it gives one) of the internships among these links.""" from nuauto import insights from nuauto import intern if not intern.ENABLED or not urls: @@ -1571,13 +1576,15 @@ def intern_facts(urls): s, p = scores.get(i), pool.get(i) if s is None or x["url"] not in urls: continue - pay = (intern.load(f"details/{i}.json", None) or {}).get("pay") or None + d = intern.load(f"details/{i}.json", None) or {} + pay = d.get("pay") or None hour = insights.hourly(pay) cat = p["category"] if p else cats.get(i) cat = cat if cat in jobs.CATEGORIES else None # (the Apply tab's kind-of-work filter) out[x["url"]] = {"match": s["match"], "score": p["rank"] if p else None, "score_text": jobs.rank_text(p) if p else None, "pay": pay, "pay_hour": hour[1] if hour else None, "posted_text": intern.posted_text(x), - "category": cat, "category_label": jobs.category_label(cat) if cat else None} + "category": cat, "category_label": jobs.category_label(cat) if cat else None, + **closing_of(jobs.closes(d) if d else None)} return out diff --git a/src/nuauto/gui_static/app.js b/src/nuauto/gui_static/app.js index 692ecdf..2805194 100644 --- a/src/nuauto/gui_static/app.js +++ b/src/nuauto/gui_static/app.js @@ -595,7 +595,7 @@ screens.home = (view) => { return { update: draw, health: () => draw(S.state) }; }; -// When the NUworks posting closes: a chip (red once past, amber within a week), or "no deadline listed". +// When the posting closes (NUworks', or an internship's own): a chip (red once past, amber within a week), or "no deadline listed". function dueChip(t) { if (!t.closes) return el("span", { class: "chip", text: "no deadline listed", testid: "due" }); return el("span", { class: "chip" + (t.past ? " fail" : t.soon ? " warn" : ""), testid: "due", @@ -1257,8 +1257,9 @@ screens.apply = async (view) => { next = el("button", { class: "btn small primary", text: "Submit on NUworks too", testid: `retry-btn-${r.row}`, onclick: () => confirmBox("Submit on NUworks too?", `${r.company} · ${r.title} is Applied on the company site. This submits the same job on NUworks.`, "Submit on NUworks", () => action("nuworks_side", runArgs({ row: r.row }))) }); - } else if (key === "ready") { // (when it was posted where NUworks rows say when they close) - chips = [r.posted_text ? el("span", { class: "chip", testid: "posted", text: r.posted_text }) : null]; + } else if (key === "ready") { // an internship: when it closes (its posting's date), else when it was posted + chips = [r.closes ? dueChip(r) : r.posted_text ? el("span", { class: "chip", testid: "posted", text: `no close date · ${r.posted_text}`, + title: "Neither the list nor the company's posting gives a closing date." }) : null]; about = r.host || r.why; next = assistantStart(d, r, "other", `other-assist-${r.row}`, { disabled: !r.target, body: { row: r.row, tab: "other" } }); open = r.target ? linkOut(r.target, "Open") : null; diff --git a/src/nuauto/intern.py b/src/nuauto/intern.py index 8afa704..530f3bc 100644 --- a/src/nuauto/intern.py +++ b/src/nuauto/intern.py @@ -631,6 +631,42 @@ def fetch(log=print, limit=None, browser_limit=None): return ok, bad +# Job sites whose data has a close date NUauto reads only since 2026-10-26 (Oracle's ExternalPostedEndDate): their +# postings read before that are read once more, for the date alone (redate) +REDATE = {"oracle"} + + +def redate(log=print, limit=40): + """Saved postings from a REDATE job site with no close date: read again once (plain requests only), keeping only the + date they give (the text was scored as it was). Returns how many got one.""" + from nuauto import postings + listings = load("listings.json", {}) + todo = [d for i, d in load_details().items() + if d.get("how") in REDATE and not d.get("deadline") and not d.get("redated") and i in listings][:limit] + if not todo: + return 0 + client, dated = postings.Client(), 0 + for d in todo: + try: + day = postings.read_plain(d["url"], client).get("deadline") + except Exception: # (unreadable now, or the network): no date, and it is not tried again + day = None + save(f"details/{d['id']}.json", {**d, "deadline": day, "redated": date.today().isoformat()}) + dated += bool(day) + log(f"postings read again for their close date: {len(todo)}, {dated} give one") + return dated + + +def closing_dates(): + """{posting link (clean_url): the day it closes} for the internships whose posting gives one (jobs.closes).""" + listings, out = load("listings.json", {}), {} + for i, d in load_details().items(): + day = jobs.closes(d) + if day and i in listings: + out[clean_url(listings[i]["url"])] = day + return out + + # ---------------------------------------------------------------- 4. score, 4b. category def score_export(limit=None): @@ -951,6 +987,7 @@ def ready(): "NUauto: internship triage incomplete", fields): return 1 fetch(daily.log) + redate(daily.log) if not daily.claude_step(ready, "INTERN_SCORE_PROMPT.md", score_export(), score_import, "NUauto: internship scoring incomplete", fields): return 1 diff --git a/src/nuauto/postings.py b/src/nuauto/postings.py index 7c80da1..19ad00b 100644 --- a/src/nuauto/postings.py +++ b/src/nuauto/postings.py @@ -19,6 +19,7 @@ import time import urllib.error import urllib.request +from datetime import date from urllib.parse import parse_qs, urlparse UA = "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" @@ -123,11 +124,17 @@ def _ld_place(o): return "; ".join(x for x in out if x) +def end_day(v): + """The close date in a job site's data (YYYY-MM-DD: its first 10 characters), or None. A date years away is a + placeholder for "no end" (Oracle's 4712-12-31), not a deadline.""" + s = str(v or "")[:10] + return s if re.fullmatch(r"\d{4}-\d\d-\d\d", s) and int(s[:4]) <= date.today().year + 2 else None + + def from_jsonld(o): desc = text_of(o.get("description")) - end = str(o.get("validThrough") or "")[:10] return {"title": str(o.get("title") or ""), "description": desc, "location": _ld_place(o), - "pay": _ld_pay(o) or pay_from_text(desc), "deadline": end if re.fullmatch(r"\d{4}-\d\d-\d\d", end) else None} + "pay": _ld_pay(o) or pay_from_text(desc), "deadline": end_day(o.get("validThrough"))} # ---------------------------------------------------------------- the job sites' own data (pure: offline-tested) @@ -213,14 +220,13 @@ def parse_api(k, data, url): info = data.get("jobPostingInfo") or {} desc = text_of(info.get("jobDescription")) place = "; ".join([info.get("location") or ""] + list(info.get("additionalLocations") or [])) - end = str(info.get("endDate") or "")[:10] return {"title": info.get("title") or "", "description": desc, "location": place.strip("; "), - "pay": pay_from_text(desc), "deadline": end if re.fullmatch(r"\d{4}-\d\d-\d\d", end) else None, + "pay": pay_from_text(desc), "deadline": end_day(info.get("endDate")), "closed": info.get("canApply") is False} if k == "greenhouse": desc = text_of(data.get("content")) return {"title": data.get("title") or "", "description": desc, "location": (data.get("location") or {}).get("name") or "", - "pay": pay_from_text(desc), "deadline": None} + "pay": pay_from_text(desc), "deadline": end_day(data.get("application_deadline"))} if k == "lever": parts = [data.get("description") or data.get("descriptionPlain") or ""] for block in data.get("lists") or []: @@ -267,7 +273,7 @@ def parse_api(k, data, url): parts += [f"

{title}

" if title else "", it[key]] desc = text_of("\n".join(parts)) return {"title": it.get("Title") or "", "description": desc, "location": it.get("PrimaryLocation") or "", - "pay": pay_from_text(desc), "deadline": None} + "pay": pay_from_text(desc), "deadline": end_day(it.get("ExternalPostedEndDate"))} if k == "eightfold": desc = text_of(data.get("job_description")) return {"title": data.get("name") or "", "description": desc, "location": data.get("location") or "", diff --git a/tests/test_daily.py b/tests/test_daily.py index 475e91b..4ee2e4a 100644 --- a/tests/test_daily.py +++ b/tests/test_daily.py @@ -6,7 +6,7 @@ import re import os import tempfile -from datetime import datetime +from datetime import date, datetime from nuauto import config @@ -123,6 +123,25 @@ def no_sheet(interactive=True): assert daily.morning([], [], now).split("\n")[-1] == "http://page/" # the homelab's page ends it review.page_url, config.REMOTE_APP = real_page, False +# Closing soon: Approved rows on both tabs that close within a week (soonest first, with the day; an internship's date +# is its posting's), and pool jobs not in the sheet that close within 3 days +os.makedirs(os.path.join(config.DATA_DIR, "details"), exist_ok=True) +for i, end in (("n1", "2026-10-05"), ("n2", "2026-10-09"), ("n3", "2026-10-20")): + daily.jobs.save(f"details/{i}.json", {"id": i, "deadline": end, "posting_end": None}) +nrow = lambda n, i, status: sheet.Row(n, f"https://x/jobs/{i}", f"Co {i}", "T", status, "", "2026-10-01") # noqa: E731 +real_dates = intern.closing_dates +intern.closing_dates = lambda: {"https://acme.example/j/1": date(2026, 10, 6)} +other = [sheet.Row(2, "https://acme.example/j/1?utm_source=Simplify", "Acme", "Intern", "Approved", "", ""), + sheet.Row(3, "https://beta.example/j/2", "Beta", "Intern", "Approved", "", "")] +pool = [{"id": "p1", "closes": "2026-10-07"}, {"id": "n1", "closes": "2026-10-05"}, {"id": "p2", "closes": "2026-10-20"}] +line = daily.closing_line([nrow(5, "n1", "Approved"), nrow(6, "n2", "Approved"), nrow(7, "n3", "Approved"), + nrow(8, "n2", "Applied")], pool, date(2026, 10, 5), other) +assert line == f"Closing soon: Co n1 today, Acme tomorrow, Co n2 {date(2026, 10, 9):%a} · 1 pool job within 3 days (Review)", line +assert daily.closing_line([], [], date(2026, 10, 5)) is None +intern.closing_dates = real_dates +for i in ("n1", "n2", "n3"): + os.remove(os.path.join(config.DATA_DIR, "details", f"{i}.json")) + # a batch's Claude is shut in work/ (the batches hold web text: job postings, Simplify's list): it runs there, may read # and edit only there (Edit rules cover Write; never a bare Write, which would allow every path), no shell, web or MCP config.WORK_DIR = os.path.join(tmp, "work") diff --git a/tests/test_gui.py b/tests/test_gui.py index 86ff45e..a25a331 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -579,6 +579,9 @@ def eventually(check, what, seconds=20): done = post("/api/decide", {"id": beacon["id"], "decision": "approve", "mode": "approve"}) assert done["tab"] == "other" and done["row"], done assert beacon["id"] not in [j["id"] for j in get("/api/review?mode=approve&source=intern")["jobs"]] # the list moved on + # its row on Apply says when it closes (its posting's own date: demo.intern_setup), the others when they were posted + ready = {x["row"]: x for x in get("/api/jobs?tab=other&recheck=1")["sections"]["ready"]} + assert ready[done["row"]]["closes"] and not ready[done["row"]]["soon"] and "(in 20 days)" in ready[done["row"]]["closes_text"], ready post("/api/undo", {"id": beacon["id"], "row": done["row"], "previous": done["previous"], "tab": "other"}) assert json.load(open(other_file))["rows"][done["row"] - 1][3] == "Proposed" post("/api/undo", {"id": "s" + "0" * 32, "row": done["row"], "previous": None, "tab": "other"}, expect=409) # not a listing diff --git a/tests/test_intern.py b/tests/test_intern.py index 029401a..e959470 100644 --- a/tests/test_intern.py +++ b/tests/test_intern.py @@ -93,6 +93,11 @@ assert sr["location"] == "Austin, TX" and sr["description"].startswith("Role") assert P("oracle", {"items": []}, "u") is None assert "Qualifications" in P("oracle", {"items": [{"Title": "T", "ExternalDescriptionStr": LONG, "ExternalQualificationsStr": "

C

"}]}, "u")["description"] +# close dates in a job site's own data (Oracle's posting end, Greenhouse's deadline); a far-off placeholder is none +assert P("oracle", {"items": [{"ExternalDescriptionStr": LONG, "ExternalPostedEndDate": "2026-12-05T05:00:00+00:00"}]}, "u")["deadline"] == "2026-12-05" +assert P("oracle", {"items": [{"ExternalDescriptionStr": LONG, "ExternalPostedEndDate": "4712-12-31T00:00:00+00:00"}]}, "u")["deadline"] is None +assert P("greenhouse", {"content": LONG, "application_deadline": "2026-11-20T23:59:00-05:00"}, "u")["deadline"] == "2026-11-20" +assert P("greenhouse", {"content": LONG, "application_deadline": None}, "u")["deadline"] is None assert P("eightfold", {"name": "T", "job_description": LONG}, "u")["description"] assert P("workable", {"title": "T", "description": LONG, "requirements": "

C

", "location": {"city": "Boston", "region": "MA"}}, "u")["location"] == "Boston, MA" @@ -626,6 +631,27 @@ def __enter__(self): assert trains == [1, 1, 1] jobs.load_details, jobs.train = real_load, real_train +# a posting from a job site whose close date NUauto reads only now (REDATE): read once more, for the date alone +listings_now = intern.load("listings.json", {}) +lid = next(iter(listings_now)) +kept_details = intern.load(f"details/{lid}.json", None) +intern.save(f"details/{lid}.json", {"id": lid, "url": listings_now[lid]["url"], "how": "oracle", "deadline": None, + "posting_end": None, "description": "scored text"}) +reads = [] +real_read = postings.read_plain +postings.read_plain = lambda url, client: reads.append(url) or {"deadline": "2026-12-05", "description": "new text"} +try: + assert intern.redate(lambda m: None) == 1 and len(reads) == 1 + again = intern.load(f"details/{lid}.json", None) + assert again["deadline"] == "2026-12-05" and again["redated"] and again["description"] == "scored text", again + assert intern.redate(lambda m: None) == 0 and len(reads) == 1 # once only + assert intern.closing_dates()[intern.clean_url(listings_now[lid]["url"])] == date(2026, 12, 5) +finally: + postings.read_plain = real_read + if kept_details is None: + os.remove(intern.path(f"details/{lid}.json")) + else: + intern.save(f"details/{lid}.json", kept_details) # stats run intern.stats() From 712f3d9b834d3cef5afb900a04bf77e86e722d45 Mon Sep 17 00:00:00 2001 From: voidReq <100312344+voidReq@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:50:06 -0400 Subject: [PATCH 4/4] Internship rows match their listing through the real link Since short links became the posting's own link on the listing, a sheet row added earlier with a go.vansh.nyc link no longer matched it: no match, pay or close date on Apply, and the morning message's Closing soon line missed it. The lookups now go through intern.real_url, like assist.other_target. Co-Authored-By: Claude Opus 5.5 --- src/nuauto/daily.py | 8 ++++---- src/nuauto/gui.py | 8 ++++---- tests/test_daily.py | 4 +++- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/nuauto/daily.py b/src/nuauto/daily.py index d074cfd..ae30a2c 100644 --- a/src/nuauto/daily.py +++ b/src/nuauto/daily.py @@ -394,13 +394,13 @@ def closing_line(rows, pool, today, other=None, days=3): details, dates = jobs.load_details(), intern.closing_dates() if other is not None else {} due = [(jobs.closes(d), r) for r in rows or [] if r.status == "Approved" and (d := details.get(jobs.job_id(r.url))) and jobs.closes(d)] - due += [(dates[intern.clean_url(r.url)], r) for r in other or [] if r.status == "Approved" - and intern.clean_url(r.url) in dates] + due += [(dates[intern.real_url(r.url)], r) for r in other or [] if r.status == "Approved" + and intern.real_url(r.url) in dates] due = sorted(((day, r) for day, r in due if 0 <= (day - today).days <= jobs.URGENT_DAYS), key=lambda x: x[0]) - in_sheet = {jobs.job_id(r.url) for r in rows or []} | {intern.clean_url(r.url) for r in other or []} + in_sheet = {jobs.job_id(r.url) for r in rows or []} | {intern.real_url(r.url) for r in other or []} ratings = jobs.load("ratings.json", {}) pools = pool + (intern.load("pool.json", []) if other is not None else []) - listed = lambda r: r["id"] in in_sheet or bool(r.get("url")) and intern.clean_url(r["url"]) in in_sheet # noqa: E731 + listed = lambda r: r["id"] in in_sheet or bool(r.get("url")) and intern.real_url(r["url"]) in in_sheet # noqa: E731 soon = [r for r in pools if r.get("closes") and not listed(r) and ratings.get(r["id"], {}).get("label") != 0 and 0 <= (datetime.fromisoformat(r["closes"]).date() - today).days <= days] log(f"approved closing within {jobs.URGENT_DAYS} days: {len(due)}; pool closing within {days} days: {len(soon)}") diff --git a/src/nuauto/gui.py b/src/nuauto/gui.py index b0e93ce..4b3cade 100644 --- a/src/nuauto/gui.py +++ b/src/nuauto/gui.py @@ -1596,13 +1596,13 @@ def other_list(): rows = other_rows() if not APP.other_exists: return {"exists": False, "tab": sheet.OTHER_TAB, "ready": [], "applied": [], "rest": [], **assistants()} - facts = intern_facts({intern.clean_url(r.url) for r in rows if r.status == "Approved"}) + facts = intern_facts({intern.real_url(r.url) for r in rows if r.status == "Approved"}) ready, applied, rest = [], [], [] for r in rows: if r.status == "Approved": url, why = assist.other_target(r) ready.append(row_view(r, target=url, host=urlparse(url).hostname if url else None, why=why, - **facts.get(intern.clean_url(r.url), {}))) + **facts.get(intern.real_url(r.url), {}))) elif r.status == "Applied": applied.append(row_view(r)) else: @@ -1663,7 +1663,7 @@ def jobs_tab(tab="nuworks", order="default", recheck=False): with APP.lock: # (the rows and the version they were read at, together) rows, version = APP.rows, APP.rows_version by_number = {r.number: r for r in rows} - facts = intern_facts({intern.clean_url(r.url) for r in rows}) if other else None + facts = intern_facts({intern.real_url(r.url) for r in rows}) if other else None def locks(v): # what Move and Remove allow on this row r = by_number[v["row"]] @@ -1680,7 +1680,7 @@ def on_nuworks(r): # what NUworks said about a sent row at the last check (curr "done": done.get(r.number)}} if a else {} def view(r, **extra): - more = facts.get(intern.clean_url(r.url), {}) if other else {**closing(r), **job_facts(r, data), **on_nuworks(r)} + more = facts.get(intern.real_url(r.url), {}) if other else {**closing(r), **job_facts(r, data), **on_nuworks(r)} return locks(row_view(r, **more, **extra)) s = {k: [] for k in ("approved", "agent", "manual", "owed", "ready", "applied", "accepted", "rejected", "proposed", "closed", "rest")} diff --git a/tests/test_daily.py b/tests/test_daily.py index 4ee2e4a..3e9df42 100644 --- a/tests/test_daily.py +++ b/tests/test_daily.py @@ -131,7 +131,8 @@ def no_sheet(interactive=True): nrow = lambda n, i, status: sheet.Row(n, f"https://x/jobs/{i}", f"Co {i}", "T", status, "", "2026-10-01") # noqa: E731 real_dates = intern.closing_dates intern.closing_dates = lambda: {"https://acme.example/j/1": date(2026, 10, 6)} -other = [sheet.Row(2, "https://acme.example/j/1?utm_source=Simplify", "Acme", "Intern", "Approved", "", ""), +intern.save("links.json", {"https://go.vansh.nyc/AB": "https://acme.example/j/1"}) # (a row added with vanshb03's short link) +other = [sheet.Row(2, "https://go.vansh.nyc/AB", "Acme", "Intern", "Approved", "", ""), sheet.Row(3, "https://beta.example/j/2", "Beta", "Intern", "Approved", "", "")] pool = [{"id": "p1", "closes": "2026-10-07"}, {"id": "n1", "closes": "2026-10-05"}, {"id": "p2", "closes": "2026-10-20"}] line = daily.closing_line([nrow(5, "n1", "Approved"), nrow(6, "n2", "Approved"), nrow(7, "n3", "Approved"), @@ -139,6 +140,7 @@ def no_sheet(interactive=True): assert line == f"Closing soon: Co n1 today, Acme tomorrow, Co n2 {date(2026, 10, 9):%a} · 1 pool job within 3 days (Review)", line assert daily.closing_line([], [], date(2026, 10, 5)) is None intern.closing_dates = real_dates +os.remove(intern.path("links.json")) for i in ("n1", "n2", "n3"): os.remove(os.path.join(config.DATA_DIR, "details", f"{i}.json"))