Files
leetcode/apps/api/src/gate.ts
T

271 lines
9.5 KiB
TypeScript

/**
* Saturday review issue: created at midnight ET so the 8 AM digest can link
* to it; scored when the issue closes (webhook).
*
* The gate half is a topic-blind quiz — one unsolved optional problem per
* topic scheduled this week (which is why daily drills only draw from
* earlier weeks). The recap half is generated data, deliberately NOT a task
* list: the week's solved problems are already scheduled by the +3/+7 ladder,
* and re-assigning them on Saturday would be massed practice.
*
* Label is `review` (the retired Actions system owned `gate`). Creation is
* idempotent by week; deferred Hards never appear.
*/
import {
type ProblemRow,
type Result,
SCHEDULE,
campaignWeek,
isoWeek,
rng,
sample,
streak,
weekMonday,
} from "./srs.ts";
import type { GitHub } from "./github.ts";
const PASS_TARGET = 0.7;
const REVIEW_LABEL = "review";
const MAX_GATE_PROBLEMS = 5;
function capitalized(difficulty: string): string {
return difficulty[0]!.toUpperCase() + difficulty.slice(1);
}
/** Unsolved problems of a topic in a set — never attempted, never drilled. */
async function freshPool(db: D1Database, topic: number, set: string): Promise<ProblemRow[]> {
const { results } = await db
.prepare(
`SELECT * FROM problems
WHERE topic_issue = ?1 AND set_label = ?2 AND stage = 'new' AND defer_until IS NULL
AND lc_number NOT IN (SELECT lc_number FROM drill_pool_used)
AND NOT EXISTS (SELECT 1 FROM attempts a WHERE a.lc_number = problems.lc_number)
ORDER BY lc_number`,
)
.bind(topic, set)
.all<ProblemRow>();
return results;
}
export interface CreateReport {
created: boolean;
issue?: number;
reason: string;
}
export async function createReviewIssue(env: Env, gh: GitHub, date: string): Promise<CreateReport> {
const db = env.DB;
const week = campaignWeek(date);
const iso = isoWeek(date);
const title = `Review — Week ${week}`;
const existing = await db
.prepare("SELECT issue FROM gates WHERE week = ? AND issue IS NOT NULL")
.bind(iso)
.first<{ issue: number }>();
if (existing) {
return { created: false, issue: existing.issue, reason: `${title} exists (#${existing.issue})` };
}
// Topics scheduled this week; review-only weeks (Sep 23 onward) sample
// across every topic that still has unsolved optional problems.
let topics = Object.entries(SCHEDULE)
.filter(([d]) => campaignWeek(d) === week)
.sort(([a], [b]) => (a < b ? -1 : 1))
.map(([, t]) => t);
if (topics.length === 0) {
const { results } = await db
.prepare(
`SELECT DISTINCT topic_issue AS t FROM problems
WHERE set_label = 'optional' AND stage = 'new'
AND lc_number NOT IN (SELECT lc_number FROM drill_pool_used)
ORDER BY topic_issue`,
)
.all<{ t: number }>();
topics = sample(results.map((r) => r.t), MAX_GATE_PROBLEMS, rng(`gate-topics-${iso}`)).sort(
(a, b) => a - b,
);
}
if (topics.length === 0) return { created: false, reason: "no topics with unsolved pools" };
const random = rng(`gate-${iso}`);
const picks: { p: ProblemRow; fallback: boolean }[] = [];
for (const topic of topics) {
const optional = await freshPool(db, topic, "optional");
if (optional.length > 0) {
picks.push({ p: sample(optional, 1, random)[0]!, fallback: false });
continue;
}
const core = await freshPool(db, topic, "core");
if (core.length > 0) picks.push({ p: sample(core, 1, random)[0]!, fallback: true });
// Both pools exhausted: the topic is fully solved; nothing to quiz.
}
if (picks.length === 0) return { created: false, reason: `every pool for week ${week} is exhausted` };
// Recap: everything attempted this week, plus streak — data, not tasks.
const monday = weekMonday(date);
const { results: attempts } = await db
.prepare(
`SELECT a.lc_number, a.date, a.kind, a.result FROM attempts a
WHERE a.date >= ? AND a.date < ? AND a.source != 'import'
ORDER BY a.date, a.id`,
)
.bind(monday, date)
.all<{ lc_number: number; date: string; kind: string; result: string }>();
const reviewsDone = attempts.filter((a) => a.kind === "review" && a.result === "pass").length;
const currentStreak = await streak(db, date);
const gateLines = picks.map(
({ p, fallback }) =>
`- LC ${p.lc_number}${capitalized(p.difficulty)}${fallback ? " *(core fallback — optional pool exhausted)*" : ""}`,
);
const recapLines = attempts.length
? attempts.map(
(a) => `| LC ${a.lc_number} | ${a.kind} | ${a.result === "pass" ? "✅" : "❌"} | ${a.date} |`,
)
: ["| — | no attempts logged this week | | |"];
const body = [
"## Gate — blind set",
`**Target: ${Math.round(PASS_TARGET * 100)}% first-attempt pass rate. Timed. Narrate out loud.**`,
"No topics given. Log with `/done <n> pass|fail`, then close this issue.",
...gateLines,
"",
`## Week ${week} recap`,
`${attempts.length} attempts · ${reviewsDone} reviews passed · streak ${currentStreak}`,
"",
"| Problem | Kind | Result | Day |",
"| --- | --- | --- | --- |",
...recapLines,
].join("\n");
// Ensure the `review` label exists (422 = already there).
try {
await gh.rest(`/repos/${gh.repo}/labels`, {
method: "POST",
body: JSON.stringify({
name: REVIEW_LABEL,
color: "5319e7",
description: "Weekly blind gate + recap",
}),
});
} catch (err) {
if (!String(err).includes("422")) throw err;
}
const milestone = await db
.prepare("SELECT milestone FROM topics WHERE issue = ? AND milestone IS NOT NULL")
.bind(topics[0])
.first<{ milestone: number }>();
const created = (await gh.rest(`/repos/${gh.repo}/issues`, {
method: "POST",
body: JSON.stringify({
title,
body,
labels: [REVIEW_LABEL],
milestone: milestone?.milestone,
}),
})) as { number: number };
await db
.prepare(
`INSERT INTO gates (week, issue, problems) VALUES (?1, ?2, ?3)
ON CONFLICT(week) DO UPDATE SET issue = ?2, problems = ?3`,
)
.bind(iso, created.number, JSON.stringify(picks.map(({ p }) => p.lc_number)))
.run();
// Last week's boosted topics had their remedial week — clear the flags.
await db.prepare("UPDATE topics SET boost = 0 WHERE boost = 1").run();
return { created: true, issue: created.number, reason: `created #${created.number}` };
}
export interface ScoreReport {
scored: boolean;
rate?: number;
reason: string;
}
/**
* Grade a closed review issue: first-attempt pass rate over its gate set.
* Unlogged problems count as failures — skipping a gate problem is not a
* pass. Boosts topics that failed a gate problem or reached 2 drill misses;
* miss counters reset once consumed. The badge/charts read D1 live, so
* "refreshing the badge" is this row update.
*/
export async function scoreReview(
env: Env,
gh: GitHub,
issueNumber: number,
date: string,
): Promise<ScoreReport> {
const db = env.DB;
const gate = await db
.prepare("SELECT week, problems, pass_rate FROM gates WHERE issue = ?")
.bind(issueNumber)
.first<{ week: number; problems: string; pass_rate: number | null }>();
if (!gate) return { scored: false, reason: `issue #${issueNumber} has no gate record` };
if (gate.pass_rate !== null) return { scored: false, rate: gate.pass_rate, reason: "already scored" };
const lcs: number[] = JSON.parse(gate.problems);
const results: Record<number, Result | undefined> = {};
for (const lc of lcs) {
const row = await db
.prepare("SELECT result FROM attempts WHERE lc_number = ? AND kind = 'gate' ORDER BY id LIMIT 1")
.bind(lc)
.first<{ result: Result }>();
results[lc] = row?.result;
}
const passes = lcs.filter((lc) => results[lc] === "pass");
const unlogged = lcs.filter((lc) => results[lc] === undefined);
const rate = passes.length / lcs.length;
await db
.prepare("UPDATE gates SET pass_rate = ?, closed_on = ? WHERE issue = ?")
.bind(rate, date, issueNumber)
.run();
const boosted: number[] = [];
for (const lc of lcs) {
if (results[lc] !== "fail") continue;
const p = await db
.prepare("SELECT topic_issue FROM problems WHERE lc_number = ?")
.bind(lc)
.first<{ topic_issue: number }>();
if (p) boosted.push(p.topic_issue);
}
const { results: missed } = await db
.prepare("SELECT issue FROM topics WHERE misses >= 2")
.all<{ issue: number }>();
boosted.push(...missed.map((m) => m.issue));
const boostSet = [...new Set(boosted)];
if (boostSet.length) {
await db.batch(
boostSet.map((t) => db.prepare("UPDATE topics SET boost = 1, misses = 0 WHERE issue = ?").bind(t)),
);
}
const passed = rate >= PASS_TARGET;
const summary = [
`## Gate — Week ${gate.week}: **${Math.round(rate * 100)}%** first-attempt (target ${Math.round(PASS_TARGET * 100)}%) — ${passed ? "✅ pass" : "❌ fail"}`,
"",
...lcs.map((lc) => {
const r = results[lc];
return `- LC ${lc}${r === "pass" ? "✅ pass" : r === "fail" ? "❌ fail" : "⬜ not logged (counted as fail)"}`;
}),
"",
unlogged.length ? `${unlogged.length} problem(s) were never logged.` : "",
boostSet.length
? `Boosted topics for next week's drills: ${boostSet.map((t) => `#${t}`).join(", ")}.`
: "No topics boosted.",
"",
`Live charts: ${env.PUBLIC_URL}/chart/progress.svg · progress: ${env.DOCS_URL}/progress`,
]
.filter((l) => l !== "")
.join("\n");
await gh.comment(issueNumber, summary);
return { scored: true, rate, reason: `scored ${Math.round(rate * 100)}%` };
}