mirror of
https://github.com/prdlk/leetcode.git
synced 2026-09-17 07:26:27 +00:00
271 lines
9.5 KiB
TypeScript
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)}%` };
|
|
}
|