diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e33b99e..3e759db 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,9 +17,10 @@ jobs: with: python-version: ${{ matrix.python-version }} - # The selection + persistence logic is deliberately free of the heavy - # app stack (ChromaDB, LangChain, Streamlit), so CI runs it with just - # pytest. test_cli.py self-skips without the full deps; the complete - # 45-test suite runs locally via `uv run pytest`. - - run: pip install pytest + # The selection, persistence, and MCP-tool logic are deliberately free + # of the heavy app stack (ChromaDB, LangChain, Streamlit), so CI runs + # them with just pytest + the (lightweight) mcp SDK. test_cli.py + # self-skips without the full deps; the complete suite runs locally + # via `uv run pytest`. + - run: pip install pytest "mcp>=1.6,<2" - run: pytest tests/ -v diff --git a/DESIGN.md b/DESIGN.md index 59a395f..efb494f 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -4,9 +4,9 @@ Architecture and data model for Java Interview Coach. See [README.md](README.md) ## Architecture overview -Two pipelines feed into two interchangeable UIs (Streamlit's `app.py`, or `cli.py`'s stdin -loop) that each drive the exact same LangGraph-style workflow — one button click, or one -`input()` call, at a time. +Two pipelines feed into three interchangeable front-ends (Streamlit's `app.py`, `cli.py`'s +stdin loop, or the `mcp_server/` MCP tools) that each drive the exact same LangGraph-style +workflow — one button click, one `input()` call, or one tool call, at a time. ```mermaid flowchart TD @@ -178,6 +178,42 @@ cumulative stats carry over between the two. and only the `evaluate` node's actual network call does. See Known limitations below for exactly how far this was verified to run in this environment. +### MCP server (round 3) + +`mcp_server/` exposes the coach over the [Model Context Protocol](https://modelcontextprotocol.io) +as a third front-end alongside Streamlit and the CLI, so any MCP client (Claude Desktop, +Claude Code, Cursor, VS Code) can run mock interviews using the same retrieval, grading, +persistence, and spaced-repetition logic. + +- **Layering.** `mcp_server/tools.py` is the tool logic as plain functions with heavy + dependencies passed in (`collection`, `llm`, `db_path`). It imports only `memory.store` + and `topics` at module level — deliberately *not* `graph.workflow` (which pulls the whole + LangChain/LangGraph chain) or `chromadb` or `mcp`. The 3-line RAG `query` call and the + evaluate/hint prompt text are inlined / moved to `prompts.py` so the module stays light. + Result: all nine tools are unit-tested with just `pytest` + a temp SQLite file + a fake + collection / fake LLM (`tests/test_mcp_tools.py`). +- **Wire layer.** `mcp_server/server.py` is the only file that imports `mcp`. It wraps each + `tools.py` function with `@mcp.tool()`, adds three resources + (`interview://topics`, `interview://progress`, `interview://question-bank/{topic}`) and a + `mock_interview` prompt, and owns the lazily-built ChromaDB collection and `ChatGroq` + client — so `list_tools` and the store-backed tools respond instantly and a missing + `GROQ_API_KEY` only surfaces (as a clear `RuntimeError`) if a grading tool is actually + called. +- **Transports.** `python -m mcp_server` runs stdio (local clients); `--http` runs + FastMCP's streamable-HTTP on `MCP_HOST`/`MCP_PORT` (default `127.0.0.1:8000/mcp`) for a + hosted deployment. Both were smoke-tested with a real MCP client handshake. +- **Sessions.** MCP tool calls are stateless, so `evaluate_answer` only records an attempt + when the caller threads through a `session_id` from `start_session` (plus a `topic`). + Without them it still grades, just doesn't persist — the `mock_interview` prompt tells the + client to always pass them. +- **`mcp` version.** Pinned to `>=1.6,<2`: v2.x renamed `FastMCP` to `MCPServer` and + changed the API, and the v1 `FastMCP` surface is what current client docs and examples + assume. + +`topics.py` and `prompts.py` were extracted in this round so the CLI, the workflow graph, +and the MCP server share one topic list and one set of prompt strings instead of three +copies drifting apart. + ## Notebooks vs. real modules | Still notebook-only | Promoted to a real module | @@ -188,9 +224,12 @@ exactly how far this was verified to run in this environment. ## Tests -`tests/test_store.py` and `tests/test_selection.py` are real `pytest` tests (40 total, all -passing as of this writing — run `uv run pytest tests/ -v` to reproduce), added in round 2. -They replace round 1's manual-script verification for these two modules: +`uv run pytest tests/ -v` runs 61 tests, all passing as of this writing. `test_store.py` +and `test_selection.py` (40 tests) were added in round 2 to replace round 1's manual-script +verification; `test_mcp_tools.py` and `test_mcp_server.py` (16 tests) were added in round 3 +with the MCP server. All of them except `test_cli.py` run with just `pytest` (+ the +lightweight `mcp` SDK for the two MCP files) — no ChromaDB, Groq key, network, or +`questions_db.json`. - `test_store.py` exercises `memory/store.py` end to end against a fresh temp SQLite file per test (`tmp_path`, via the `db_path` parameter every `store` function already accepts) @@ -216,7 +255,14 @@ They replace round 1's manual-script verification for these two modules: instead of looping, EOF-mid-answer still submitting what was typed) by monkeypatching `builtins.input`. It's the only I/O-free logic in `cli.py` — everything else in that file does real ChromaDB/Groq I/O, so it's verified by hand instead (see Known limitations). -- None of the three test files require `GROQ_API_KEY`, network access, or +- `test_mcp_tools.py` calls every MCP tool through `mcp_server/tools.py` with a fake + collection (returns a fixed candidate list) and a fake LLM (returns fixed content), plus a + temp SQLite file — covering topic accuracy roll-up, `get_interview_question` selection and + its empty-bank branch, `evaluate_answer` CORRECT/INCORRECT parsing and the persist-only- + when-scoped rule, and the spaced-repetition tools. `test_mcp_server.py` checks the FastMCP + registration (all nine tools, three resources, the prompt) and that a grading tool raises + a clear `GROQ_API_KEY` error when unset; it self-skips if the `mcp` SDK isn't installed. +- None of the committed test files require `GROQ_API_KEY`, network access, or `questions_db.json`. ## Known limitations diff --git a/README.md b/README.md index 8a3a680..e93fd2c 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,12 @@ pipeline**: semantic search over 1,715 real interview questions finds a relevant pool, then a difficulty- and history-aware ranker decides which one to actually ask. An LLM grades each answer against an ideal answer. Every attempt is persisted, so weak-topic weighting and a spaced-repetition -schedule accumulate across sessions. A Streamlit UI and a headless CLI share -the exact same core. +schedule accumulate across sessions. + +Three front-ends drive the identical core: a **Streamlit UI**, a **headless +CLI**, and an **MCP server** ([Model Context Protocol](https://modelcontextprotocol.io)) +that exposes the whole coach as tools any MCP client — Claude Desktop, Claude +Code, Cursor, VS Code — can run mock interviews against. ## The pipeline @@ -30,7 +34,7 @@ topic ─▶ ChromaDB semantic search ─▶ candidate pool ─▶ selection.py | **Selection layered on top of RAG, not replacing it** | Vector search is good at "relevant to this topic," bad at "the right difficulty for this user right now." `graph/selection.py` takes the retrieved pool and re-ranks it against persisted per-topic accuracy, so retrieval stays simple and the adaptivity is testable in isolation (no vector store needed). | | **Difficulty is an explicit heuristic, labelled as a proxy** | The 1,715-question bank has no difficulty labels. `_estimate_difficulty` approximates it from question length and phrasing ("why"/"how does X work"/internals vs. "what is"/"define"). Called out in code and docs so it isn't mistaken for ground truth. | | **Persistence is stdlib `sqlite3`, no ORM** | `st.session_state` dies with the process. `memory/store.py` writes every attempt (topic, question, correctness, timestamp, session id) so stats survive restarts. One small module, one file, fully unit-tested against temp DBs. | -| **UI and CLI call the identical core** | `app.py` (Streamlit) and `cli.py` both drive the same `graph/` and `memory/` modules — no business logic behind a Streamlit import. The CLI is both a scripting entry point and proof the split is real. | +| **One core, three front-ends** | `app.py` (Streamlit), `cli.py`, and `mcp_server/` all drive the same `graph/` + `memory/` modules — no business logic behind a Streamlit import. `mcp_server/tools.py` holds the tool logic as dependency-injected plain functions (kept off the LangChain/ChromaDB import chain), so it's unit-tested with just `pytest`; `mcp_server/server.py` is the only file that touches the MCP wire protocol. | | **Spaced repetition on the same file** | A post-answer confidence rating (Again/Hard/Good/Easy) schedules that exact question 1/3/7/14 days out; "Due for Review" mode resurfaces what's due instead of pulling from RAG. | See [DESIGN.md](DESIGN.md) for the full architecture and data model. @@ -64,10 +68,53 @@ echo "my answer\n\nquit" | uv run python cli.py --questions 5 # scripted Same prerequisites and same persistence as the Streamlit app — CLI and UI sessions share stats. +## MCP server + +Exposes the coach over the [Model Context Protocol](https://modelcontextprotocol.io) +so any MCP client can use it as a tool. + +```bash +uv run python -m mcp_server # stdio (for Claude Desktop / Cursor / VS Code) +uv run python -m mcp_server --http # streamable-HTTP on 127.0.0.1:8000/mcp +``` + +**Tools:** `list_topics`, `start_session`, `get_interview_question` (RAG + +adaptive selection), `evaluate_answer`, `get_hint`, `record_attempt`, +`rate_question`, `get_due_reviews`, `get_progress`. +**Resources:** `interview://topics`, `interview://progress`, +`interview://question-bank/{topic}`. +**Prompt:** `mock_interview(topic, num_questions)` — a template that drives a +full session through the tools. + +The ChromaDB collection and the Groq client are built lazily, so `list_tools` +and the store-backed tools respond instantly. `evaluate_answer` / `get_hint` +need `GROQ_API_KEY`; without it they return a clear message and everything +else still works. + +
+Claude Desktop / Cursor config + +`claude_desktop_config.json` (or `.cursor/mcp.json`): + +```json +{ + "mcpServers": { + "java-interview-coach": { + "command": "uv", + "args": ["--directory", "/ABS/PATH/TO/java-interview-coach", "run", "python", "-m", "mcp_server"], + "env": { "GROQ_API_KEY": "your_key_here" } + } + } +} +``` + +Claude Code: `claude mcp add java-interview-coach -- uv --directory /ABS/PATH run python -m mcp_server` +
+ ## Tests ```bash -uv run pytest tests/ # 45 tests +uv run pytest tests/ # 61 tests ``` - `test_store.py` — session/attempt CRUD, cumulative stats, recent-question @@ -75,11 +122,15 @@ uv run pytest tests/ # 45 tests ordering (all against temp SQLite files). - `test_selection.py` — difficulty heuristic, difficulty re-ranking, recently-asked filtering, auto-topic weighting. +- `test_mcp_tools.py` — every MCP tool via a fake collection / fake LLM and a + temp DB. +- `test_mcp_server.py` — the FastMCP wiring: all tools/resources/prompt + registered, graceful error without `GROQ_API_KEY`. - `test_cli.py` — `cli.py`'s stdin-parsing helper. -`test_store.py` and `test_selection.py` need only `pytest` — no ChromaDB, -Groq key, or network — which is what CI runs on 3.11 and 3.12. `test_cli.py` -self-skips unless the full app stack is installed. +Everything except `test_cli.py` runs with just `pytest` + the (lightweight) +`mcp` SDK — no ChromaDB, Groq key, or network — which is what CI runs on 3.11 +and 3.12. `test_cli.py` self-skips unless the full app stack is installed. ## Tech stack @@ -89,7 +140,7 @@ self-skips unless the full app stack is installed. | Agent framework | LangChain + LangGraph | | Vector store | ChromaDB | | Persistence | SQLite (stdlib `sqlite3`) | -| UI | Streamlit | +| Front-ends | Streamlit · CLI · MCP server (`mcp` SDK, FastMCP) | | Env / packaging | Python 3.11, `uv` | ## Project structure @@ -97,6 +148,8 @@ self-skips unless the full app stack is installed. ``` app.py Streamlit UI, wires graph nodes to session state cli.py headless practice loop: ask -> answer -> evaluate -> score +topics.py the fixed topic list, shared by every front-end +prompts.py evaluate / hint prompt text, shared by workflow.py and mcp_server corpus.py shared ChromaDB collection loader report.py exportable Markdown session report rag.ipynb fetches + parses the question corpus into questions_db.json @@ -106,6 +159,10 @@ graph/ selection.py difficulty-adaptive re-ranking on top of RAG retrieval memory/ store.py SQLite persistence for sessions / attempts / reviews + stats +mcp_server/ + tools.py tool logic as dependency-injected plain functions + server.py FastMCP wiring (tools / resources / prompt) + lazy deps + __main__.py `python -m mcp_server [--http]` tests/ pytest suite (see above) ``` diff --git a/cli.py b/cli.py index d9284f0..c26466d 100755 --- a/cli.py +++ b/cli.py @@ -35,13 +35,7 @@ from graph.selection import pick_topic_for_auto_mode from graph.workflow import build_nodes from memory import store - -AUTO = "auto" -TOPICS = [ - "OOP", "Java Core", "Java Collections", "Spring", - "JVM", "Multithreading", "Databases", "Java 8", - "Patterns", "Testing", -] +from topics import AUTO, TOPICS def _prompt_answer() -> str | None: diff --git a/graph/workflow.py b/graph/workflow.py index 93b61c2..61cc4f7 100644 --- a/graph/workflow.py +++ b/graph/workflow.py @@ -29,29 +29,13 @@ from graph.selection import select_question from graph.state import InterviewState from memory import store +from prompts import EVAL_PROMPT, HINT_PROMPT # RAG candidates fetched per question before the adaptive layer ranks them. QUESTION_POOL_SIZE = 12 -eval_prompt = ChatPromptTemplate.from_template(""" -You are a Java technical interviewer evaluating an answer. - -Question: {question} -Candidate's Answer: {answer} - -Respond with: -1. CORRECT or INCORRECT -2. Brief feedback (2-3 sentences) -3. Ideal answer in simple terms - -Start your response with either CORRECT or INCORRECT on the first line. -""") - -hint_prompt = ChatPromptTemplate.from_template(""" -You are a helpful Java tutor. -Give a short hint (2-3 sentences) for this question without giving away the answer. -Question: {question} -""") +eval_prompt = ChatPromptTemplate.from_template(EVAL_PROMPT) +hint_prompt = ChatPromptTemplate.from_template(HINT_PROMPT) def retrieve_candidates(collection, topic: str, n_results: int = QUESTION_POOL_SIZE) -> list[str]: diff --git a/mcp_server/__init__.py b/mcp_server/__init__.py new file mode 100644 index 0000000..7a1d499 --- /dev/null +++ b/mcp_server/__init__.py @@ -0,0 +1,11 @@ +"""MCP server for Java Interview Coach. + +Exposes the same retrieval + adaptive-selection + grading + spaced-repetition +core that the Streamlit app and the CLI drive, as Model Context Protocol +tools/resources/prompts — so any MCP client (Claude Desktop, Claude Code, +Cursor, VS Code) can run mock interviews against it. + +``mcp_server.tools`` holds the logic as plain functions (dependency-injected, +unit-testable). ``mcp_server.server`` wires them to FastMCP and owns the +lazily-built ChromaDB collection and Groq client. +""" diff --git a/mcp_server/__main__.py b/mcp_server/__main__.py new file mode 100644 index 0000000..62fdc30 --- /dev/null +++ b/mcp_server/__main__.py @@ -0,0 +1,3 @@ +from mcp_server.server import main + +main() diff --git a/mcp_server/server.py b/mcp_server/server.py new file mode 100644 index 0000000..5d4fa9e --- /dev/null +++ b/mcp_server/server.py @@ -0,0 +1,229 @@ +"""FastMCP wiring for Java Interview Coach. + +Run it: + + python -m mcp_server # stdio (Claude Desktop / Cursor / VS Code) + python -m mcp_server --http # streamable-HTTP on 127.0.0.1:8000/mcp + +The ChromaDB collection (a few seconds to embed 1,715 questions) and the +Groq client are built lazily on first use, so `list_tools` and the +store-backed tools respond instantly and a missing GROQ_API_KEY only +matters if you actually call a grading tool. +""" +from __future__ import annotations + +import os + +from dotenv import load_dotenv +from mcp.server.fastmcp import FastMCP + +from mcp_server import tools +from topics import TOPICS + +load_dotenv() + +mcp = FastMCP( + "java-interview-coach", + instructions=( + "Run mock Java technical interviews. Typical loop: start_session -> " + "get_interview_question -> (get_hint if the candidate is stuck) -> " + "evaluate_answer -> rate_question. Use get_due_reviews to resurface " + "questions that are due, and get_progress to report on weak topics." + ), + host=os.environ.get("MCP_HOST", "127.0.0.1"), + port=int(os.environ.get("MCP_PORT", "8000")), +) + +# ── lazily-built heavy dependencies ───────────────────────────────────── + +_collection = None +_llm = None + + +def _get_collection(): + global _collection + if _collection is None: + from corpus import load_collection + + _collection = load_collection() + return _collection + + +def _get_llm(): + global _llm + if _llm is None: + if not os.environ.get("GROQ_API_KEY"): + raise RuntimeError( + "GROQ_API_KEY is not set. The grading tools (evaluate_answer, " + "get_hint) need a Groq API key — add it to the MCP client's env " + "config or a .env file. Retrieval, progress, and spaced-repetition " + "tools work without one." + ) + from langchain_groq import ChatGroq + + _llm = ChatGroq(model="llama-3.3-70b-versatile") + return _llm + + +# ── tools ────────────────────────────────────────────────────────────── + +@mcp.tool() +def list_topics() -> list[dict]: + """List the Java practice topics with the user's running accuracy on each.""" + return tools.list_topics() + + +@mcp.tool() +def start_session() -> dict: + """Start a new practice session. Pass the returned session_id to + evaluate_answer so attempts are recorded against it.""" + return tools.start_session() + + +@mcp.tool() +def get_interview_question(topic: str, mode: str = "topic") -> dict: + """Get one interview question. mode="topic" uses the given topic; + mode="auto" ignores it and picks one weighted toward weak areas. + Returns {topic, question, estimated_difficulty}.""" + return tools.get_interview_question(_get_collection(), topic, mode=mode) + + +@mcp.tool() +def evaluate_answer( + question: str, answer: str, topic: str = "", session_id: str = "" +) -> dict: + """Grade an answer (CORRECT/INCORRECT + feedback + ideal answer). + Pass topic and session_id to record the attempt toward progress. + Needs GROQ_API_KEY.""" + return tools.evaluate_answer( + _get_llm(), + question, + answer, + topic=topic or None, + session_id=session_id or None, + ) + + +@mcp.tool() +def get_hint(question: str) -> dict: + """A short hint for a question that doesn't give away the answer. + Needs GROQ_API_KEY.""" + return tools.get_hint(_get_llm(), question) + + +@mcp.tool() +def record_attempt( + session_id: str, + topic: str, + question: str, + answer: str, + feedback: str, + is_correct: bool, +) -> dict: + """Persist an answered question directly (if grading happened elsewhere).""" + return tools.record_attempt( + session_id, topic, question, answer, feedback, is_correct + ) + + +@mcp.tool() +def rate_question(topic: str, question: str, rating: str) -> dict: + """Schedule a question's next spaced-repetition review. + rating is one of Again / Hard / Good / Easy.""" + return tools.rate_question(topic, question, rating) + + +@mcp.tool() +def get_due_reviews(topic: str = "") -> list[dict]: + """Questions whose spaced-repetition review date has arrived, most overdue first.""" + return tools.get_due_reviews(topic=topic or None) + + +@mcp.tool() +def get_progress() -> dict: + """All-time totals and the weakest topics by accuracy.""" + return tools.get_progress() + + +# ── resources ────────────────────────────────────────────────────────── + +@mcp.resource("interview://topics") +def topics_resource() -> str: + """The topic list as plain text.""" + return "\n".join(TOPICS) + + +@mcp.resource("interview://progress") +def progress_resource() -> str: + """A Markdown progress report.""" + s = tools.get_progress() + lines = [ + "# Java Interview Coach — progress", + "", + f"- Sessions: {s['total_sessions']}", + f"- Questions answered: {s['total_questions']}", + f"- Correct: {s['total_correct']}", + f"- Weakest topics: {', '.join(s['weakest_topics']) or '—'}", + "", + "| Topic | Attempts | Accuracy |", + "|---|---:|---:|", + ] + for topic, st in sorted(s["topic_stats"].items()): + lines.append(f"| {topic} | {st['total']} | {st['accuracy']:.0%} |") + return "\n".join(lines) + + +@mcp.resource("interview://question-bank/{topic}") +def question_bank_resource(topic: str) -> str: + """Every question in the bank for one topic (raw, no selection).""" + import json + from pathlib import Path + + path = Path(__file__).resolve().parent.parent / "questions_db.json" + if not path.exists(): + return ( + "questions_db.json is not built yet — run rag.ipynb once to fetch " + "the question bank (see README.md)." + ) + bank = json.loads(path.read_text()) + questions = bank.get(topic) + if questions is None: + return f"Unknown topic {topic!r}. Known: {', '.join(bank)}" + return "\n".join(f"- {q}" for q in questions) + + +# ── prompts ──────────────────────────────────────────────────────────── + +@mcp.prompt() +def mock_interview(topic: str = "auto", num_questions: int = 5) -> str: + """Template: run a full mock interview using this server's tools.""" + ask = 'get_interview_question with mode="auto"' if topic == "auto" else "get_interview_question" + return ( + f"Act as a Java technical interviewer. Run a {num_questions}-question " + f"mock interview on the topic '{topic}'.\n\n" + "1. Call start_session first.\n" + f"2. For each question: call {ask}, present it, wait for my answer, then " + "call evaluate_answer with the topic and session_id so it counts toward " + "my progress.\n" + "3. Only call get_hint if I explicitly ask for one.\n" + "4. After the last question, call get_progress and summarize how I did " + "and which topics to focus on.\n" + "5. Offer to rate_question any questions I want scheduled for review." + ) + + +def main() -> None: + import argparse + + parser = argparse.ArgumentParser(prog="mcp_server", description=__doc__) + parser.add_argument( + "--http", + action="store_true", + help="serve over streamable-HTTP instead of stdio", + ) + args = parser.parse_args() + mcp.run(transport="streamable-http" if args.http else "stdio") + + +if __name__ == "__main__": + main() diff --git a/mcp_server/tools.py b/mcp_server/tools.py new file mode 100644 index 0000000..5af4fab --- /dev/null +++ b/mcp_server/tools.py @@ -0,0 +1,178 @@ +"""Tool logic, independent of the MCP wire layer. + +Every function here is a plain callable with its heavy dependencies +(``collection``, ``llm``) passed in, so the store-backed tools can be unit +tested with nothing but ``pytest`` + a temp SQLite file, and the +retrieval/grading tools can be tested with a fake collection / fake LLM. +``mcp_server.server`` is the only place that builds the real ones. +""" +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from memory import store +from topics import TOPICS + +# How many RAG candidates to pull before the adaptive layer ranks them. +_POOL_SIZE = 12 + + +# ── store-backed tools (no API key, no vector store) ───────────────────── + +def list_topics(db_path: str | Path | None = None) -> list[dict[str, Any]]: + """The practice topics, each with the user's running accuracy.""" + stats = store.get_topic_stats(db_path=db_path) + out = [] + for topic in TOPICS: + s = stats.get(topic) + out.append( + { + "topic": topic, + "attempts": s["total"] if s else 0, + "accuracy": round(s["accuracy"], 2) if s else None, + } + ) + return out + + +def start_session(db_path: str | Path | None = None) -> dict[str, str]: + """Open a new practice session; pass its id to ``evaluate_answer`` to + have attempts recorded against it.""" + return {"session_id": store.start_session(db_path=db_path)} + + +def get_progress(db_path: str | Path | None = None) -> dict[str, Any]: + """All-time totals plus the weakest topics by accuracy.""" + return store.get_cumulative_stats(db_path=db_path) + + +def record_attempt( + session_id: str, + topic: str, + question: str, + answer: str, + feedback: str, + is_correct: bool, + db_path: str | Path | None = None, +) -> dict[str, bool]: + """Persist an answered question directly (when grading happened elsewhere).""" + store.record_attempt( + session_id=session_id, + topic=topic, + question=question, + answer=answer, + feedback=feedback, + is_correct=is_correct, + db_path=db_path, + ) + return {"recorded": True} + + +def rate_question( + topic: str, question: str, rating: str, db_path: str | Path | None = None +) -> dict[str, str]: + """Schedule a question's next spaced-repetition review from a confidence + rating: one of ``Again`` / ``Hard`` / ``Good`` / ``Easy``.""" + next_review_at = store.record_review(topic, question, rating, db_path=db_path) + return { + "topic": topic, + "question": question, + "rating": rating, + "next_review_at": next_review_at, + } + + +def get_due_reviews( + topic: str | None = None, db_path: str | Path | None = None +) -> list[dict[str, Any]]: + """Questions whose scheduled review date has arrived, most overdue first.""" + return store.get_due_questions(topic=topic, db_path=db_path) + + +# ── retrieval + grading tools (need a collection / an llm) ─────────────── + +def _retrieve_candidates(collection, topic: str, n_results: int = _POOL_SIZE) -> list[str]: + """The RAG step: semantic search against the ChromaDB collection. + + Inlined here (rather than imported from ``graph.workflow``) so this + module stays free of the LangChain/LangGraph import chain and the tool + logic is unit-testable with just a fake collection object. + """ + results = collection.query(query_texts=[topic], n_results=n_results) + documents = results.get("documents") or [[]] + return documents[0] + + +def get_interview_question( + collection, + topic: str, + mode: str = "topic", + db_path: str | Path | None = None, +) -> dict[str, Any]: + """RAG retrieval + difficulty-adaptive selection. + + ``mode="auto"`` ignores ``topic`` and picks one weighted toward the + user's weaker areas. + """ + from graph.selection import ( + estimate_difficulty, + pick_topic_for_auto_mode, + select_question, + ) + + chosen_topic = ( + pick_topic_for_auto_mode(TOPICS, db_path=db_path) if mode == "auto" else topic + ) + candidates = _retrieve_candidates(collection, chosen_topic) + if not candidates: + return {"topic": chosen_topic, "question": None, + "error": f"no questions in the bank for topic {chosen_topic!r}"} + + question = select_question(candidates, chosen_topic, db_path=db_path) + return { + "topic": chosen_topic, + "question": question, + "estimated_difficulty": round(estimate_difficulty(question), 2), + } + + +def evaluate_answer( + llm, + question: str, + answer: str, + topic: str | None = None, + session_id: str | None = None, + db_path: str | Path | None = None, +) -> dict[str, Any]: + """Grade an answer against an ideal answer (CORRECT / INCORRECT + feedback). + + If both ``topic`` and ``session_id`` are given, the attempt is persisted + so it counts toward progress and weak-topic tracking. + """ + from prompts import EVAL_PROMPT + + response = llm.invoke(EVAL_PROMPT.format(question=question, answer=answer)) + feedback = response.content + is_correct = feedback.strip().upper().startswith("CORRECT") + + if topic and session_id: + store.record_attempt( + session_id=session_id, + topic=topic, + question=question, + answer=answer, + feedback=feedback, + is_correct=is_correct, + db_path=db_path, + ) + + return {"is_correct": is_correct, "feedback": feedback, "recorded": bool(topic and session_id)} + + +def get_hint(llm, question: str) -> dict[str, str]: + """A short hint for a question that doesn't give away the answer.""" + from prompts import HINT_PROMPT + + response = llm.invoke(HINT_PROMPT.format(question=question)) + return {"hint": response.content} diff --git a/prompts.py b/prompts.py new file mode 100644 index 0000000..f824ff8 --- /dev/null +++ b/prompts.py @@ -0,0 +1,27 @@ +"""Prompt text for the evaluate / hint steps, in one place. + +``graph/workflow.py`` wraps these in ``ChatPromptTemplate`` for the +LangGraph nodes; ``mcp_server/tools.py`` uses ``str.format`` so it needs no +LangChain import. Same wording either way — the ``{question}`` / ``{answer}`` +placeholders are compatible with both. +""" + +EVAL_PROMPT = """ +You are a Java technical interviewer evaluating an answer. + +Question: {question} +Candidate's Answer: {answer} + +Respond with: +1. CORRECT or INCORRECT +2. Brief feedback (2-3 sentences) +3. Ideal answer in simple terms + +Start your response with either CORRECT or INCORRECT on the first line. +""" + +HINT_PROMPT = """ +You are a helpful Java tutor. +Give a short hint (2-3 sentences) for this question without giving away the answer. +Question: {question} +""" diff --git a/pyproject.toml b/pyproject.toml index b1bcaf8..4fc4765 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "langchain-google-genai>=4.2.5", "langchain-groq>=1.1.3", "langgraph>=1.2.6", + "mcp>=1.6,<2", "onnxruntime<1.20", "python-dotenv>=1.2.2", "streamlit>=1.58.0", diff --git a/requirements.txt b/requirements.txt index 7279254..95ae5cf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,9 @@ langgraph langchain +langchain-groq langchain-google-genai python-dotenv cryptography==41.0.7 chromadb -onnxruntime<1.20 \ No newline at end of file +onnxruntime<1.20 +mcp>=1.6,<2 diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py new file mode 100644 index 0000000..c03b52c --- /dev/null +++ b/tests/test_mcp_server.py @@ -0,0 +1,56 @@ +"""The FastMCP wiring: every tool/resource/prompt is registered, and the +store-backed tools work end to end through the server layer. + +Skips cleanly if the `mcp` SDK isn't installed (the pytest-only CI job). +""" +from __future__ import annotations + +import pytest + +pytest.importorskip("mcp", reason="the mcp SDK isn't installed") + +from mcp_server import server # noqa: E402 + + +EXPECTED_TOOLS = { + "list_topics", "start_session", "get_interview_question", "evaluate_answer", + "get_hint", "record_attempt", "rate_question", "get_due_reviews", "get_progress", +} + + +def test_all_expected_tools_are_registered(): + names = {t.name for t in server.mcp._tool_manager.list_tools()} + assert EXPECTED_TOOLS <= names + + +def test_resources_and_prompt_are_registered(): + templates = {t.uri_template for t in server.mcp._resource_manager.list_templates()} + fixed = {str(r.uri) for r in server.mcp._resource_manager.list_resources()} + assert "interview://question-bank/{topic}" in templates + assert {"interview://topics", "interview://progress"} <= fixed + assert "mock_interview" in {p.name for p in server.mcp._prompt_manager.list_prompts()} + + +def test_grading_tool_gives_a_clear_error_without_a_key(monkeypatch): + monkeypatch.delenv("GROQ_API_KEY", raising=False) + server._llm = None + with pytest.raises(RuntimeError, match="GROQ_API_KEY"): + server.get_hint("What is a JVM?") + + +def test_question_bank_resource(): + from pathlib import Path + + real = Path(server.__file__).resolve().parent.parent / "questions_db.json" + text = server.question_bank_resource("OOP") + if real.exists(): + assert text.startswith("- ") or "Unknown topic" in text + else: + # No bank built here — the resource says so instead of raising. + assert "not built yet" in text + + +def test_progress_resource_renders_markdown(tmp_path, monkeypatch): + monkeypatch.setattr("memory.store.DB_PATH", tmp_path / "h.db") + out = server.progress_resource() + assert out.startswith("# Java Interview Coach") diff --git a/tests/test_mcp_tools.py b/tests/test_mcp_tools.py new file mode 100644 index 0000000..277a6e3 --- /dev/null +++ b/tests/test_mcp_tools.py @@ -0,0 +1,128 @@ +"""Tests for mcp_server/tools.py. + +``mcp_server.tools`` is deliberately free of the LangChain / ChromaDB / mcp +import chain, so every tool is exercised here with just pytest + a temp +SQLite file and, where needed, a fake collection / fake LLM. +""" +from __future__ import annotations + +import pytest + +from mcp_server import tools +from topics import TOPICS + + +@pytest.fixture +def db(tmp_path): + return tmp_path / "hist.db" + + +# ── fakes ────────────────────────────────────────────────────────────── + +class _FakeCollection: + """Stands in for a ChromaDB collection: returns a fixed candidate list.""" + + def __init__(self, documents): + self._documents = documents + + def query(self, query_texts, n_results): + return {"documents": [self._documents[:n_results]]} + + +class _FakeLLM: + def __init__(self, content): + self._content = content + + def invoke(self, _prompt): + return type("Resp", (), {"content": self._content})() + + +# ── store-backed tools ───────────────────────────────────────────────── + +def test_list_topics_reports_zero_before_any_practice(db): + rows = tools.list_topics(db_path=db) + assert [r["topic"] for r in rows] == TOPICS + assert all(r["attempts"] == 0 and r["accuracy"] is None for r in rows) + + +def test_start_session_returns_an_id(db): + out = tools.start_session(db_path=db) + assert isinstance(out["session_id"], str) and out["session_id"] + + +def test_record_attempt_then_progress_and_topic_accuracy(db): + sid = tools.start_session(db_path=db)["session_id"] + tools.record_attempt(sid, "OOP", "What is encapsulation?", "hiding state", + "CORRECT ...", True, db_path=db) + tools.record_attempt(sid, "OOP", "What is a JVM?", "no idea", + "INCORRECT ...", False, db_path=db) + + progress = tools.get_progress(db_path=db) + assert progress["total_questions"] == 2 + assert progress["total_correct"] == 1 + + oop = next(r for r in tools.list_topics(db_path=db) if r["topic"] == "OOP") + assert oop["attempts"] == 2 + assert oop["accuracy"] == 0.5 + + +def test_rate_question_schedules_a_review_and_it_comes_due(db): + out = tools.rate_question("Spring", "What is a bean?", "Again", db_path=db) + assert out["next_review_at"] # Again -> 1 day out + # Nothing is due yet... + assert tools.get_due_reviews(db_path=db) == [] + + +def test_rate_question_rejects_an_unknown_rating(db): + with pytest.raises(ValueError): + tools.rate_question("Spring", "q", "Sometimes", db_path=db) + + +# ── retrieval + grading tools (fakes, still no heavy deps) ────────────── + +def test_get_interview_question_selects_from_the_candidate_pool(db): + pool = [ + "What is the difference between an interface and an abstract class?", + "Define polymorphism.", + "How does the JVM implement method dispatch under the hood?", + ] + out = tools.get_interview_question(_FakeCollection(pool), "OOP", db_path=db) + assert out["question"] in pool + assert 0.0 <= out["estimated_difficulty"] <= 1.0 + assert out["topic"] == "OOP" + + +def test_get_interview_question_auto_mode_picks_a_known_topic(db): + out = tools.get_interview_question( + _FakeCollection(["Define polymorphism."]), "ignored", mode="auto", db_path=db + ) + assert out["topic"] in TOPICS + + +def test_get_interview_question_handles_an_empty_bank(db): + out = tools.get_interview_question(_FakeCollection([]), "OOP", db_path=db) + assert out["question"] is None + assert "no questions" in out["error"] + + +def test_evaluate_answer_parses_correct_and_persists_when_scoped(db): + sid = tools.start_session(db_path=db)["session_id"] + llm = _FakeLLM("CORRECT\nGood, that's the idea.\nIdeal: ...") + out = tools.evaluate_answer(llm, "What is encapsulation?", "hiding internal state", + topic="OOP", session_id=sid, db_path=db) + assert out["is_correct"] is True + assert out["recorded"] is True + assert tools.get_progress(db_path=db)["total_questions"] == 1 + + +def test_evaluate_answer_does_not_persist_without_topic_and_session(db): + llm = _FakeLLM("INCORRECT\nNot quite.\nIdeal: ...") + out = tools.evaluate_answer(llm, "q", "a", db_path=db) + assert out["is_correct"] is False + assert out["recorded"] is False + assert tools.get_progress(db_path=db)["total_questions"] == 0 + + +def test_get_hint_returns_the_model_text(db): + out = tools.get_hint(_FakeLLM("Think about what 'private' buys you."), "q") + assert out["hint"].startswith("Think about") diff --git a/topics.py b/topics.py new file mode 100644 index 0000000..c654d9f --- /dev/null +++ b/topics.py @@ -0,0 +1,21 @@ +"""The fixed topic list, shared by every front-end (Streamlit, CLI, MCP). + +Kept in one module so adding a topic is a one-line change and the CLI, the +MCP server, and auto-mode weighting can't drift out of sync. +""" +from __future__ import annotations + +AUTO = "auto" + +TOPICS: list[str] = [ + "OOP", + "Java Core", + "Java Collections", + "Spring", + "JVM", + "Multithreading", + "Databases", + "Java 8", + "Patterns", + "Testing", +] diff --git a/uv.lock b/uv.lock index 8c201b8..57cdfa2 100644 --- a/uv.lock +++ b/uv.lock @@ -1057,6 +1057,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[[package]] +name = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, +] + [[package]] name = "huggingface-hub" version = "1.28.0" @@ -1197,6 +1206,7 @@ dependencies = [ { name = "langchain-google-genai" }, { name = "langchain-groq" }, { name = "langgraph" }, + { name = "mcp" }, { name = "onnxruntime" }, { name = "python-dotenv" }, { name = "streamlit" }, @@ -1217,6 +1227,7 @@ requires-dist = [ { name = "langchain-google-genai", specifier = ">=4.2.5" }, { name = "langchain-groq", specifier = ">=1.1.3" }, { name = "langgraph", specifier = ">=1.2.6" }, + { name = "mcp", specifier = ">=1.6,<2" }, { name = "onnxruntime", specifier = "<1.20" }, { name = "python-dotenv", specifier = ">=1.2.2" }, { name = "streamlit", specifier = ">=1.58.0" }, @@ -1600,6 +1611,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/09/5b161152e2d90f7b87f781c2e1267494aef9c32498df793f73ad0a0a494a/matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6", size = 9534, upload-time = "2026-05-08T17:33:32.055Z" }, ] +[[package]] +name = "mcp" +version = "1.29.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/48/0bb26fdfe7ac16875f534a101ce2405eae192bdef37e7451f2f4507c13ec/mcp-1.29.1.tar.gz", hash = "sha256:1967ba4c315f7a375146209949f45950d18b0efd2f913d7cf3400bc723ee5f04", size = 646823, upload-time = "2026-08-24T18:30:41.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/04/d6b4fb82eefe9e81807aabca1ac98f460ae0883974b83a997aaa20c52545/mcp-1.29.1-py3-none-any.whl", hash = "sha256:b6310eeb59153300c4ab8b9aec4c52f4819a2d6a8e429eb43d908bed7c783648", size = 224653, upload-time = "2026-08-24T18:30:39.573Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -3022,6 +3058,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "pypika" version = "0.51.1" @@ -3095,6 +3145,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, ] +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/f5/10a6e845a00fc5e7afd0a988b744f403d4d57162a28d160a093c4d9322f0/pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c", size = 6362659, upload-time = "2026-06-04T07:49:21.349Z" }, + { url = "https://files.pythonhosted.org/packages/35/c4/dcd2d62b5944b6d5db53413a5899016ccd57ffcb7278f3f81655d25d2027/pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a", size = 6928825, upload-time = "2026-06-04T07:49:23.934Z" }, + { url = "https://files.pythonhosted.org/packages/b7/56/3cbb433fe4501cdba2eb9040f56a4e1a8243faa4186b25295564d1a7a79d/pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47", size = 6721875, upload-time = "2026-06-04T07:49:26.416Z" }, + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -3448,6 +3520,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] +[[package]] +name = "sse-starlette" +version = "3.4.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1e/e1/8a41e88e825ea26c44333897c7ffe35fe60153a2cfc097a5bd1d209ad281/sse_starlette-3.4.10.tar.gz", hash = "sha256:c6c87280d8feb4e55a8d79633782766b9cac6a26da5c79a145d00aa404117a86", size = 33720, upload-time = "2026-09-03T09:36:24.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/3c/96018a51c7301a64f7b0579d9ce8f9b69dd39ca8ed5aa100ba3feadee503/sse_starlette-3.4.10-py3-none-any.whl", hash = "sha256:710f5f5b0527409903a22a91699db02f76f4c2eb9204e882e4ee7cada76bdf75", size = 17120, upload-time = "2026-09-03T09:36:22.56Z" }, +] + [[package]] name = "stack-data" version = "0.6.3"