diff --git a/README.md b/README.md index f087d66..7631526 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Session/tracking data, under `~/interview-prep/` by default: - **`DSA_SOLUTIONS_DIR`** (e.g. `~/code/DSA_mock`) — organized as `/.py`, one real Python file per problem grouped by topic, matching a typical personal LeetCode-practice layout. DSA is code, not markdown — see `save_dsa_solution` below. - **`HLD_SOLUTIONS_DIR`** (e.g. `~/code/HLD`) — one markdown file per HLD problem (e.g. `design-rate-limiter.md`). -- **`LLD_SOLUTIONS_DIR`** (e.g. `~/code/LLD`) — holds two separate things: the **reference corpus** of your own designs, one self-contained `.py` per problem grouped into category folders (`1_state_machine/vending_machine.py`), plus flat `design-parking-lot.md` write-ups from `save_practice_doc`; and **`Mock Solutions/`**, the mock-interview loop where you attempt a problem and Claude grades it. See [LLD mock interviews](#lld-mock-interviews) below. +- **`LLD_SOLUTIONS_DIR`** (e.g. `~/code/LLD`) — holds two separate things: the **reference corpus** of your own designs, one self-contained `.py` per problem grouped into category folders (`1_state_machine/vending_machine.py`), plus flat `design-parking-lot.md` write-ups from `save_practice_doc`; and **`Mock Solutions/`**, the mock-interview loop where you attempt a problem and Claude grades it. See [LLD mock interviews](#lld-mock-interviews) below. A third file, **`DRILL_LOG.md`**, sits at the top level: one running, append-only log of short LLD drills (see [LLD drills](#lld-drills)). - **`BEHAVIORAL_SOLUTIONS_DIR`** (e.g. `~/code/Behavioral`) — holds a single `candidate_context.md`: your reusable background + STAR story bank, with a "Current Focus" section you refresh per company/role. See `save_candidate_context` below. ## Tools exposed to Claude @@ -46,6 +46,8 @@ Session/tracking data, under `~/interview-prep/` by default: | `save_ideal_solution` | Write the interviewer's reference design as `ideal.py`, beside your attempt. Can only ever write `ideal*.py`, so it cannot touch your own file | | `save_simple_solution` | Write a pared-back version of that design as `simple.py` — what a strong candidate could realistically finish in the time box. Can only ever write `simple*.py` | | `get_lld_feedback` | Rubric averages, weakest dimensions, and attempt history — call this *before* `suggest_next_problems("LLD")` to aim the next question | +| `log_lld_drill` | Append one short LLD drill to `DRILL_LOG.md` — a quick rep that doesn't warrant a whole mock folder. Feeds the same weak-area tracker as `log_session` | +| `get_lld_drill_log` | Read back the last N drills — call at the start of a drill session so the next reps build on the last ones | | `get_session_detail` | Revisit the full log entry for one past session | | `resolve_weak_area` | Remove a weak area once you've demonstrably improved at it | @@ -101,7 +103,7 @@ Claude will call `scan_dsa_directory()`, read each file's problem-statement snip > Scan my LLD directory and import everything you can confidently match to the catalog. -`scan_lld_directory()` returns every file with a **Kind** column — `solution` (a per-problem design), `category-doc` (a folder README), `aggregate-doc` (`INDEX.md`, `QUICK_REFERENCE.md` and friends, which span many problems), `practice-doc` (a markdown write-up this server wrote via `save_practice_doc`), or `other`. Only `solution` rows are importable; `import_solved_lld_problem(s)` refuses the rest, so an index file can never get linked to a single problem. Claude matches filenames to catalog ids by judgment (`8_lru_cache.py` → `design-lru-cache-oop`), and roughly half the corpus isn't in the 25-entry built-in LLD catalog at all (`order_lifecycle.py`, `whatsapp_messaging.py`, `audit_trail.py`, …) — those need `add_custom_problem` first. Nothing under `~/code/LLD` is modified; only `index.json` is written. +`scan_lld_directory()` returns every file with a **Kind** column — `solution` (a per-problem design), `category-doc` (a folder README), `aggregate-doc` (`INDEX.md`, `QUICK_REFERENCE.md` and friends, which span many problems), `practice-doc` (a markdown write-up this server wrote via `save_practice_doc`), `drill-log` (`DRILL_LOG.md`), or `other`. Only `solution` rows are importable; `import_solved_lld_problem(s)` refuses the rest, so an index file can never get linked to a single problem. Claude matches filenames to catalog ids by judgment (`8_lru_cache.py` → `design-lru-cache-oop`), and roughly half the corpus isn't in the 25-entry built-in LLD catalog at all (`order_lifecycle.py`, `whatsapp_messaging.py`, `audit_trail.py`, …) — those need `add_custom_problem` first. Nothing under `~/code/LLD` is modified; only `index.json` is written. ## LLD mock interviews @@ -145,6 +147,23 @@ Scores accumulate in `index.json` under `lld:`-prefixed keys (kept apart from Be **Safety.** `attempt.py` is yours: `start_mock_attempt` refuses to overwrite one, `save_ideal_solution` can only ever write a file named `ideal*.py`, and `save_simple_solution` only `simple*.py`. Scans and imports exclude `Mock Solutions/` entirely, so Claude's own output can never be backfilled into the tracker as your finished work. +## LLD drills + +A drill is a short focused rep — one pattern, one class hierarchy, one "how would you extend this" — not a full mock interview. Drills don't get a folder, a prompt file or rubric scores; they all append to a single running log: + +``` +~/code/LLD/DRILL_LOG.md # newest drill at the bottom, entries split by --- +``` + +Claude calls `get_lld_drill_log()` at the start of a drill session to see what you covered last time, and `log_lld_drill(...)` after each rep. The signature: + +``` +log_lld_drill(topic, content_markdown, problem_id="", duration_minutes=0, gaps="") +get_lld_drill_log(limit=5) # limit=0 returns the whole file +``` + +`content_markdown` is the whole entry body, written by Claude — start its headings at `###` and don't put a bare `---` rule inside it, since `##` and `---` are what separate one drill from the next. `gaps` is a semicolon-separated list in the same vocabulary as `log_session`, and feeds the same weak-area counts that `get_progress_summary` reports. Passing `problem_id` also counts the drill as an attempt on that problem in the tracker — including refreshing its "last practiced" date, so a drill postpones that problem's next revision — but never unlinks a doc `save_practice_doc` wrote for it. + ## Wiring it into your practice routine Say this at the start of a chat (or bake it into a Claude Desktop project/skill): @@ -189,7 +208,7 @@ A stdio-transport MCP server (what this is) isn't a background daemon you start Four ways to check it's actually working, in increasing order of realism: 1. **Does it even boot?** `python3 server.py` from this folder — should start and hang silently (that's correct; it's waiting for a client on stdin). Ctrl+C to stop. -2. **Does it speak MCP correctly?** `python3 test_server.py` — spawns the server as a real MCP client would, does the protocol handshake, lists all 26 tools, and calls `get_progress_summary` for real. Read-only, safe to run anytime. A clean "All checks passed" means the server itself is solid, independent of Claude Desktop. +2. **Does it speak MCP correctly?** `python3 test_server.py` — spawns the server as a real MCP client would, does the protocol handshake, lists all 29 tools, and calls `get_progress_summary` for real. Read-only, safe to run anytime. A clean "All checks passed" means the server itself is solid, independent of Claude Desktop. 3. **Do the LLD tools actually behave?** `python3 test_lld_tools.py` — builds a synthetic design repo in a temp directory and exercises every LLD tool against it: path guards, kind classification, rubric validation, the full mock loop, and above all that `attempt.py` comes out byte-identical to what was written. Hermetic — it never touches `~/code/LLD` or your real `index.json`. 4. **Is Claude Desktop actually using it?** - Open a chat and look at the tools/connectors icon near the input box — `interview-memory` should be listed with its tool count. diff --git a/server.py b/server.py index c712b7b..dbb7c21 100644 --- a/server.py +++ b/server.py @@ -35,6 +35,9 @@ suffixed: attempt_2.py, ideal_2.py, etc. feedback.md at the top of Mock Solutions/ is the running rubric scorecard. + LLD_SOLUTIONS_DIR/DRILL_LOG.md One running, append-only log of short LLD + drills (the LLD-drill skill) -- quick reps + that don't warrant a whole mock folder. DSA_SOLUTIONS_DIR//.py One real .py file per DSA problem, grouped by topic folder, matching a typical @@ -70,6 +73,12 @@ The rubric vocabulary is fixed (LLD_RUBRIC) so scores aggregate across sessions -- that aggregate is what step 1 reads, closing the loop. +LLD drills (short focused reps, no rubric, no per-problem folder): + 1. get_lld_drill_log() -- what was drilled recently, what's unresolved + 2. ... run the drill with the user ... + 3. log_lld_drill(...) -- append it to DRILL_LOG.md; gaps feed the same + weak-area tracker log_session writes to. + One-time / occasional housekeeping: scan_dsa_directory() / scan_lld_directory() -> match files to catalog ids yourself -> import_solved_dsa_problem(s) / import_solved_lld_problem(s) to @@ -144,6 +153,13 @@ LLD_MOCK_DIR = LLD_SOLUTIONS_DIR / LLD_MOCK_DIRNAME LLD_FEEDBACK_MD = LLD_MOCK_DIR / "feedback.md" +# The drill log: one running, append-only file for short focused LLD drills +# (the LLD-drill skill), as opposed to the full mock loop under Mock Solutions/. +# Same shape as revision.md -- newest entry at the bottom, sections separated by +# a "---" rule -- but scoped to LLD and kept beside the corpus it's about. +LLD_DRILL_LOG_MD = LLD_SOLUTIONS_DIR / "DRILL_LOG.md" +LLD_DRILL_SEPARATOR = "\n---\n" + # Roles within one mock problem folder, and the extension each is written with. # "attempt" is the user's own file: no tool in this server ever writes over one. # "simple" is the pared-back companion to "ideal" -- same problem, only what a @@ -283,7 +299,7 @@ def _tracked_doc_paths(problem_type: str) -> set: def _record_practice( problem_type: str, slug: str, - doc_path: Path, + doc_path: "Path | None", when: str, title: str = "", topic: str = "", @@ -295,8 +311,13 @@ def _record_practice( caller's title/topic/difficulty, which are fallbacks for problems not in the catalog; a previously recorded verdict wins over default_verdict. - Shared by save_dsa_solution / save_lld_solution and the import tools, which - differ only in their root directory and default verdict. + doc_path=None means "this practice produced no per-problem file" (a drill, + logged into the shared DRILL_LOG.md) -- any doc_path already recorded is + kept, so logging a drill never unlinks a doc save_practice_doc wrote. + + Shared by save_dsa_solution / save_lld_solution, the import tools and + log_lld_drill, which differ only in their root directory and default + verdict. """ index = _load_index() tracker = index["problems"][problem_type] @@ -312,12 +333,24 @@ def _record_practice( "last_practiced": when, "last_verdict": verdict, "history": existing.get("history", []) + [{"date": when, "verdict": verdict}], - "doc_path": str(doc_path.resolve()), + "doc_path": str(doc_path.resolve()) if doc_path else existing.get("doc_path"), } _save_index(index) return tracker[slug] +def _bump_weak_areas(index: dict, gaps: str) -> List[str]: + """Fold a semicolon-separated gaps string into index["weak_areas"], the + running count of what keeps going wrong. Returns the parsed gaps. Shared by + log_session and log_lld_drill so both feed the same tracker the same way -- + the caller still owns whatever else it writes (revision.md, sessions).""" + gap_list = [g.strip() for g in gaps.split(";") if g.strip()] + for g in gap_list: + key = g.lower() + index["weak_areas"][key] = index["weak_areas"].get(key, 0) + 1 + return gap_list + + def _markdown_table(headers: List[str], rows) -> List[str]: """Render a markdown table as a list of lines. Every tool here returns markdown to Claude, so this keeps the header/separator/row formatting in @@ -465,6 +498,10 @@ def _lld_kind(path: Path) -> str: # A .md inside a category folder documents that category. if path.parent != LLD_SOLUTIONS_DIR: return "category-doc" + # The drill log is generated (and appended to) by this server, like + # feedback.md -- not a corpus-wide index the user maintains. + if name == LLD_DRILL_LOG_MD.name: + return "drill-log" # At the top level, save_practice_doc's own per-problem write-ups # sit alongside corpus-wide indexes (INDEX.md, QUICK_REFERENCE.md). # Tell them apart by the header that tool stamps on every file it @@ -537,6 +574,22 @@ def _lld_category_folder(category: str) -> str: return "_".join(w.lower() for w in words) if words else "misc" +def _ensure_drill_log() -> None: + """Create DRILL_LOG.md with its preamble if it isn't there yet. The LLD + root is env-configurable and may not exist at all, so make it too -- + same contract as _ensure_revision_md.""" + LLD_SOLUTIONS_DIR.mkdir(parents=True, exist_ok=True) + if not LLD_DRILL_LOG_MD.exists(): + LLD_DRILL_LOG_MD.write_text( + "# LLD Drill Log\n\n" + "Short, focused LLD drills — appended by the interview-memory MCP " + "server (log_lld_drill). Newest entries are at the bottom. Full " + "mock interviews live under " + f"{LLD_MOCK_DIRNAME}/ instead.\n", + encoding="utf-8", + ) + + def _rubric_scores() -> dict: """The LLD rubric slice of index["competency_scores"], keys un-prefixed.""" scores = _load_index().get("competency_scores", {}) @@ -711,7 +764,7 @@ def log_session( index = _load_index() session_id = len(index["sessions"]) + 1 today = date.today().isoformat() - gap_list = [g.strip() for g in gaps.split(";") if g.strip()] + gap_list = _bump_weak_areas(index, gaps) strength_list = [s.strip() for s in strengths.split(";") if s.strip()] index["sessions"].append( @@ -724,9 +777,6 @@ def log_session( "gaps": gap_list, } ) - for g in gap_list: - key = g.lower() - index["weak_areas"][key] = index["weak_areas"].get(key, 0) + 1 scores_tracker = index["competency_scores"] for area, score in competency_scores.items(): @@ -2095,5 +2145,114 @@ def get_lld_feedback() -> str: return "\n".join(lines) +# --------------------------------------------------------------------------- +# Tools: the LLD drill log (short focused reps, not full mock interviews) +# --------------------------------------------------------------------------- + +@mcp.tool() +def log_lld_drill( + topic: str, + content_markdown: str, + problem_id: str = "", + duration_minutes: int = 0, + gaps: str = "", +) -> str: + """Append one LLD drill to the running drill log + (LLD_SOLUTIONS_DIR/DRILL_LOG.md), creating the file if it doesn't exist + yet. This is the persistence step of the LLD-drill skill. + + A drill is a short focused rep — one pattern, one class hierarchy, one + "how would you extend this" question — not a full mock interview. Use + start_mock_attempt / save_mock_evaluation for those; they get their own + folder, rubric scores and per-problem files. A drill only ever appends + here, so a session of six quick reps stays one readable file. + + Write the ENTIRE entry body yourself in content_markdown — this tool only + persists it and stamps the dated header above it. Start any headings in + the body at `###` and don't emit a bare `---` rule: `## ` and `---` are + what separate one drill from the next in this file. + + Also updates the shared trackers: `gaps` feed the weak-area counts that + get_progress_summary reports, and passing problem_id counts the drill as + an attempt on that problem (so suggest_next_problems knows you've touched + it). A doc previously linked by save_practice_doc is left alone. + + Args: + topic: What was drilled, e.g. "Strategy vs. State for a vending + machine" or "Design a Parking Lot". + content_markdown: The full write-up of the drill in markdown, authored + by you: what was asked, what the user produced, what to fix. + problem_id: Optional LLD catalog id (see get_catalog) when the drill + was on a cataloged problem, linking it into the problem tracker. + duration_minutes: Optional length of the drill, stamped in the header. + gaps: Optional semicolon-separated weak areas, same vocabulary as + log_session — reuse consistent short names so they aggregate + (e.g. "class-decomposition; interface segregation"). + """ + if not content_markdown.strip(): + return "content_markdown is empty — nothing to log." + + today = date.today().isoformat() + index = _load_index() + gap_list = _bump_weak_areas(index, gaps) + _save_index(index) + + problem_note = "" + if problem_id.strip(): + slug = _slugify(problem_id) + entry = _record_practice("LLD", slug, None, today, title=topic) + problem_note = f" Tracker updated for LLD `{slug}` (attempt #{entry['times_practiced']})." + + header = f"## {today} · {topic.strip()}" + if duration_minutes > 0: + header += f" · {duration_minutes} min" + + lines = [f"\n{LLD_DRILL_SEPARATOR}\n{header}", ""] + if problem_id.strip(): + lines += [f"_Problem: `{_slugify(problem_id)}`_", ""] + lines += [content_markdown.strip()] + if gap_list: + lines += ["", "**Gaps / to revise:** " + ", ".join(gap_list)] + + _ensure_drill_log() + with LLD_DRILL_LOG_MD.open("a", encoding="utf-8") as f: + f.write("\n".join(lines) + "\n") + + return ( + f"Drill logged to {LLD_DRILL_LOG_MD} ({len(gap_list)} gap(s) recorded)." + f"{problem_note}" + ) + + +@mcp.tool() +def get_lld_drill_log(limit: int = 5) -> str: + """Read back the LLD drill log written by log_lld_drill. Call this at the + start of a drill session to see what was drilled recently and what was + left unresolved, so the next reps build on it instead of repeating it. + + Args: + limit: How many of the most recent drills to return (newest last). + Pass 0 for the whole file. + """ + if not LLD_DRILL_LOG_MD.exists(): + return ( + f"No drill log yet at {LLD_DRILL_LOG_MD}. " + "Use log_lld_drill after the first drill." + ) + + text = LLD_DRILL_LOG_MD.read_text(encoding="utf-8") + # Entries are separated by the "---" rule log_lld_drill writes; the first + # chunk is the file preamble, so it isn't counted as a drill. + _preamble, *entries = text.split(LLD_DRILL_SEPARATOR) + if not entries: + return f"{LLD_DRILL_LOG_MD} exists but has no drills logged yet." + shown = entries[-limit:] if limit > 0 else entries + + heading = f"{len(entries)} drill(s) logged in {LLD_DRILL_LOG_MD}" + if len(shown) < len(entries): + heading += f" — showing the most recent {len(shown)}" + return heading + ".\n\n" + LLD_DRILL_SEPARATOR.join(shown).strip() + + if __name__ == "__main__": mcp.run(transport="stdio") diff --git a/test_lld_tools.py b/test_lld_tools.py index 708efaa..85e3efe 100644 --- a/test_lld_tools.py +++ b/test_lld_tools.py @@ -336,6 +336,53 @@ def main() -> int: "not `solution`" in server.import_solved_lld_problem( "design-parking-lot", "Mock Solutions/design-parking-lot/attempt.py")) + # --- the drill log ----------------------------------------------------- + drill_log = lld / "DRILL_LOG.md" + check("empty-state points at the drill tool", "No drill log yet" in server.get_lld_drill_log()) + check("rejects an empty drill", "nothing to log" in server.log_lld_drill("noop", " ")) + check("empty drill created no file", not drill_log.exists()) + + weak_before = server._load_index()["weak_areas"].get("interface segregation", 0) + r = server.log_lld_drill( + "Strategy vs. State for a vending machine", + "### Asked\nWhen does State beat Strategy?\n\n### Verdict\nMuddled the two.", + duration_minutes=15, + gaps="interface segregation; class-decomposition", + ) + check("drill log created", drill_log.exists(), r[:120]) + check("two gaps reported", "2 gap(s)" in r, r[:120]) + text = drill_log.read_text() + check("preamble written once", text.count("# LLD Drill Log") == 1) + check("header stamps the duration", "· 15 min" in text, text[:400]) + check("body preserved", "When does State beat Strategy?" in text) + check("gaps rendered in the entry", "interface segregation, class-decomposition" in text) + check("gaps feed the shared weak-area tracker", + server._load_index()["weak_areas"]["interface segregation"] == weak_before + 1) + + # A drill on a cataloged problem counts as an attempt but must NOT clobber + # the doc_path save_practice_doc linked earlier -- _record_practice(None). + doc_before = server._load_index()["problems"]["LLD"]["design-toy-cache"]["doc_path"] + r = server.log_lld_drill("Toy cache eviction", "### Asked\nLRU vs LFU.", + problem_id="design-toy-cache") + cache = server._load_index()["problems"]["LLD"]["design-toy-cache"] + check("drill counts as an attempt", cache["times_practiced"] == 1, str(cache["times_practiced"])) + check("drill preserves an existing doc_path", cache["doc_path"] == doc_before, str(cache["doc_path"])) + check("get_practice_doc still resolves after a drill", + "Design a Toy Cache" in server.get_practice_doc("LLD", "design-toy-cache")) + check("problem id stamped in the entry", "_Problem: `design-toy-cache`_" in drill_log.read_text()) + + check("appends rather than overwrites", drill_log.read_text().count("\n## ") == 2) + full = server.get_lld_drill_log(limit=0) + check("reads back both drills", "2 drill(s) logged" in full, full[:120]) + recent = server.get_lld_drill_log(limit=1) + check("limit returns only the newest drill", + "Toy cache eviction" in recent and "vending machine" not in recent, recent[:200]) + check("limit says what it clipped", "most recent 1" in recent, recent[:120]) + check("drill log classified as drill-log", + server._lld_kind(drill_log) == "drill-log", server._lld_kind(drill_log)) + check("drill log not mistaken for a corpus index", + "| drill-log |" in server.scan_lld_directory()) + print() if FAILS: print(f"{len(FAILS)} failure(s): " + ", ".join(FAILS)) diff --git a/test_server.py b/test_server.py index f04aea8..f5c6f71 100644 --- a/test_server.py +++ b/test_server.py @@ -25,6 +25,7 @@ "import_solved_lld_problems", "save_lld_solution", "start_mock_attempt", "list_mock_attempts", "save_mock_evaluation", "save_ideal_solution", "save_simple_solution", "get_lld_feedback", + "log_lld_drill", "get_lld_drill_log", }