mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 22:27:01 +01:00
New python_pkg/code_tutor package that turns an unfamiliar codebase into an interactive lesson: analyse code into items (_analyzer), order them dependency-first (_plan_builder), pose reimplement challenges (_challenge + _challenge_support), verify user implementations with tests and coverage (_verifier), track progress (_progress/_session), and offer local-LLM hints (_llm), driven by a Typer/Rich CLI (cli). - meta/pyproject.toml + .pre-commit-config.yaml + meta/requirements.txt: add anthropic/rich/typer deps, a cli.py per-file-ignore (Typer B008 etc), and code_tutor-specific ruff/pylint/mypy tuning. - _challenge.py and its tests are split across _challenge_support.py and test_challenge_part2/part3.py to stay under the 500-line file cap. Verification: pytest 631 passed at 100% line+branch coverage; every file <=500 lines; pre-commit (ruff ALL, mypy strict, pylint 10/10, bandit, evidence + multi-file contract) green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RRCdR4D8SaVrN2xraMQxM
228 lines
6.7 KiB
Python
228 lines
6.7 KiB
Python
"""Persist and load study plans and progress state.
|
|
|
|
Storage layout::
|
|
|
|
~/.config/code_tutor/
|
|
└── <sha256 of absolute codebase path>/
|
|
├── plan.json # created by ``analyze``
|
|
├── progress.json # updated after each lesson
|
|
└── sessions/
|
|
└── YYYY-MM-DD.jsonl # full lesson records for analysis
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import asdict, dataclass
|
|
import datetime
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
from typing import TypedDict, cast
|
|
|
|
from python_pkg.code_tutor._analyzer import CodeItem
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TypedDicts matching the plan.json / progress.json schemas
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class ItemData(TypedDict):
|
|
"""Schema for a single code item entry in ``plan.json``."""
|
|
|
|
id: str
|
|
file: str
|
|
type: str
|
|
name: str
|
|
start_line: int
|
|
end_line: int
|
|
class_name: str
|
|
depends_on: list[str]
|
|
|
|
|
|
class SessionData(TypedDict):
|
|
"""Schema for a session block in ``plan.json``."""
|
|
|
|
id: int
|
|
title: str
|
|
items: list[ItemData]
|
|
|
|
|
|
class PlanData(TypedDict):
|
|
"""Top-level schema of ``plan.json``."""
|
|
|
|
codebase_path: str
|
|
created_at: str
|
|
total_items: int
|
|
source_fingerprint: str
|
|
sessions: list[SessionData]
|
|
|
|
|
|
class ProgressData(TypedDict):
|
|
"""Schema of ``progress.json`` tracking what the user has studied."""
|
|
|
|
learned: list[str]
|
|
struggled: list[str]
|
|
skipped: list[str]
|
|
last_session: str
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Full lesson record — written to sessions/YYYY-MM-DD.jsonl for analysis
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass
|
|
class LessonRecord:
|
|
"""Complete transcript of one Socratic lesson.
|
|
|
|
Stored as a JSON line so the file can be streamed / analysed later.
|
|
|
|
Attributes:
|
|
timestamp: ISO-8601 UTC timestamp of the lesson.
|
|
item_id: Dotted ID of the code item studied.
|
|
file: Relative path of the source file.
|
|
lines: ``"start-end"`` line range string.
|
|
snippet: The exact source text shown to the user.
|
|
outcome: ``"learned"``, ``"struggled"``, or ``"skipped"``.
|
|
answers: Mapping of question label to the user's answer.
|
|
improvement: User's free-text code improvement idea (may be empty).
|
|
verdict: Final LLM verdict: ``"PASS"``, ``"FAIL"``, or ``"skipped"``.
|
|
attempt: Which attempt produced the final verdict (1-3).
|
|
"""
|
|
|
|
timestamp: str
|
|
item_id: str
|
|
file: str
|
|
lines: str
|
|
snippet: str
|
|
outcome: str
|
|
answers: dict[str, str]
|
|
improvement: str
|
|
verdict: str
|
|
attempt: int
|
|
challenge_result: str = ""
|
|
|
|
|
|
def append_session_record(codebase: Path, record: LessonRecord) -> None:
|
|
"""Append *record* as a JSON line to today's session log for *codebase*.
|
|
|
|
Creates ``sessions/`` if it does not exist. Each line is a self-contained
|
|
JSON object so the file can be streamed with ``jq``.
|
|
|
|
Args:
|
|
codebase: Root directory of the codebase being studied.
|
|
record: Lesson record to persist.
|
|
"""
|
|
sessions_dir = config_dir(codebase) / "sessions"
|
|
sessions_dir.mkdir(parents=True, exist_ok=True)
|
|
today = datetime.datetime.now(tz=datetime.timezone.utc).date().isoformat()
|
|
log_file = sessions_dir / f"{today}.jsonl"
|
|
line = json.dumps(asdict(record), ensure_ascii=False)
|
|
with log_file.open("a", encoding="utf-8") as fh:
|
|
fh.write(line + "\n")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Config directory helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def config_dir(codebase: Path) -> Path:
|
|
"""Return the config directory for *codebase*.
|
|
|
|
The directory name is the SHA-256 hex digest of the absolute path string,
|
|
so different codebases never collide.
|
|
|
|
Args:
|
|
codebase: Root directory of the codebase being studied.
|
|
|
|
Returns:
|
|
``~/.config/code_tutor/<hex>/`` (not guaranteed to exist).
|
|
"""
|
|
path_hash = hashlib.sha256(str(codebase.resolve()).encode()).hexdigest()
|
|
return Path.home() / ".config" / "code_tutor" / path_hash
|
|
|
|
|
|
def load_plan(codebase: Path) -> PlanData | None:
|
|
"""Load the saved plan for *codebase*, or return ``None`` if absent.
|
|
|
|
Args:
|
|
codebase: Root directory of the codebase.
|
|
|
|
Returns:
|
|
Parsed ``PlanData`` dict, or ``None`` when no plan file exists.
|
|
"""
|
|
plan_file = config_dir(codebase) / "plan.json"
|
|
if not plan_file.exists():
|
|
return None
|
|
return cast("PlanData", json.loads(plan_file.read_text(encoding="utf-8")))
|
|
|
|
|
|
def save_plan(codebase: Path, plan: dict[str, object]) -> None:
|
|
"""Persist *plan* to ``plan.json`` for *codebase*.
|
|
|
|
Creates the config directory if it does not already exist.
|
|
|
|
Args:
|
|
codebase: Root directory of the codebase.
|
|
plan: Plan dict (as returned by ``build_plan``).
|
|
"""
|
|
dest = config_dir(codebase)
|
|
dest.mkdir(parents=True, exist_ok=True)
|
|
(dest / "plan.json").write_text(json.dumps(plan, indent=2), encoding="utf-8")
|
|
|
|
|
|
def load_progress(codebase: Path) -> ProgressData:
|
|
"""Load progress for *codebase*, returning an empty record if none exists.
|
|
|
|
Args:
|
|
codebase: Root directory of the codebase.
|
|
|
|
Returns:
|
|
``ProgressData`` dict with learned/struggled/skipped lists.
|
|
"""
|
|
progress_file = config_dir(codebase) / "progress.json"
|
|
if not progress_file.exists():
|
|
return {
|
|
"learned": [],
|
|
"struggled": [],
|
|
"skipped": [],
|
|
"last_session": "",
|
|
}
|
|
return cast("ProgressData", json.loads(progress_file.read_text(encoding="utf-8")))
|
|
|
|
|
|
def save_progress(codebase: Path, progress: ProgressData) -> None:
|
|
"""Write *progress* to ``progress.json`` for *codebase*.
|
|
|
|
Args:
|
|
codebase: Root directory of the codebase.
|
|
progress: Progress dict to persist.
|
|
"""
|
|
dest = config_dir(codebase)
|
|
dest.mkdir(parents=True, exist_ok=True)
|
|
(dest / "progress.json").write_text(
|
|
json.dumps(progress, indent=2), encoding="utf-8"
|
|
)
|
|
|
|
|
|
def item_from_data(data: ItemData) -> CodeItem:
|
|
"""Convert an ``ItemData`` TypedDict to a ``CodeItem`` dataclass.
|
|
|
|
Args:
|
|
data: Dict loaded from ``plan.json``.
|
|
|
|
Returns:
|
|
Corresponding ``CodeItem`` with all fields populated.
|
|
"""
|
|
return CodeItem(
|
|
id=data["id"],
|
|
file=data["file"],
|
|
type=data["type"],
|
|
name=data["name"],
|
|
start_line=data["start_line"],
|
|
end_line=data["end_line"],
|
|
class_name=data["class_name"],
|
|
depends_on=data["depends_on"],
|
|
)
|