Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 22 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Session/tracking data, under `~/interview-prep/` by default:

- **`DSA_SOLUTIONS_DIR`** (e.g. `~/code/DSA_mock`) — organized as `<Topic_Folder>/<problem>.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
Expand Down Expand Up @@ -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 |

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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.
Expand Down
175 changes: 167 additions & 8 deletions server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/<topic>/<problem>.py
One real .py file per DSA problem, grouped
by topic folder, matching a typical
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 = "",
Expand All @@ -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]
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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", {})
Expand Down Expand Up @@ -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(
Expand All @@ -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():
Expand Down Expand Up @@ -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")
Loading