monash-study-kit 1.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,324 @@
1
+ """课件全文索引:PDF、录播字幕稿、网页/文本课件,用 SQLite FTS5 搜。
2
+
3
+ monash grep "monad" # 搜,结果带页码 / 字幕时间戳(sync 完会自动增量建索引)
4
+ monash grep "docker volume" FIT2109
5
+
6
+ 索引两处的文件:
7
+ - Moodle 同步下来的课件(FILES_DIR),课号取自第一层目录名;
8
+ - Ed Lessons 的课件 PDF 和文字页(ED_FILES_DIR),课号和 lesson 标题查 ed.db。
9
+
10
+ 切块:字幕稿按 **[MM:SS]** 段落,PDF 按页,docx/pptx 按段/页,其他文本按空行攒到约 1500 字。
11
+ zip 里的文本文件(starter code、README)也索引,loc 是 zip 内的路径,交给 read_file 的 inner。
12
+ 每块一行,loc 记“p.3”“12:34”“slide 5”这类定位。文件没变(mtime + size)不重新抽取。
13
+ 视频不索引。
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ import sqlite3
19
+ import zipfile
20
+ from pathlib import Path
21
+
22
+ from . import moodlelib, textextract
23
+ from .edlib import DB_PATH as ED_DB
24
+ from .moodlelib import FILES_DIR, now_iso
25
+ from .paths import ED_FILES_DIR
26
+ TEXT_EXT = {".md", ".txt", ".html", ".htm", ".py", ".hs", ".java", ".c", ".h", ".js", ".ts", ".sh",
27
+ ".json", ".csv", ".ipynb", ".r", ".sql", ".tex", ".yaml", ".yml"}
28
+ INDEX_EXT = TEXT_EXT | {".pdf", ".docx", ".pptx", ".zip"}
29
+ ZIP_MEMBER_MAX = 1 << 20 # zip 里单个文本文件最多读 1 MB,再大的多半是数据集
30
+ ZIP_MEMBERS_MAX = 300
31
+ ED_PREFIX = "ed:" # Ed 的文件在 read_file 里带这个前缀
32
+ CHUNK = 1500
33
+ MAX_FILE = 30 * 1024 * 1024
34
+ CODE_RE = re.compile(r"\b([A-Z]{3}\d{4})\b")
35
+ TS_RE = re.compile(r"^\*\*\[(\d{1,2}:\d{2}(?::\d{2})?)\]\*\*\s*", re.M)
36
+
37
+ SCHEMA = """
38
+ CREATE TABLE IF NOT EXISTS content_files (
39
+ path TEXT PRIMARY KEY, source TEXT, course TEXT, title TEXT,
40
+ mtime REAL, size INTEGER, chunks INTEGER, indexed_at TEXT
41
+ );
42
+ CREATE VIRTUAL TABLE IF NOT EXISTS content_fts USING fts5(
43
+ text, path UNINDEXED, loc UNINDEXED, tokenize = 'porter unicode61'
44
+ );
45
+ """
46
+
47
+
48
+ def ensure_schema(con: sqlite3.Connection) -> None:
49
+ con.executescript(SCHEMA)
50
+ # 1.0.3 以前的库没有 url(Ed 课件的原文链接),补上
51
+ if "url" not in {r[1] for r in con.execute("PRAGMA table_info(content_files)")}:
52
+ con.execute("ALTER TABLE content_files ADD COLUMN url TEXT")
53
+
54
+
55
+ # ---------------------------------------------------------------- 抽文字
56
+
57
+ def _paragraph_chunks(text: str, prefix: str = "¶") -> list[tuple[str, str]]:
58
+ out, buf, n = [], [], 0
59
+ for para in re.split(r"\n\s*\n", text):
60
+ para = para.strip()
61
+ if not para:
62
+ continue
63
+ buf.append(para)
64
+ if sum(len(p) for p in buf) >= CHUNK:
65
+ n += 1
66
+ out.append((f"{prefix}{n}", "\n\n".join(buf)))
67
+ buf = []
68
+ if buf:
69
+ out.append((f"{prefix}{n + 1}", "\n\n".join(buf)))
70
+ return out
71
+
72
+
73
+ def transcript_chunks(text: str) -> list[tuple[str, str]]:
74
+ """**[01:06]** 开头的段落各成一块,loc 就是时间戳。"""
75
+ parts = TS_RE.split(text)
76
+ # split 结果:[开头, ts1, 正文1, ts2, 正文2, ...]
77
+ return [(parts[i], parts[i + 1].strip()) for i in range(1, len(parts) - 1, 2) if parts[i + 1].strip()]
78
+
79
+
80
+ def pdf_chunks(path: Path) -> list[tuple[str, str]]:
81
+ pages = textextract.pdf_pages(path)
82
+ return [(f"p.{i}", " ".join(p.split())) for i, p in enumerate(pages, 1) if p.strip()]
83
+
84
+
85
+ def office_chunks(path: Path) -> list[tuple[str, str]]:
86
+ parts = textextract.office_parts(path)
87
+ if path.suffix.lower() == ".pptx":
88
+ return parts
89
+ return _paragraph_chunks(parts[0][1]) if parts else []
90
+
91
+
92
+ def zip_chunks(path: Path) -> list[tuple[str, str]]:
93
+ """zip 里的文本文件按空行切块,loc 是 zip 内路径(read_file 的 inner 直接用它)。"""
94
+ out = []
95
+ with zipfile.ZipFile(path) as z:
96
+ members = [i for i in z.infolist() if not i.is_dir() and i.file_size <= ZIP_MEMBER_MAX
97
+ and Path(i.filename).suffix.lower() in TEXT_EXT
98
+ and not i.filename.startswith("__MACOSX/") and not Path(i.filename).name.startswith(".")]
99
+ for info in members[:ZIP_MEMBERS_MAX]:
100
+ text = z.read(info).decode("utf-8", "replace")
101
+ out += [(info.filename, chunk) for _, chunk in _paragraph_chunks(text)]
102
+ return out
103
+
104
+
105
+ def extract(path: Path) -> list[tuple[str, str]]:
106
+ ext = path.suffix.lower()
107
+ if ext == ".pdf":
108
+ return pdf_chunks(path)
109
+ if ext == ".zip":
110
+ try:
111
+ return zip_chunks(path)
112
+ except (zipfile.BadZipFile, NotImplementedError, RuntimeError): # 坏包、少见的压缩方式、加密
113
+ return []
114
+ if ext in (".docx", ".pptx"):
115
+ try:
116
+ return office_chunks(path)
117
+ except (zipfile.BadZipFile, KeyError):
118
+ return []
119
+ text = path.read_text(encoding="utf-8", errors="replace")
120
+ if path.name.endswith(".transcript.md"):
121
+ return transcript_chunks(text)
122
+ if ext in (".html", ".htm"):
123
+ text = re.sub(r"(?s)<(script|style).*?</\1>|<[^>]+>", " ", text)
124
+ return _paragraph_chunks(text)
125
+
126
+
127
+ # ---------------------------------------------------------------- 要索引哪些文件
128
+
129
+ def _ed_labels() -> dict[str, tuple[str, str, str | None]]:
130
+ """Ed 课件的 local_path → (课号, "lesson 标题 · 文件标题", 原文链接)。
131
+
132
+ 两处来源:lesson_files(课件 PDF)和 lesson_texts(Ed 文字页、抓下来的阅读网页;
133
+ ed-digest 的库有这张表,本包自己同步的库没有,没有就跳过)。"""
134
+ db = ED_DB
135
+ if not db.exists():
136
+ return {}
137
+ rows = []
138
+ try:
139
+ con = sqlite3.connect(f"file:{db.as_posix()}?mode=ro", uri=True)
140
+ except sqlite3.Error:
141
+ return {}
142
+ try:
143
+ cols = {r[1] for r in con.execute("PRAGMA table_info(lesson_files)")}
144
+ url = "f.file_url" if "file_url" in cols else "NULL"
145
+ rows += con.execute(f"""SELECT f.local_path, f.title, l.title, c.code, {url} FROM lesson_files f
146
+ JOIN lessons l ON l.id = f.lesson_id JOIN courses c ON c.id = f.course_id
147
+ WHERE f.local_path IS NOT NULL""").fetchall()
148
+ try:
149
+ rows += con.execute("""SELECT t.local_path, t.title, l.title, c.code,
150
+ COALESCE(t.url, 'https://edstem.org/au/courses/' || t.course_id
151
+ || '/lessons/' || t.lesson_id || '/slides/' || t.slide_id)
152
+ FROM lesson_texts t JOIN lessons l ON l.id = t.lesson_id
153
+ JOIN courses c ON c.id = t.course_id
154
+ WHERE t.local_path IS NOT NULL""").fetchall()
155
+ except sqlite3.OperationalError:
156
+ pass
157
+ except sqlite3.Error:
158
+ return {}
159
+ finally:
160
+ con.close()
161
+ out = {}
162
+ for rel, ftitle, ltitle, code, link in rows:
163
+ m = CODE_RE.search(code or "")
164
+ out[rel] = (m.group(1) if m else (code or "").strip(), f"{ltitle} · {ftitle}", link)
165
+ return out
166
+
167
+
168
+ def _removed() -> set[str]:
169
+ """Moodle 上已经下架的文件,不再进搜索。还没有库(第一次同步前、测试里)就当没有。"""
170
+ if not (moodlelib.DATA_DIR / "moodle.db").exists():
171
+ return set()
172
+ con = moodlelib.db_connect()
173
+ try:
174
+ return moodlelib.removed_paths(con)
175
+ finally:
176
+ con.close()
177
+
178
+
179
+ def _inside(path: Path, root: Path) -> bool:
180
+ """软链接指到文件目录外面的不进索引,和 read_file 的规则一样。"""
181
+ return not path.is_symlink() or root.resolve() in path.resolve().parents
182
+
183
+
184
+ def candidates() -> list[dict]:
185
+ files = []
186
+ if FILES_DIR.exists():
187
+ removed = _removed()
188
+ for p in FILES_DIR.rglob("*"):
189
+ if p.is_file() and p.suffix.lower() in INDEX_EXT and _inside(p, FILES_DIR):
190
+ rel = p.relative_to(FILES_DIR)
191
+ if rel.as_posix() in removed:
192
+ continue
193
+ m = CODE_RE.search(rel.parts[0]) if len(rel.parts) > 1 else None
194
+ files.append({"path": str(p), "source": "moodle", "course": m.group(1) if m else None,
195
+ "title": rel.as_posix(), "url": None})
196
+ ed_files = ED_FILES_DIR
197
+ if ed_files.exists():
198
+ labels = _ed_labels()
199
+ for p in ed_files.rglob("*"):
200
+ if p.is_file() and p.suffix.lower() in INDEX_EXT and _inside(p, ed_files):
201
+ rel = p.relative_to(ed_files).as_posix()
202
+ course, title, url = labels.get(rel, (None, rel, None))
203
+ files.append({"path": str(p), "source": "ed", "course": course, "title": title, "url": url})
204
+ return files
205
+
206
+
207
+ # ---------------------------------------------------------------- 建索引 / 搜
208
+
209
+ def index(con: sqlite3.Connection, verbose: bool = False) -> dict:
210
+ ensure_schema(con)
211
+ known = {r[0]: (r[1], r[2]) for r in con.execute("SELECT path, mtime, size FROM content_files")}
212
+ seen, stats = set(), {"indexed": 0, "unchanged": 0, "removed": 0, "chunks": 0}
213
+ for f in candidates():
214
+ p = Path(f["path"])
215
+ try:
216
+ st = p.stat()
217
+ except OSError: # 列完目录到这里之间被删了(同步正好在改名)
218
+ continue
219
+ seen.add(f["path"])
220
+ if st.st_size > MAX_FILE:
221
+ continue
222
+ if known.get(f["path"]) == (st.st_mtime, st.st_size):
223
+ # 标签可能变了(比如 ed 那边补了 lesson 标题),便宜,顺手更新
224
+ con.execute("UPDATE content_files SET course=?, title=?, url=? WHERE path=?",
225
+ (f["course"], f["title"], f["url"], f["path"]))
226
+ stats["unchanged"] += 1
227
+ continue
228
+ try:
229
+ chunks = extract(p)
230
+ except Exception as e: # noqa: BLE001 一个坏文件(加密的 zip、半截的 docx)不该让整次索引失败
231
+ if verbose:
232
+ print(f"! {p}: {e}")
233
+ continue
234
+ con.execute("DELETE FROM content_fts WHERE path = ?", (f["path"],))
235
+ con.executemany("INSERT INTO content_fts (text, path, loc) VALUES (?,?,?)",
236
+ [(text, f["path"], loc) for loc, text in chunks])
237
+ con.execute("INSERT OR REPLACE INTO content_files (path, source, course, title, mtime, size, chunks, "
238
+ "indexed_at, url) VALUES (?,?,?,?,?,?,?,?,?)",
239
+ (f["path"], f["source"], f["course"], f["title"], st.st_mtime, st.st_size,
240
+ len(chunks), now_iso(), f["url"]))
241
+ stats["indexed"] += 1
242
+ stats["chunks"] += len(chunks)
243
+ if verbose:
244
+ print(f" {len(chunks):4d} 块 {f['title']}")
245
+ for gone in set(known) - seen:
246
+ con.execute("DELETE FROM content_fts WHERE path = ?", (gone,))
247
+ con.execute("DELETE FROM content_files WHERE path = ?", (gone,))
248
+ stats["removed"] += 1
249
+ con.commit()
250
+ return stats
251
+
252
+
253
+ def fts_query(q: str) -> str:
254
+ """用户输入 → FTS5 查询:每个词都要出现;"引号里的"当短语;词尾 * 当前缀。其余符号去掉,避免语法错误。"""
255
+ parts = []
256
+ for phrase, word in re.findall(r'"([^"]+)"|(\S+)', q):
257
+ if phrase:
258
+ toks = re.findall(r"\w+", phrase)
259
+ if toks:
260
+ parts.append('"' + " ".join(toks) + '"')
261
+ else:
262
+ star = word.endswith("*")
263
+ for t in re.findall(r"\w+", word):
264
+ parts.append(f'"{t}"' + ("*" if star else ""))
265
+ return " AND ".join(parts)
266
+
267
+
268
+ def search(con: sqlite3.Connection, query: str, course: str | None = None, limit: int = 20) -> list[dict]:
269
+ ensure_schema(con)
270
+ q = fts_query(query)
271
+ if not q:
272
+ return []
273
+ sql = """SELECT f.course, f.source, f.title, t.path, t.loc,
274
+ snippet(content_fts, 0, '«', '»', '…', 24) AS snip, bm25(content_fts) AS score, f.url
275
+ FROM content_fts t JOIN content_files f ON f.path = t.path
276
+ WHERE content_fts MATCH ?"""
277
+ args: list = [q]
278
+ if course:
279
+ sql += " AND f.course = ?"
280
+ args.append(course.upper())
281
+ sql += " ORDER BY score LIMIT ?"
282
+ args.append(limit)
283
+ out = []
284
+ for r in con.execute(sql, args):
285
+ path = Path(r[3])
286
+ hit = {"course": r[0], "source": r[1], "title": r[2], "loc": r[4],
287
+ "snippet": " ".join(r[5].split()), "path": readable_path(path), "abs_path": r[3], "url": r[7],
288
+ "video": _video_for(path) if path.name.endswith(".transcript.md") else None}
289
+ if path.suffix.lower() == ".zip":
290
+ hit["inner"] = r[4] # 命中的是 zip 里的这个文件,交给 read_file 的 inner
291
+ out.append(hit)
292
+ return out
293
+
294
+
295
+ def readable_path(path: Path) -> str | None:
296
+ """给 read_file 用的相对路径:Moodle 课件是 FILES_DIR 下的相对路径,Ed 的加 "ed:" 前缀。"""
297
+ if FILES_DIR in path.parents:
298
+ return path.relative_to(FILES_DIR).as_posix()
299
+ if ED_FILES_DIR in path.parents:
300
+ return ED_PREFIX + path.relative_to(ED_FILES_DIR).as_posix()
301
+ return None
302
+
303
+
304
+ def resolve_readable(rel: str) -> Path:
305
+ """readable_path 的反向:相对路径 → 绝对路径,只允许落在两个文件目录里面。"""
306
+ rel = rel.strip()
307
+ base = FILES_DIR
308
+ if rel.startswith(ED_PREFIX):
309
+ base, rel = ED_FILES_DIR, rel[len(ED_PREFIX):]
310
+ path = (base / rel.lstrip("/")).resolve()
311
+ if base.resolve() not in path.parents or not path.is_file():
312
+ raise ValueError("只能读 list_files / search_content 给出的文件(相对路径,Ed 的带 ed: 前缀)")
313
+ if base is FILES_DIR and path.relative_to(FILES_DIR.resolve()).as_posix() in _removed():
314
+ raise ValueError("这个文件已经在 Moodle 上撤下了")
315
+ return path
316
+
317
+
318
+ def _video_for(transcript: Path) -> str | None:
319
+ base = transcript.name[: -len(".transcript.md")]
320
+ for ext in (".mp4", ".m4a", ".webm", ".mkv"):
321
+ v = transcript.with_name(base + ext)
322
+ if v.exists():
323
+ return v.name
324
+ return None
@@ -0,0 +1,228 @@
1
+ """monash doctor:把常见问题一项项查一遍,每项给出“怎么修”。
2
+
3
+ --deep 再拿第二个来源核对一遍工具给出的结果,见 doctor_deep.py。
4
+
5
+ 输出是给朋友发给帮忙的人看的,所以绝不打印令牌、cookie、日历链接、邮箱;
6
+ 路径、版本、课号、报错摘要可以打。每项检查互相独立,一项出错不影响别的。
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import platform
12
+ import re
13
+ import shutil
14
+ import subprocess
15
+ import urllib.error
16
+ from datetime import datetime, timezone
17
+
18
+ from . import __version__, claude_setup, edlib, jobs
19
+ from .moodlelib import MoodleAuthError, MoodleClient, MoodleError, db_connect
20
+ from .paths import DEFAULTS, FILES_DIR, HOME, LOG_FILE, load_settings
21
+
22
+ OK, WARN, FAIL = "ok", "warn", "fail"
23
+ MARK = {OK: "✓", WARN: "!", FAIL: "✗"}
24
+ ERROR_LINE = re.compile(r"出错|失败|Traceback|Error", re.I)
25
+ SECRET = re.compile(r"(authtoken=|sesskey=|MoodleSession[=:]\s*|Bearer\s+|token[=:]\s*)[^\s&\"',]+", re.I)
26
+
27
+
28
+ def _check(name: str, status: str, detail: str, fix: str = "") -> dict:
29
+ return {"name": name, "status": status, "detail": detail, "fix": fix}
30
+
31
+
32
+ def scrub(text: str) -> str:
33
+ """日志摘要里万一带了凭据,遮掉再给人看。"""
34
+ return SECRET.sub(r"\1…", text)
35
+
36
+
37
+ # ---------------------------------------------------------------- 各项检查
38
+
39
+ def check_install() -> dict:
40
+ from . import update_check
41
+ exe = shutil.which("monash")
42
+ where = f"monash {__version__} · Python {platform.python_version()} · {platform.system()} {platform.machine()}"
43
+ if not exe:
44
+ return _check("安装", WARN, where + ";命令行里找不到 monash",
45
+ "关掉终端重新打开;还不行就重新运行 README 里的安装命令")
46
+ latest = update_check.refresh().get("latest") if update_check.enabled() else None
47
+ if update_check.is_newer(latest):
48
+ return _check("安装", WARN, f"{where};最新版是 {latest}", "monash update,然后重启 Claude Desktop")
49
+ return _check("安装", OK, where + ("(已是最新)" if latest else ""))
50
+
51
+
52
+ def check_data_dir() -> dict:
53
+ try:
54
+ HOME.mkdir(parents=True, exist_ok=True)
55
+ probe = HOME / ".doctor-probe"
56
+ probe.write_text("x", encoding="utf-8")
57
+ probe.unlink()
58
+ except OSError as e:
59
+ return _check("数据目录", FAIL, f"{HOME} 写不进去:{e}", "检查磁盘空间和文件夹权限")
60
+ free = shutil.disk_usage(HOME).free / 1e9
61
+ status = WARN if free < 1 else OK
62
+ return _check("数据目录", status, f"{HOME}(剩余 {free:.1f} GB)",
63
+ "磁盘快满了,课件同步可能失败" if status == WARN else "")
64
+
65
+
66
+ def check_browser() -> dict:
67
+ from .browser_login import find_browser
68
+ exe = find_browser()
69
+ if not exe:
70
+ return _check("登录用的浏览器", FAIL, "没找到 Chrome / Edge / Brave",
71
+ "装一个 Chrome 或 Edge;装在别处的话 monash config browser <路径>")
72
+ return _check("登录用的浏览器", OK, exe)
73
+
74
+
75
+ def check_moodle() -> dict:
76
+ from . import features as F
77
+ fallback = ";截止日期暂时靠日历订阅链接" if F.load_calendar_url() else ""
78
+ try:
79
+ left = MoodleClient().time_remaining()
80
+ except MoodleAuthError as e:
81
+ return _check("Moodle 登录", FAIL, f"{scrub(str(e))}{fallback}", "monash login")
82
+ except (MoodleError, urllib.error.URLError, OSError) as e:
83
+ return _check("Moodle 登录", WARN, f"连不上 Moodle:{scrub(str(e))}", "检查网络,过一会儿再试")
84
+ return _check("Moodle 登录", OK, f"已登录,会话还剩 {(left or 0) // 60} 分钟")
85
+
86
+
87
+ def check_ed() -> dict:
88
+ if not jobs.have_ed_token():
89
+ return _check("Ed 令牌", FAIL, "还没设置", "monash login ed")
90
+ try:
91
+ user = edlib.EdClient().me().get("user", {})
92
+ except edlib.EdAuthError:
93
+ return _check("Ed 令牌", FAIL, "令牌失效了(在 Ed 设置页被删了?)", "monash login ed,粘贴一个新令牌")
94
+ except (urllib.error.URLError, OSError) as e:
95
+ return _check("Ed 令牌", WARN, f"连不上 Ed:{scrub(str(e))}", "检查网络,过一会儿再试")
96
+ return _check("Ed 令牌", OK, f"有效({user.get('name') or '已登录'})")
97
+
98
+
99
+ def check_courses() -> dict:
100
+ parts = []
101
+ try:
102
+ con = db_connect()
103
+ moodle = [r["code"] or str(r["id"]) for r in con.execute("SELECT id, code FROM courses WHERE tracked=1")]
104
+ con.close()
105
+ parts.append("Moodle " + ("、".join(moodle) or "无"))
106
+ except Exception: # noqa: BLE001
107
+ moodle = []
108
+ ed = []
109
+ try:
110
+ conn = edlib.db_connect()
111
+ ed = [(r["code"] or "").split()[0] for r in conn.execute("SELECT code FROM courses WHERE tracked=1")]
112
+ conn.close()
113
+ parts.append("Ed " + ("、".join(ed) or "无"))
114
+ except Exception: # noqa: BLE001
115
+ pass
116
+ if not moodle and not ed:
117
+ return _check("跟踪的课", WARN, "还没有", "登录后运行 monash sync,或 monash courses 手动选")
118
+ return _check("跟踪的课", OK, ";".join(parts))
119
+
120
+
121
+ def check_sync() -> dict:
122
+ st = jobs.read_status()
123
+ at = jobs.last_sync_at(st)
124
+ if not at:
125
+ return _check("同步", WARN, "还没同步过", "monash sync")
126
+ hours = jobs.hours_since_sync(st)
127
+ when = datetime.fromisoformat(at).astimezone().strftime("%m-%d %H:%M")
128
+ errs = {k: v for k, v in (st.get("errors") or {}).items() if v}
129
+ detail = f"上次 {when}({hours:.0f} 小时前)" + "".join(f";{k}:{scrub(str(v))[:120]}" for k, v in errs.items())
130
+ if errs:
131
+ return _check("同步", WARN, detail, "按上面的登录项修好后 monash sync")
132
+ if hours > 48:
133
+ return _check("同步", WARN, detail, "Claude 开着时会自动同步;也可以手动 monash sync")
134
+ return _check("同步", OK, detail)
135
+
136
+
137
+ def check_claude_desktop() -> dict:
138
+ path = claude_setup.desktop_config_path()
139
+ if path is None:
140
+ return _check("Claude Desktop", WARN, "没装,或者从没打开过", "只用 Claude Code / 其他客户端的话可以忽略")
141
+ if not path.exists():
142
+ return _check("Claude Desktop", FAIL, "还没接入 monash", "monash setup claude")
143
+ try:
144
+ entry = json.loads(path.read_text(encoding="utf-8") or "{}").get("mcpServers", {}).get(claude_setup.SERVER_NAME)
145
+ except ValueError:
146
+ return _check("Claude Desktop", FAIL, f"配置文件不是合法的 JSON:{path}", "monash setup claude(会先备份)")
147
+ if not entry:
148
+ return _check("Claude Desktop", FAIL, "配置里没有 monash", "完全退出 Claude 后运行 monash setup claude")
149
+ cmd = entry.get("command", "")
150
+ if not (shutil.which(cmd) or (cmd and _exists(cmd))):
151
+ return _check("Claude Desktop", FAIL, f"配置里的程序找不到:{cmd}",
152
+ "重装过或挪过位置:完全退出 Claude 后运行 monash setup claude")
153
+ return _check("Claude Desktop", OK, "已接入 monash")
154
+
155
+
156
+ def _exists(path: str) -> bool:
157
+ from pathlib import Path
158
+ return Path(path).exists()
159
+
160
+
161
+ def check_claude_code() -> dict:
162
+ claude = shutil.which("claude")
163
+ if not claude:
164
+ return _check("Claude Code", OK, "没装(不影响 Claude Desktop)")
165
+ try:
166
+ r = subprocess.run([claude, "mcp", "get", claude_setup.SERVER_NAME], capture_output=True, text=True,
167
+ timeout=20)
168
+ except (OSError, subprocess.TimeoutExpired):
169
+ return _check("Claude Code", WARN, "查不了(claude 命令没响应)")
170
+ if r.returncode != 0:
171
+ return _check("Claude Code", WARN, "装了但没接入 monash", "monash setup claude")
172
+ return _check("Claude Code", OK, "已接入 monash")
173
+
174
+
175
+ def check_mcp_log(lines: int = 400) -> dict:
176
+ """Claude 里用的时候出的错只在 mcp.log 里,挑最近几条给人看。"""
177
+ try:
178
+ tail = LOG_FILE.read_text(encoding="utf-8", errors="replace").splitlines()[-lines:]
179
+ except OSError:
180
+ return _check("Claude 里的运行记录", OK, "还没有(Claude 还没启动过 monash)")
181
+ started = [l for l in tail if "MCP 启动" in l]
182
+ errors = [scrub(l) for l in tail if ERROR_LINE.search(l) and not l.startswith((" ", "\t"))]
183
+ last = started[-1][:16] if started else "(最近没有)"
184
+ if errors:
185
+ return _check("Claude 里的运行记录", WARN, f"最近启动 {last};最近的报错:" + " | ".join(e[:160] for e in errors[-3:]),
186
+ f"完整日志在 {LOG_FILE}")
187
+ return _check("Claude 里的运行记录", OK, f"最近启动 {last},没有报错")
188
+
189
+
190
+ def check_settings() -> dict:
191
+ changed = {k: v for k, v in load_settings().items() if DEFAULTS.get(k) != v}
192
+ files = f"课件在 {FILES_DIR}"
193
+ return _check("设置", OK, files + (";改过的设置:" + json.dumps(changed, ensure_ascii=False) if changed else ""))
194
+
195
+
196
+ CHECKS = [check_install, check_data_dir, check_browser, check_moodle, check_ed, check_courses, check_sync,
197
+ check_claude_desktop, check_claude_code, check_mcp_log, check_settings]
198
+
199
+
200
+ def run(skip: tuple = (), deep: bool = False) -> dict:
201
+ results = []
202
+ for fn in CHECKS:
203
+ if fn.__name__ in skip:
204
+ continue
205
+ try:
206
+ results.append(fn())
207
+ except Exception as e: # noqa: BLE001 体检本身不能崩
208
+ results.append(_check(fn.__name__.removeprefix("check_"), WARN, f"检查时出错:{type(e).__name__}: {scrub(str(e))}"))
209
+ if deep:
210
+ from . import doctor_deep
211
+ results += doctor_deep.run()
212
+ return {"version": __version__, "checked_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
213
+ "ok": not any(r["status"] == FAIL for r in results), "checks": results}
214
+
215
+
216
+ def render(report: dict) -> str:
217
+ out = []
218
+ for r in report["checks"]:
219
+ out.append(f"{MARK[r['status']]} {r['name']}:{r['detail']}")
220
+ if r["fix"] and r["status"] != OK:
221
+ out.append(f" → {r['fix']}")
222
+ bad = [r for r in report["checks"] if r["status"] == FAIL]
223
+ out.append("")
224
+ out.append("都没问题。" if not bad and all(r["status"] == OK for r in report["checks"])
225
+ else f"{len(bad)} 项有问题(✗),先按 → 后面的做。" if bad else "没有大问题,! 的几项可以看一下。")
226
+ out.append("需要别人帮忙时,把上面整段发过去就行(不包含密码、令牌之类的东西)。")
227
+ return "\n".join(out)
228
+