openstem-mcp 0.3.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.
Files changed (95) hide show
  1. openstem/__init__.py +3 -0
  2. openstem/lms/__init__.py +8 -0
  3. openstem/lms/canvas.py +309 -0
  4. openstem/lms/client.py +625 -0
  5. openstem/lms/common.py +141 -0
  6. openstem/lms/feed.py +108 -0
  7. openstem/lms/ical.py +270 -0
  8. openstem/lms/moodle.py +259 -0
  9. openstem/lms/schools.py +258 -0
  10. openstem/mathexpr.py +416 -0
  11. openstem/output.py +58 -0
  12. openstem/prompts/cheatsheet.md +13 -0
  13. openstem/prompts/exam.md +26 -0
  14. openstem/prompts/figure.md +16 -0
  15. openstem/prompts/flashcards.md +16 -0
  16. openstem/prompts/lab_report.md +21 -0
  17. openstem/prompts/slides.md +18 -0
  18. openstem/prompts/solve.md +19 -0
  19. openstem/remote.py +512 -0
  20. openstem/render/__init__.py +0 -0
  21. openstem/render/convert.py +88 -0
  22. openstem/render/exam.lua +67 -0
  23. openstem/render/flashcards.lua +103 -0
  24. openstem/render/fonts.py +58 -0
  25. openstem/render/latex.py +352 -0
  26. openstem/render/pages.py +77 -0
  27. openstem/render/tabular.lua +39 -0
  28. openstem/render/tectonic_setup.py +366 -0
  29. openstem/sandbox.py +203 -0
  30. openstem/server.py +493 -0
  31. openstem/templates/circuits/half_wave_rectifier.tex +13 -0
  32. openstem/templates/circuits/opamp_inverting.tex +13 -0
  33. openstem/templates/circuits/opamp_noninverting.tex +12 -0
  34. openstem/templates/circuits/parallel_resistors.tex +13 -0
  35. openstem/templates/circuits/rc_series.tex +11 -0
  36. openstem/templates/circuits/rl_series.tex +11 -0
  37. openstem/templates/circuits/rlc_series_ac.tex +11 -0
  38. openstem/templates/circuits/two_loop_kirchhoff.tex +13 -0
  39. openstem/templates/circuits/voltage_divider.tex +14 -0
  40. openstem/templates/circuits/wheatstone_bridge.tex +19 -0
  41. openstem/templates/diagrams/concept_map.tex +27 -0
  42. openstem/templates/diagrams/feedback_control.tex +39 -0
  43. openstem/templates/diagrams/flowchart_decision.tex +36 -0
  44. openstem/templates/diagrams/hierarchy_tree.tex +26 -0
  45. openstem/templates/diagrams/neural_network.tex +26 -0
  46. openstem/templates/diagrams/pipeline_stages.tex +28 -0
  47. openstem/templates/diagrams/signal_chain.tex +38 -0
  48. openstem/templates/diagrams/state_machine.tex +26 -0
  49. openstem/templates/diagrams/timeline.tex +26 -0
  50. openstem/templates/diagrams/two_panel.tex +33 -0
  51. openstem/templates/math/definite_integral_area.tex +24 -0
  52. openstem/templates/math/function_graph.tex +26 -0
  53. openstem/templates/math/inscribed_angle.tex +28 -0
  54. openstem/templates/math/riemann_sum.tex +27 -0
  55. openstem/templates/math/right_triangle_trig.tex +23 -0
  56. openstem/templates/math/tangent_line.tex +25 -0
  57. openstem/templates/math/triangle_labeled.tex +20 -0
  58. openstem/templates/math/unit_circle.tex +27 -0
  59. openstem/templates/math/vectors_3d.tex +25 -0
  60. openstem/templates/math/venn_diagram.tex +22 -0
  61. openstem/templates/physics/atwood_machine.tex +37 -0
  62. openstem/templates/physics/hanging_two_ropes.tex +33 -0
  63. openstem/templates/physics/horizontal_pull_fbd.tex +31 -0
  64. openstem/templates/physics/incline_fbd.tex +36 -0
  65. openstem/templates/physics/projectile_motion.tex +36 -0
  66. openstem/templates/physics/simple_pendulum.tex +32 -0
  67. openstem/templates/physics/spring_mass.tex +28 -0
  68. openstem/templates/physics/uniform_circular_motion.tex +22 -0
  69. openstem/templates/physics/vector_addition.tex +22 -0
  70. openstem/templates/physics/vector_components.tex +25 -0
  71. openstem/texcheck.py +179 -0
  72. openstem/tools/__init__.py +24 -0
  73. openstem/tools/documents.py +174 -0
  74. openstem/tools/export.py +679 -0
  75. openstem/tools/lms.py +482 -0
  76. openstem/tools/molecule.py +79 -0
  77. openstem/tools/plot.py +149 -0
  78. openstem/tools/plotdata.py +179 -0
  79. openstem/tools/templates.py +119 -0
  80. openstem/tools/tikz.py +289 -0
  81. openstem/tools/units.py +112 -0
  82. openstem/tools/verify.py +276 -0
  83. openstem/workers/__init__.py +49 -0
  84. openstem/workers/convert.py +62 -0
  85. openstem/workers/documents.py +210 -0
  86. openstem/workers/molecule.py +338 -0
  87. openstem/workers/plot.py +580 -0
  88. openstem/workers/plotdata.py +635 -0
  89. openstem/workers/units.py +558 -0
  90. openstem/workers/verify.py +1489 -0
  91. openstem_mcp-0.3.0.dist-info/METADATA +240 -0
  92. openstem_mcp-0.3.0.dist-info/RECORD +95 -0
  93. openstem_mcp-0.3.0.dist-info/WHEEL +4 -0
  94. openstem_mcp-0.3.0.dist-info/entry_points.txt +2 -0
  95. openstem_mcp-0.3.0.dist-info/licenses/LICENSE +21 -0
openstem/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """openstem-mcp: 이공계 학습용 MCP 서버."""
2
+
3
+ __version__ = "0.3.0"
@@ -0,0 +1,8 @@
1
+ """학교 LMS(Canvas 계열·Moodle 계열) 읽기 전용 연결. 툴 쪽 입구는 openstem.tools.lms.
2
+
3
+ - client: 설정 읽기(학교 이름·주소·토큰·캘린더 주소), 토큰을 설정한 주소에만 싣는 HTTP(크기·시간 상한)
4
+ - canvas / moodle: 각 LMS의 API를 같은 모양의 dict 목록으로 바꾸는 어댑터
5
+ - schools: 조사한 학교별 LMS 목록(주소·플랫폼·포털 → API 주소)
6
+ - feed / ical: 토큰 없이 캘린더 구독 주소(.ics)로 마감 읽기
7
+ - common: HTML→글자, 시각 표기, 파일 이름 정리
8
+ """
openstem/lms/canvas.py ADDED
@@ -0,0 +1,309 @@
1
+ """Canvas 계열 LMS(REST API v1, Bearer 토큰) 어댑터. 읽기(GET)만 한다.
2
+
3
+ 에러 글은 학교마다 한국어로 바뀌어 있어("인증되지 않음", "사용자 인증 필요", "권한이 없음") 글자로 판단하지 않고
4
+ 상태 코드·JSON 모양·WWW-Authenticate 헤더로 판단한다. 401은 두 가지다: 토큰이 틀렸거나 만료됨(Canvas는
5
+ WWW-Authenticate를 붙인다) / 토큰은 맞는데 그 과목·메뉴에 권한이 없음. 헤더가 없으면 /api/v1/users/self로
6
+ 토큰이 살아 있는지 한 번 확인해 가른다.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import time
12
+ from datetime import datetime, timezone
13
+ from typing import Any
14
+ from urllib.parse import urljoin
15
+
16
+ from openstem.lms.client import Http, LmsError, Reply, how_to_connect
17
+ from openstem.lms.common import parse_time
18
+
19
+ KIND = "canvas"
20
+ MODULE_FILE_LOOKUPS = 200 # 모듈 대체 경로에서 파일 정보를 하나씩 물어보는 최대 개수
21
+ MODULE_LOOKUP_BUDGET = 25.0 # 그 조회에 쓰는 시간(초). 넘으면 이름만 돌려준다
22
+
23
+
24
+ def _zulu(dt: datetime) -> str:
25
+ return dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
26
+
27
+
28
+ def _message(reply: Reply) -> str:
29
+ """에러 본문의 첫 메시지(학교 언어 그대로, 판단에는 쓰지 않고 보여 주기만 한다)."""
30
+ data = reply.data
31
+ if isinstance(data, dict):
32
+ errors = data.get("errors")
33
+ if isinstance(errors, list) and errors and isinstance(errors[0], dict):
34
+ return str(errors[0].get("message") or "")[:200]
35
+ if isinstance(errors, dict):
36
+ return str(next(iter(errors.values()), ""))[:200]
37
+ return str(data.get("message") or data.get("status") or "")[:200]
38
+ return ""
39
+
40
+
41
+ def canvas_error_body(reply: Reply) -> bool:
42
+ """Canvas 에러 JSON 모양: {"errors": [...]} 또는 {"errors": {...}}."""
43
+ return isinstance(reply.data, dict) and isinstance(reply.data.get("errors"), (list, dict))
44
+
45
+
46
+ _CANVAS_HEADERS = ("x-canvas-meta", "x-request-context-id", "x-rate-limit-remaining")
47
+
48
+
49
+ def _canvas_shaped(reply: Reply) -> bool:
50
+ """Canvas 응답 모양: 사용자(dict, id), 과목 목록(course_code가 있거나 Canvas 헤더), 에러 JSON(4xx)."""
51
+ data = reply.data
52
+ if reply.status == 200:
53
+ if isinstance(data, dict):
54
+ return "id" in data
55
+ if isinstance(data, list):
56
+ courses = bool(data) and all(isinstance(c, dict) and "course_code" in c for c in data)
57
+ return courses or any(reply.header(h) for h in _CANVAS_HEADERS)
58
+ return False
59
+ return 400 <= reply.status < 500 and canvas_error_body(reply)
60
+
61
+
62
+ def detect(http: Http) -> bool:
63
+ """Canvas면 True. 토큰이 틀려도 Canvas 에러 JSON(401·404)이면 True.
64
+
65
+ LearningX는 인증 안 된 /api/v1/users/self에 401이 아니라 404 JSON을 준다. 그 응답도 Canvas 모양이면 받고,
66
+ 애매하면 /api/v1/courses로 한 번 더 본다.
67
+ """
68
+ reply = http.request_json("GET", http.url("/api/v1/users/self"))
69
+ if _canvas_shaped(reply):
70
+ return True
71
+ reply = http.request_json("GET", http.url("/api/v1/courses"), params=[("per_page", "1")])
72
+ return _canvas_shaped(reply)
73
+
74
+
75
+ class Canvas:
76
+ kind = KIND
77
+
78
+ def __init__(self, http: Http) -> None:
79
+ self.http = http
80
+ self.notes: list[str] = []
81
+ self._token_ok: bool | None = None
82
+
83
+ # ── 401 가르기 ──
84
+
85
+ def token_works(self) -> bool:
86
+ """토큰이 살아 있는지(/api/v1/users/self가 200). 호출 한 번에 한 번만 묻는다."""
87
+ if self._token_ok is None:
88
+ reply = self.http.request_json("GET", self.http.url("/api/v1/users/self"))
89
+ self._token_ok = reply.status == 200 and isinstance(reply.data, dict) and "id" in reply.data
90
+ return self._token_ok
91
+
92
+ def token_rejected(self, reply: Reply) -> bool:
93
+ """401이 "토큰이 틀림·만료"인지(권한 없음이 아니라). 글자가 아니라 헤더와 토큰 확인으로 가른다."""
94
+ if reply.status != 401:
95
+ return False
96
+ if reply.header("www-authenticate"):
97
+ return True
98
+ return not self.token_works()
99
+
100
+ def _fail(self, reply: Reply, what: str) -> LmsError:
101
+ config = self.http.config
102
+ if self.token_rejected(reply):
103
+ return LmsError("The LMS rejected the token (invalid, expired or deleted).",
104
+ how_to_connect(config.school, KIND), 401, "invalidtoken")
105
+ if reply.status in (401, 403):
106
+ return LmsError(f"No permission to read {what} in the LMS (the token works; this part is closed).",
107
+ status=reply.status)
108
+ if reply.status == 404:
109
+ return LmsError(f"The LMS has no {what} here (HTTP 404).", status=404)
110
+ detail = _message(reply)
111
+ return LmsError(f"The LMS failed to return {what} (HTTP {reply.status}){': ' + detail if detail else ''}.",
112
+ status=reply.status)
113
+
114
+ def _soft(self, reply: Reply) -> bool:
115
+ """대체 경로로 넘어가도 되는 실패(권한 없음·메뉴 숨김·없는 API). 잘못된 토큰은 아니다."""
116
+ return reply.status in (401, 403, 404) and not self.token_rejected(reply)
117
+
118
+ def _get(self, path: str, what: str, params: list[tuple[str, str]] | None = None) -> Any:
119
+ reply = self.http.request_json("GET", self.http.url(path), params=params)
120
+ if reply.status != 200:
121
+ raise self._fail(reply, what)
122
+ return reply.data
123
+
124
+ def _list(self, path: str, what: str, params: list[tuple[str, str]], max_pages: int = 10) -> list[Any]:
125
+ items, reply = self.http.get_pages(path, params, max_pages)
126
+ if reply.status != 200:
127
+ raise self._fail(reply, what)
128
+ return [it for it in items if isinstance(it, dict)]
129
+
130
+ # ── 과목 ──
131
+
132
+ def courses(self) -> list[dict[str, Any]]:
133
+ params = [("enrollment_state", "active"), ("per_page", "50"), ("include[]", "term")]
134
+ out = []
135
+ for c in self._list("/api/v1/courses", "courses", params, max_pages=5):
136
+ if c.get("id") is None or not c.get("name") or c.get("access_restricted_by_date"):
137
+ continue
138
+ term = c.get("term") if isinstance(c.get("term"), dict) else {}
139
+ out.append({"id": c["id"], "name": str(c["name"]), "code": c.get("course_code"), "term": term.get("name")})
140
+ return out
141
+
142
+ # ── 마감 ──
143
+
144
+ def deadlines(self, start: datetime, end: datetime, course_id: Any = None) -> list[dict[str, Any]]:
145
+ params = [("start_date", _zulu(start)), ("end_date", _zulu(end)), ("per_page", "100")]
146
+ if course_id is not None:
147
+ params.append(("context_codes[]", f"course_{course_id}"))
148
+ items, reply = self.http.get_pages("/api/v1/planner/items", params, max_pages=3)
149
+ if reply.status == 200:
150
+ return [d for it in items if isinstance(it, dict) and (d := self._planner_item(it))]
151
+ if not self._soft(reply):
152
+ raise self._fail(reply, "deadlines")
153
+ # 플래너가 꺼진 학교: 다가오는 일정(기간 지정 없음, 대개 1~2주)
154
+ events = self._list("/api/v1/users/self/upcoming_events", "upcoming events", [], max_pages=1)
155
+ return [d for ev in events if (d := self._event_item(ev))]
156
+
157
+ def _abs_url(self, raw: Any) -> str | None:
158
+ if not isinstance(raw, str) or not raw:
159
+ return None
160
+ url = urljoin(self.http.config.base + "/", raw)
161
+ return url if self.http.same_origin(url) else None
162
+
163
+ def _planner_item(self, it: dict[str, Any]) -> dict[str, Any] | None:
164
+ kind = it.get("plannable_type")
165
+ if kind in ("announcement", "attendance"):
166
+ return None
167
+ p = it.get("plannable") if isinstance(it.get("plannable"), dict) else {}
168
+ due = parse_time(p.get("due_at") or p.get("todo_date") or it.get("plannable_date"))
169
+ if not due:
170
+ return None
171
+ subs = it.get("submissions")
172
+ submitted = bool(subs.get("submitted") or subs.get("excused")) if isinstance(subs, dict) else None
173
+ return {
174
+ "title": str(p.get("title") or p.get("name") or "(untitled)"),
175
+ "course": it.get("context_name"),
176
+ "course_id": it.get("course_id"),
177
+ "type": {"planner_note": "note", "discussion_topic": "discussion"}.get(kind, kind),
178
+ "due": due,
179
+ "submitted": submitted,
180
+ "url": self._abs_url(it.get("html_url")),
181
+ }
182
+
183
+ def _event_item(self, ev: dict[str, Any]) -> dict[str, Any] | None:
184
+ assignment = ev.get("assignment") if isinstance(ev.get("assignment"), dict) else {}
185
+ due = parse_time(assignment.get("due_at") or ev.get("start_at"))
186
+ if not due:
187
+ return None
188
+ code = str(ev.get("context_code") or "")
189
+ course_id = assignment.get("course_id") or (code[len("course_"):] if code.startswith("course_") else None)
190
+ submitted = assignment.get("has_submitted_submissions") if assignment else None
191
+ return {
192
+ "title": str(ev.get("title") or assignment.get("name") or "(untitled)"),
193
+ "course": ev.get("context_name"),
194
+ "course_id": course_id,
195
+ "type": "assignment" if assignment else str(ev.get("type") or "event"),
196
+ "due": due,
197
+ "submitted": submitted if isinstance(submitted, bool) else None,
198
+ "url": self._abs_url(ev.get("html_url")),
199
+ }
200
+
201
+ # ── 공지 ──
202
+
203
+ def announcements(self, courses: list[dict[str, Any]], start: datetime, end: datetime) -> list[dict[str, Any]]:
204
+ names = {f"course_{c['id']}": c["name"] for c in courses}
205
+ out: list[dict[str, Any]] = []
206
+ codes = list(names)
207
+ for i in range(0, len(codes), 20): # 주소가 너무 길어지지 않게 20과목씩
208
+ params = [("context_codes[]", code) for code in codes[i : i + 20]]
209
+ params += [("start_date", _zulu(start)), ("end_date", _zulu(end)), ("per_page", "50")]
210
+ for it in self._list("/api/v1/announcements", "announcements", params, max_pages=2):
211
+ author = it.get("author") if isinstance(it.get("author"), dict) else {}
212
+ out.append({
213
+ "course": names.get(str(it.get("context_code"))),
214
+ "title": str(it.get("title") or "(untitled)"),
215
+ "posted": parse_time(it.get("posted_at") or it.get("created_at")),
216
+ "author": author.get("display_name") or it.get("user_name"),
217
+ "html": it.get("message"),
218
+ "url": self._abs_url(it.get("html_url")),
219
+ })
220
+ return out
221
+
222
+ # ── 파일 ──
223
+
224
+ def files(self, course_id: Any) -> tuple[list[dict[str, Any]], str]:
225
+ """(파일 목록, 출처 "files" | "modules"). 파일 메뉴를 숨긴 과목은 모듈에 걸린 파일로 대신한다."""
226
+ items, reply = self.http.get_pages(f"/api/v1/courses/{course_id}/files", [("per_page", "100")], 10)
227
+ if reply.status == 200:
228
+ folders = self._folders(course_id)
229
+ return [self._file(it, folders.get(it.get("folder_id"))) for it in items if isinstance(it, dict)], "files"
230
+ if not self._soft(reply):
231
+ raise self._fail(reply, "course files")
232
+ return self._files_from_modules(course_id), "modules"
233
+
234
+ def _folders(self, course_id: Any) -> dict[Any, str | None]:
235
+ items, reply = self.http.get_pages(f"/api/v1/courses/{course_id}/folders", [("per_page", "100")], 3)
236
+ if reply.status != 200:
237
+ return {}
238
+ out: dict[Any, str | None] = {}
239
+ for f in items:
240
+ if isinstance(f, dict) and f.get("id") is not None:
241
+ name = str(f.get("full_name") or f.get("name") or "")
242
+ if name.startswith("course files"):
243
+ name = name[len("course files") :].lstrip("/")
244
+ out[f["id"]] = name or None
245
+ return out
246
+
247
+ @staticmethod
248
+ def _file(it: dict[str, Any], folder: str | None) -> dict[str, Any]:
249
+ return {
250
+ "id": it.get("id"),
251
+ "name": str(it.get("display_name") or it.get("filename") or ""),
252
+ "size": it.get("size") if isinstance(it.get("size"), int) else None,
253
+ "updated": parse_time(it.get("updated_at") or it.get("modified_at") or it.get("created_at")),
254
+ "folder": folder,
255
+ "locked": bool(it.get("locked_for_user") or it.get("hidden_for_user")),
256
+ }
257
+
258
+ def _files_from_modules(self, course_id: Any) -> list[dict[str, Any]]:
259
+ params = [("include[]", "items"), ("per_page", "50")]
260
+ found: dict[Any, tuple[str, str | None]] = {}
261
+ modules, reply = self.http.get_pages(f"/api/v1/courses/{course_id}/modules", params, 5)
262
+ if reply.status != 200:
263
+ if self._soft(reply):
264
+ return [] # 파일 메뉴도 모듈도 안 보이는 과목
265
+ raise self._fail(reply, "course modules")
266
+ for module in (m for m in modules if isinstance(m, dict)):
267
+ items = module.get("items")
268
+ if not isinstance(items, list) and isinstance(module.get("id"), int):
269
+ items, reply = self.http.get_pages(
270
+ f"/api/v1/courses/{course_id}/modules/{module['id']}/items", [("per_page", "100")], 3
271
+ )
272
+ if reply.status != 200:
273
+ items = []
274
+ for item in items or []:
275
+ if isinstance(item, dict) and item.get("type") == "File" and isinstance(item.get("content_id"), int):
276
+ found.setdefault(item["content_id"], (str(item.get("title") or ""), module.get("name")))
277
+ out = []
278
+ deadline = time.monotonic() + MODULE_LOOKUP_BUDGET
279
+ for n, (file_id, (title, module_name)) in enumerate(found.items()):
280
+ info = None
281
+ if n < MODULE_FILE_LOOKUPS and time.monotonic() < deadline:
282
+ reply = self.http.request_json("GET", self.http.url(f"/api/v1/files/{file_id}"))
283
+ if reply.status == 200 and isinstance(reply.data, dict):
284
+ info = self._file(reply.data, module_name)
285
+ elif self.token_rejected(reply):
286
+ raise self._fail(reply, "files")
287
+ out.append(info or {"id": file_id, "name": title, "size": None, "updated": None,
288
+ "folder": module_name, "locked": False})
289
+ return out
290
+
291
+ def file_for_download(self, course_id: Any, file_id: str) -> dict[str, Any]:
292
+ reply = self.http.request_json("GET", self.http.url(f"/api/v1/courses/{course_id}/files/{file_id}"))
293
+ if reply.status in (401, 403) and not self.token_rejected(reply):
294
+ # 파일 메뉴를 숨긴 과목: 모듈에 걸린 파일은 파일 id로 직접 읽을 수 있다(404면 이 과목 파일이 아니다)
295
+ reply = self.http.request_json("GET", self.http.url(f"/api/v1/files/{file_id}"))
296
+ if reply.status != 200 or not isinstance(reply.data, dict):
297
+ if reply.status == 404:
298
+ raise LmsError(f"No file with id {file_id} in this course.",
299
+ 'Call action="files" for this course and use an id from that list.', 404)
300
+ raise self._fail(reply, "this file")
301
+ info = reply.data
302
+ if info.get("locked_for_user") or not info.get("url"):
303
+ raise LmsError("This file is locked in the LMS (not available yet, or the teacher hid it).")
304
+ return {
305
+ "name": str(info.get("display_name") or info.get("filename") or f"file-{file_id}"),
306
+ "size": info.get("size") if isinstance(info.get("size"), int) else None,
307
+ "url": urljoin(self.http.config.base + "/", str(info["url"])),
308
+ "auth": True, # 같은 origin일 때만 Bearer 헤더(Http.download가 판단)
309
+ }