codelith 0.1.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 (129) hide show
  1. backend/__init__.py +1 -0
  2. backend/agents/__init__.py +6 -0
  3. backend/agents/assessment_agent.py +273 -0
  4. backend/agents/coding_agent.py +779 -0
  5. backend/agents/concept_categories.py +131 -0
  6. backend/agents/concept_detector.py +1217 -0
  7. backend/agents/debug_agent.py +166 -0
  8. backend/agents/teacher_agent.py +179 -0
  9. backend/cli/__init__.py +1 -0
  10. backend/cli/config_cmd.py +135 -0
  11. backend/cli/main.py +606 -0
  12. backend/daemon/__init__.py +1 -0
  13. backend/daemon/launcher.py +243 -0
  14. backend/daemon/server.py +453 -0
  15. backend/daemon/state.py +110 -0
  16. backend/daemon/static/assets/Gambarino-Regular-BjbcsURA.otf +0 -0
  17. backend/daemon/static/assets/abnfDiagram-VCTEODGH-CCJBE2aE.js +1 -0
  18. backend/daemon/static/assets/arc-BEvzHx4o.js +1 -0
  19. backend/daemon/static/assets/architecture-7GRP2DOG-DaWrPggL.js +1 -0
  20. backend/daemon/static/assets/architectureDiagram-5GKGNRK7-pR-klcZv.js +36 -0
  21. backend/daemon/static/assets/array-BifhSqXX.js +1 -0
  22. backend/daemon/static/assets/blockDiagram-I7D4REHJ-C504Gj6_.js +129 -0
  23. backend/daemon/static/assets/c4Diagram-7LVT6UL2-BjM04Mni.js +38 -0
  24. backend/daemon/static/assets/channel-DzSauwD3.js +1 -0
  25. backend/daemon/static/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  26. backend/daemon/static/assets/chunk-4HAMMTFA-EgoP78tp.js +62 -0
  27. backend/daemon/static/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
  28. backend/daemon/static/assets/chunk-75Z2AOVW-EXNbuzun.js +2 -0
  29. backend/daemon/static/assets/chunk-DU6HZSFF-CF3OK3MZ.js +127 -0
  30. backend/daemon/static/assets/chunk-F27PBJKO-G71ylWJa.js +1 -0
  31. backend/daemon/static/assets/chunk-FOHPRMQF-DHwB1DNv.js +161 -0
  32. backend/daemon/static/assets/chunk-GMAD6QVW-2yfGg28o.js +72 -0
  33. backend/daemon/static/assets/chunk-GVQU2GXP-C_VeaX4U.js +1 -0
  34. backend/daemon/static/assets/chunk-IMKFNOWR-CNexRjjn.js +231 -0
  35. backend/daemon/static/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
  36. backend/daemon/static/assets/chunk-P2QGCYS3-E4AByfsD.js +1 -0
  37. backend/daemon/static/assets/chunk-POPQ4Y6H-Bisbc2-3.js +1 -0
  38. backend/daemon/static/assets/chunk-PWAF6VOD-DaoPxZAa.js +1 -0
  39. backend/daemon/static/assets/chunk-SHT3W25Y-DarPToto.js +168 -0
  40. backend/daemon/static/assets/chunk-SVP7TREG-DvMOAiwI.js +88 -0
  41. backend/daemon/static/assets/chunk-TICWLB2K-DheuvyGM.js +206 -0
  42. backend/daemon/static/assets/chunk-XXDRQBXY-DFBUG-OT.js +1 -0
  43. backend/daemon/static/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
  44. backend/daemon/static/assets/classDiagram-ZZMXUADV-Ys5zkCXW.js +1 -0
  45. backend/daemon/static/assets/classDiagram-v2-VYDZK3BY-Ys5zkCXW.js +1 -0
  46. backend/daemon/static/assets/cose-bilkent-JH36ORCC-DLPLnxrP.js +1 -0
  47. backend/daemon/static/assets/cynefin-OW5HDTMX-Dv1OY_0y.js +1 -0
  48. backend/daemon/static/assets/cynefinDiagram-5FMLGOSQ-Ur7MTCmF.js +62 -0
  49. backend/daemon/static/assets/cytoscape.esm-CECbKnxF.js +321 -0
  50. backend/daemon/static/assets/dagre-CJLTJMFW.js +1 -0
  51. backend/daemon/static/assets/dagre-GXQ25YYZ-R3BwTvng.js +4 -0
  52. backend/daemon/static/assets/defaultLocale-BFoDCU3G.js +1 -0
  53. backend/daemon/static/assets/diagram-S7CK7UJ4-BxIoEKb4.js +30 -0
  54. backend/daemon/static/assets/diagram-UQ7AKVKN-DO4cuWN-.js +41 -0
  55. backend/daemon/static/assets/diagram-VSXAHHWV-DW5imp5t.js +3 -0
  56. backend/daemon/static/assets/diagram-VX7I27RA-CdZ3k7wQ.js +24 -0
  57. backend/daemon/static/assets/diagram-Z3DM3KII-DPyjbneL.js +24 -0
  58. backend/daemon/static/assets/dist-DTg6UBE_.js +1 -0
  59. backend/daemon/static/assets/ebnfDiagram-PWID7BFC-BO7VQsye.js +1 -0
  60. backend/daemon/static/assets/erDiagram-RLTQ6QDP-CevvjECq.js +99 -0
  61. backend/daemon/static/assets/eventmodeling-NTZA5JFV-yNfKR6-v.js +1 -0
  62. backend/daemon/static/assets/flowDiagram-HODETNUW-B4GT41mU.js +1 -0
  63. backend/daemon/static/assets/ganttDiagram-EL5Y4UJY-DNW5fWw1.js +292 -0
  64. backend/daemon/static/assets/gitGraph-4MIJSDKK-DKgVkWaZ.js +1 -0
  65. backend/daemon/static/assets/gitGraphDiagram-WWUBYQGX-0S7OF9Aj.js +106 -0
  66. backend/daemon/static/assets/index-D3vj8REa.js +63 -0
  67. backend/daemon/static/assets/index-D4lMFaiv.css +1 -0
  68. backend/daemon/static/assets/info-A6RAGUB7-Bxy-SzRN.js +1 -0
  69. backend/daemon/static/assets/infoDiagram-27XIBGKW-ClzQji6X.js +2 -0
  70. backend/daemon/static/assets/init-C-OQMol4.js +1 -0
  71. backend/daemon/static/assets/ishikawaDiagram-5VMMS53U-B3Lo-sS3.js +70 -0
  72. backend/daemon/static/assets/journeyDiagram-3NMN7TZE-0KL6R2Rz.js +139 -0
  73. backend/daemon/static/assets/kanban-definition-UXKFOSKX-zt5NbEep.js +89 -0
  74. backend/daemon/static/assets/katex-CXMH3UgJ.js +257 -0
  75. backend/daemon/static/assets/line-CiAFRJVJ.js +1 -0
  76. backend/daemon/static/assets/linear-BI6yqEPV.js +1 -0
  77. backend/daemon/static/assets/logo_darkmode-BPDdj6GZ.png +0 -0
  78. backend/daemon/static/assets/logo_lightmode-C3ZWMgAH.png +0 -0
  79. backend/daemon/static/assets/mermaid-parser.core-DEadI1Ja.js +7 -0
  80. backend/daemon/static/assets/mindmap-definition-YA3MSWOX-TGKGYg5n.js +96 -0
  81. backend/daemon/static/assets/ordinal-BDEzSJ7C.js +1 -0
  82. backend/daemon/static/assets/packet-AYTQ26CC-CZTSuh5x.js +1 -0
  83. backend/daemon/static/assets/path-fybaL0A-.js +1 -0
  84. backend/daemon/static/assets/pegDiagram-XKGWAZYB-DGd8LACA.js +1 -0
  85. backend/daemon/static/assets/pie-WAS4IAKB-B59sPr3Z.js +1 -0
  86. backend/daemon/static/assets/pieDiagram-E7YTZNPT-CpwxCR3L.js +39 -0
  87. backend/daemon/static/assets/quadrantDiagram-AXDQQJYC-BwSeF_E_.js +7 -0
  88. backend/daemon/static/assets/radar-RG4KPBEZ-DAa4JvTb.js +1 -0
  89. backend/daemon/static/assets/railroad-74A4TZTK-BitdNgDt.js +1 -0
  90. backend/daemon/static/assets/railroad-abnf-HS5TGJTU-DCrNKqAH.js +1 -0
  91. backend/daemon/static/assets/railroad-ebnf-LZEXJU2U-DmEwx8OK.js +1 -0
  92. backend/daemon/static/assets/railroad-peg-WCYAUIDC-CPc8dTCP.js +1 -0
  93. backend/daemon/static/assets/railroadDiagram-O6MQD6OU-DuizuzwD.js +1 -0
  94. backend/daemon/static/assets/requirementDiagram-BXWQKSXE-BjMk0yS8.js +84 -0
  95. backend/daemon/static/assets/rough.esm-Dy-Kn_BL.js +1 -0
  96. backend/daemon/static/assets/sankeyDiagram-P5KCCOFB-0T_bhkmz.js +40 -0
  97. backend/daemon/static/assets/sequenceDiagram-WJ2MYXX4-Cwa-1Stp.js +162 -0
  98. backend/daemon/static/assets/sizeCapture-INFHLROL-B0uUizjq.js +1 -0
  99. backend/daemon/static/assets/src-BH-TyZbA.js +1 -0
  100. backend/daemon/static/assets/stateDiagram-D77RDMKH-BpQSg_QL.js +1 -0
  101. backend/daemon/static/assets/stateDiagram-v2-MP3YSRHH-BItVXKof.js +1 -0
  102. backend/daemon/static/assets/swimlanes-42K2YHIH-h_ED18Vy.js +1 -0
  103. backend/daemon/static/assets/swimlanesDiagram-VR7AAH4N-D0fo0LN-.js +8 -0
  104. backend/daemon/static/assets/timeline-definition-24CTP7MA-DKfSO33a.js +120 -0
  105. backend/daemon/static/assets/treeView-Q6P3EWNA-DAj9fxfC.js +1 -0
  106. backend/daemon/static/assets/treemap-WGGIJYW6-5IIXD9Zu.js +1 -0
  107. backend/daemon/static/assets/vennDiagram-4TSXK5OY-BoBvVEci.js +34 -0
  108. backend/daemon/static/assets/wardley-WFR3VGLG-CGsd7s_-.js +1 -0
  109. backend/daemon/static/assets/wardleyDiagram-VM6X3IG4-QHdK5NsY.js +78 -0
  110. backend/daemon/static/assets/xychartDiagram-S5SC5T6Z-MN_fdKCJ.js +7 -0
  111. backend/daemon/static/index.html +49 -0
  112. backend/database/__init__.py +1 -0
  113. backend/database/concept_slug.py +39 -0
  114. backend/database/concepts.py +804 -0
  115. backend/llm/__init__.py +5 -0
  116. backend/llm/client.py +333 -0
  117. backend/llm/config.py +254 -0
  118. backend/llm/key_setup.py +237 -0
  119. backend/main.py +13 -0
  120. backend/orchestrator/__init__.py +1 -0
  121. backend/orchestrator/events.py +52 -0
  122. backend/orchestrator/graph.py +316 -0
  123. backend/orchestrator/modes.py +125 -0
  124. codelith-0.1.0.dist-info/METADATA +301 -0
  125. codelith-0.1.0.dist-info/RECORD +129 -0
  126. codelith-0.1.0.dist-info/WHEEL +5 -0
  127. codelith-0.1.0.dist-info/entry_points.txt +2 -0
  128. codelith-0.1.0.dist-info/licenses/LICENSE +21 -0
  129. codelith-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,5 @@
1
+ """LLM integration for the CodeLith backend (currently Groq)."""
2
+
3
+ from backend.llm.client import DEFAULT_MODEL, generate_reply
4
+
5
+ __all__ = ["DEFAULT_MODEL", "generate_reply"]
backend/llm/client.py ADDED
@@ -0,0 +1,333 @@
1
+ """LLM chat clients for the CodeLith daemon.
2
+
3
+ Two independent providers are used:
4
+
5
+ - **Groq** — the teaching-side models (teacher agent, assessment grading,
6
+ concept detection, dashboard questions). Key: ``GROQ_API_KEY``.
7
+ - **OpenRouter** — the workhorse coding models (coding agent, debug
8
+ agent). Key: ``OPENROUTER_API_KEY``. The model is chosen with
9
+ ``CODELITH_AGENT_MODEL`` (default: ``qwen/qwen3-coder-next``, a cheap
10
+ code-specialised model with a 262k context and native tool calling).
11
+
12
+ Both providers are OpenAI-compatible, so a single ``openai`` SDK client
13
+ is used with a different ``base_url`` per provider.
14
+
15
+ API keys are resolved from, in order:
16
+
17
+ 1. the environment variable (``GROQ_API_KEY`` / ``OPENROUTER_API_KEY``),
18
+ 2. a ``.env`` file in the repository root,
19
+ 3. a ``.env`` file in the daemon state directory (``~/.codelith/``).
20
+
21
+ Files are re-read on every request, so adding a key to a ``.env`` file
22
+ takes effect without restarting the daemon. Usage::
23
+
24
+ from backend.llm.client import generate_reply
25
+
26
+ reply = generate_reply("What is a closure?")
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import json
32
+ import os
33
+ from pathlib import Path
34
+ from typing import Any, Optional
35
+
36
+ from openai import OpenAI
37
+
38
+ from backend.llm.config import get_model
39
+
40
+ GROQ_API_KEY_ENV = "GROQ_API_KEY"
41
+ # Kept for backwards compatibility with existing imports; teaching-side
42
+ # model selection now goes through backend.llm.config.get_model(role).
43
+ DEFAULT_MODEL = "openai/gpt-oss-120b"
44
+ MAX_COMPLETION_TOKENS = 4096
45
+ GROQ_BASE_URL = "https://api.groq.com/openai/v1"
46
+
47
+ OPENROUTER_API_KEY_ENV = "OPENROUTER_API_KEY"
48
+ OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
49
+ # Coding/debug agent workhorse model. Qwen3-Coder-Next: code-specialised,
50
+ # native tool calling, 262k context, generous output budget. Override with
51
+ # the CODELITH_AGENT_MODEL env var, e.g. "anthropic/claude-sonnet-4.5" for
52
+ # the strongest agentic coder (paid) or any OpenRouter "...:free" model.
53
+ DEFAULT_AGENT_MODEL = "qwen/qwen3-coder-next"
54
+ AGENT_MODEL_ENV = "CODELITH_AGENT_MODEL"
55
+ # Per-round completion budget for the coding/debug agents. OpenRouter
56
+ # models (unlike Groq) expose this via the ``max_tokens`` parameter.
57
+ AGENT_MAX_TOKENS = 8192
58
+
59
+ REPO_ROOT = Path(__file__).resolve().parents[2]
60
+ ENV_FILES = (
61
+ REPO_ROOT / ".env",
62
+ Path.home() / ".codelith" / ".env",
63
+ )
64
+
65
+ # OS credential store (Windows Credential Manager, macOS Keychain,
66
+ # Secret Service). Keys land here when saved through `codelith setup`;
67
+ # env vars and .env files keep working unchanged for users who prefer
68
+ # them. keyring is an optional dependency: absence or an unlocked/
69
+ # missing backend degrades gracefully to the .env path below.
70
+ KEYRING_SERVICE = "codelith"
71
+ KEYRING_ACCOUNTS = {
72
+ GROQ_API_KEY_ENV: "groq",
73
+ OPENROUTER_API_KEY_ENV: "openrouter",
74
+ }
75
+
76
+
77
+ def _keyring_get(env_name: str) -> Optional[str]:
78
+ """Return a key from the OS credential store, or None.
79
+
80
+ Never raises: any keyring failure (module missing, no backend,
81
+ locked keychain) just means "not stored here" and resolution falls
82
+ through to the env-var/.env layers.
83
+ """
84
+ try:
85
+ import keyring
86
+ except ImportError:
87
+ return None
88
+ try:
89
+ secret = keyring.get_password(KEYRING_SERVICE, KEYRING_ACCOUNTS[env_name])
90
+ except Exception: # noqa: BLE001 - keyring backends raise many shapes
91
+ return None
92
+ return secret.strip() if secret and secret.strip() else None
93
+
94
+
95
+ def _keyring_set(env_name: str, api_key: str) -> bool:
96
+ """Store a key in the OS credential store. Returns True on success."""
97
+ try:
98
+ import keyring
99
+ except ImportError:
100
+ return False
101
+ try:
102
+ keyring.set_password(KEYRING_SERVICE, KEYRING_ACCOUNTS[env_name], api_key.strip())
103
+ return True
104
+ except Exception: # noqa: BLE001 - no backend / locked store / ACL error
105
+ return False
106
+
107
+
108
+ def store_api_key(provider: str, api_key: str) -> bool:
109
+ """Securely persist *provider*'s API key (``"groq"``/``"openrouter"``).
110
+
111
+ Public entry point for the first-run setup flow. Returns False when
112
+ no usable keyring backend exists — the caller should then fall back
113
+ to advising a .env file instead of storing the key in plaintext.
114
+ """
115
+ env_name = {v: k for k, v in KEYRING_ACCOUNTS.items()}.get(provider)
116
+ if not env_name:
117
+ raise ValueError(f"unknown provider: {provider!r} (known: {', '.join(sorted(KEYRING_ACCOUNTS.values()))})")
118
+ if not api_key.strip():
119
+ raise ValueError("API key must not be empty")
120
+ return _keyring_set(env_name, api_key)
121
+
122
+ SYSTEM_PROMPT = (
123
+ "You are CodeLith, an AI mentor that blends coding assistance with "
124
+ "adaptive teaching. The user is learning to code. Teach at their level: "
125
+ "explain concepts clearly, use concrete examples, and guide them toward "
126
+ "solutions instead of just giving the answer. Keep answers focused and "
127
+ "conversational, and ask a question now and then to check understanding."
128
+ )
129
+
130
+ GRADING_SYSTEM_PROMPT = (
131
+ "You are CodeLith, an AI mentor grading a learner's answer to a concept "
132
+ "question. Judge whether the answer shows real understanding of the "
133
+ "concept. Be fair: accept correct answers even if they are worded "
134
+ "differently from a textbook, but reject answers that are wrong or "
135
+ "miss the point. Respond ONLY with a JSON object of the form "
136
+ '{"correct": true or false, "feedback": "..."}. Keep feedback to '
137
+ "1-2 sentences: if the answer is wrong, name the key idea the learner "
138
+ "missed without giving the full answer away."
139
+ )
140
+
141
+
142
+ def _load_dotenv(path: Path) -> None:
143
+ """Load ``KEY=VALUE`` pairs from ``path`` without overriding existing vars."""
144
+ try:
145
+ lines = path.read_text(encoding="utf-8").splitlines()
146
+ except OSError:
147
+ return
148
+ for line in lines:
149
+ stripped = line.strip()
150
+ if not stripped or stripped.startswith("#") or "=" not in stripped:
151
+ continue
152
+ key, _, value = stripped.partition("=")
153
+ key = key.strip()
154
+ value = value.strip().strip('"').strip("'")
155
+ if key and key not in os.environ:
156
+ os.environ[key] = value
157
+
158
+
159
+ def resolve_api_key() -> Optional[str]:
160
+ """Return the Groq API key, or None if it is not configured anywhere.
161
+
162
+ Resolution order: env var → OS keyring → .env files. Existing
163
+ setups (env var or .env) keep working unchanged; the keyring layer
164
+ is only consulted when those are empty.
165
+ """
166
+ if os.environ.get(GROQ_API_KEY_ENV):
167
+ return os.environ[GROQ_API_KEY_ENV].strip()
168
+ stored = _keyring_get(GROQ_API_KEY_ENV)
169
+ if stored:
170
+ return stored
171
+ for path in ENV_FILES:
172
+ _load_dotenv(path)
173
+ key = os.environ.get(GROQ_API_KEY_ENV)
174
+ if key:
175
+ return key.strip()
176
+ return None
177
+
178
+
179
+ def resolve_agent_api_key() -> Optional[str]:
180
+ """Return the OpenRouter API key, or None if it is not configured anywhere.
181
+
182
+ Same resolution order as :func:`resolve_api_key`.
183
+ """
184
+ if os.environ.get(OPENROUTER_API_KEY_ENV):
185
+ return os.environ[OPENROUTER_API_KEY_ENV].strip()
186
+ stored = _keyring_get(OPENROUTER_API_KEY_ENV)
187
+ if stored:
188
+ return stored
189
+ for path in ENV_FILES:
190
+ _load_dotenv(path)
191
+ key = os.environ.get(OPENROUTER_API_KEY_ENV)
192
+ if key:
193
+ return key.strip()
194
+ return None
195
+
196
+
197
+ def resolve_agent_model() -> str:
198
+ """Return the coding-agent model slug (CODELITH_AGENT_MODEL or default)."""
199
+ return (os.environ.get(AGENT_MODEL_ENV) or DEFAULT_AGENT_MODEL).strip()
200
+
201
+
202
+ def get_client() -> OpenAI:
203
+ """Return an OpenAI-compatible client pointed at Groq."""
204
+ api_key = resolve_api_key()
205
+ if not api_key:
206
+ raise ValueError(
207
+ "No Groq API key found. Set GROQ_API_KEY "
208
+ "environment variable, or add it to a .env file."
209
+ )
210
+ return OpenAI(
211
+ base_url=GROQ_BASE_URL,
212
+ api_key=api_key,
213
+ )
214
+
215
+
216
+ def get_agent_client() -> OpenAI:
217
+ """Return an OpenAI-compatible client pointed at OpenRouter."""
218
+ api_key = resolve_agent_api_key()
219
+ if not api_key:
220
+ raise ValueError(
221
+ "No OpenRouter API key found. Set OPENROUTER_API_KEY "
222
+ "environment variable, or add it to a .env file."
223
+ )
224
+ return OpenAI(
225
+ base_url=OPENROUTER_BASE_URL,
226
+ api_key=api_key,
227
+ )
228
+
229
+
230
+ def generate_reply(
231
+ user_message: str,
232
+ model: Optional[str] = None,
233
+ history: Optional[list[dict]] = None,
234
+ ) -> str:
235
+ """Ask Groq for a reply to ``user_message`` using the CodeLith persona.
236
+
237
+ ``model`` defaults to the resolved *teaching* role model (env var >
238
+ ``config.toml`` > built-in default) when not given explicitly.
239
+
240
+ ``history`` is an optional list of prior turns (``{"role", "content"}``
241
+ dicts, oldest first) so follow-up questions keep their context.
242
+
243
+ Never raises: a missing API key and API/network failures are converted
244
+ into a readable message so the CLI keeps working without a key.
245
+ """
246
+ if model is None:
247
+ model = get_model("teaching")
248
+ api_key = resolve_api_key()
249
+ if not api_key:
250
+ return (
251
+ "I need a Groq API key to think. Set the GROQ_API_KEY "
252
+ "environment variable, or add it to a .env file in the project "
253
+ "root (see the README), then try again."
254
+ )
255
+ try:
256
+ client = get_client()
257
+ messages = [{"role": "system", "content": SYSTEM_PROMPT}]
258
+ for entry in history or []:
259
+ role = entry.get("role")
260
+ content = (entry.get("content") or "").strip()
261
+ if role in ("user", "assistant") and content:
262
+ messages.append({"role": role, "content": content})
263
+ messages.append({"role": "user", "content": user_message})
264
+ completion = client.chat.completions.create(
265
+ model=model,
266
+ messages=messages,
267
+ max_completion_tokens=MAX_COMPLETION_TOKENS,
268
+ )
269
+ except Exception as exc: # noqa: BLE001 - surface any API/network failure
270
+ return f"(I couldn't reach Groq: {exc})"
271
+ return completion.choices[0].message.content or ""
272
+
273
+
274
+ def grade_answer(
275
+ question: str,
276
+ answer: str,
277
+ concept_name: str,
278
+ concept_category: str = "",
279
+ model: Optional[str] = None,
280
+ ) -> dict[str, Any]:
281
+ """Grade a learner's answer to a concept question via the LLM.
282
+
283
+ ``model`` defaults to the resolved *grading* role model (env var >
284
+ ``config.toml`` > built-in default) when not given explicitly.
285
+
286
+ Returns ``{"correct": bool, "feedback": str}``. Never raises: any
287
+ failure (missing key, API error, unparseable output) is converted into
288
+ a conservative result — the answer is graded as not-correct with a
289
+ readable explanation, so a broken grader can never inflate progress.
290
+ """
291
+ if model is None:
292
+ model = get_model("grading")
293
+ api_key = resolve_api_key()
294
+ if not api_key:
295
+ return {
296
+ "correct": False,
297
+ "feedback": (
298
+ "(Grading needs a Groq API key — set GROQ_API_KEY or add it "
299
+ "to a .env file, then submit again.)"
300
+ ),
301
+ }
302
+
303
+ category_note = f" (category: {concept_category})" if concept_category else ""
304
+ user_prompt = (
305
+ f"Concept: {concept_name}{category_note}\n"
306
+ f"Question: {question}\n\n"
307
+ f"Learner's answer: {answer}\n\n"
308
+ "Grade the answer. Respond ONLY with the JSON object."
309
+ )
310
+ try:
311
+ client = get_client()
312
+ completion = client.chat.completions.create(
313
+ model=model,
314
+ messages=[
315
+ {"role": "system", "content": GRADING_SYSTEM_PROMPT},
316
+ {"role": "user", "content": user_prompt},
317
+ ],
318
+ max_completion_tokens=512,
319
+ )
320
+ raw = (completion.choices[0].message.content or "").strip()
321
+ # Tolerate code fences or prose around the JSON object.
322
+ start, end = raw.find("{"), raw.rfind("}")
323
+ if start == -1 or end <= start:
324
+ raise ValueError(f"no JSON object in grader output: {raw[:200]}")
325
+ parsed = json.loads(raw[start : end + 1])
326
+ correct = bool(parsed.get("correct", False))
327
+ feedback = str(parsed.get("feedback", "")).strip()
328
+ return {"correct": correct, "feedback": feedback or ("Correct!" if correct else "Not quite — try again.")}
329
+ except Exception as exc: # noqa: BLE001 - conservative failure
330
+ return {
331
+ "correct": False,
332
+ "feedback": f"(Grading failed: {exc}. Your answer was not recorded as correct — please submit again.)",
333
+ }
backend/llm/config.py ADDED
@@ -0,0 +1,254 @@
1
+ """Model-role resolution for CodeLith.
2
+
3
+ CodeLith's AI work is split into logical **roles** (coding, debugging,
4
+ teaching, ...). Each role maps to an LLM model slug; every role
5
+ resolves independently through the same three-layer chain:
6
+
7
+ 1. the role's environment variable (``CODELITH_MODEL_CODING`` ...),
8
+ 2. ``~/.codelith/config.toml`` under ``[models]``,
9
+ 3. the built-in default for that role.
10
+
11
+ The first layer that is set wins, so a shell env var can override the
12
+ config file for one command (CI, scripts), while the config file is the
13
+ durable per-machine override.
14
+
15
+ **No file is ever created automatically.** A user who never customizes
16
+ models sees zero config behavior: all roles resolve to the built-in
17
+ defaults and nothing appears in ``~/.codelith/``. ``config.toml`` comes
18
+ into existence only when the user runs ``codelith config set`` (or
19
+ hand-writes the file).
20
+
21
+ Roles are logical, not physical: nothing stops two roles from using the
22
+ same model, and multiple roles currently share the built-in defaults.
23
+
24
+ Usage::
25
+
26
+ from backend.llm.config import get_model
27
+
28
+ model = get_model("grading")
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import os
34
+ import sys
35
+ from pathlib import Path
36
+
37
+ if sys.version_info >= (3, 11):
38
+ import tomllib
39
+ else: # pragma: no cover - depends on the interpreter running the tests
40
+ try:
41
+ import tomli as tomllib # type: ignore[no-redef]
42
+ except ImportError: # pragma: no cover - degraded mode, see below
43
+ tomllib = None # type: ignore[assignment]
44
+
45
+ from backend.daemon.state import state_dir
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # Role registry
49
+ # ---------------------------------------------------------------------------
50
+
51
+ #: Every logical model role, with its built-in default and env-var name.
52
+ #: Defaults are the models CodeLith ships with today; a role with no
53
+ #: override resolves to exactly what it used before this module existed.
54
+ MODEL_ROLES: dict[str, dict[str, str]] = {
55
+ "coding": {
56
+ "default": "qwen/qwen3-coder-next",
57
+ "env": "CODELITH_MODEL_CODING",
58
+ },
59
+ "debugging": {
60
+ "default": "qwen/qwen3-coder-next",
61
+ "env": "CODELITH_MODEL_DEBUGGING",
62
+ },
63
+ "teaching": {
64
+ "default": "openai/gpt-oss-120b",
65
+ "env": "CODELITH_MODEL_TEACHING",
66
+ },
67
+ "assessment": {
68
+ "default": "openai/gpt-oss-120b",
69
+ "env": "CODELITH_MODEL_ASSESSMENT",
70
+ },
71
+ "grading": {
72
+ "default": "openai/gpt-oss-120b",
73
+ "env": "CODELITH_MODEL_GRADING",
74
+ },
75
+ "detection": {
76
+ "default": "openai/gpt-oss-120b",
77
+ "env": "CODELITH_MODEL_DETECTION",
78
+ },
79
+ }
80
+
81
+ CONFIG_FILE_NAME = "config.toml"
82
+
83
+ # mtime cache: the TOML file is parsed at most once per modification.
84
+ # Keeps the "edit the file, no daemon restart" behavior of the .env
85
+ # loader while avoiding a disk hit + reparse on every LLM call.
86
+ _cache: dict[str, object] = {"mtime": None, "models": {}}
87
+
88
+
89
+ def config_path() -> Path:
90
+ """Return the config file path (``~/.codelith/config.toml``)."""
91
+ return state_dir() / CONFIG_FILE_NAME
92
+
93
+
94
+ def _parse_toml(text: str) -> dict:
95
+ """Parse *text* as TOML, or return {} when no parser is available."""
96
+ if tomllib is None: # pragma: no cover - Python < 3.11 without tomli
97
+ return {}
98
+ return tomllib.loads(text)
99
+
100
+
101
+ def _read_models_table(path: Path) -> dict[str, str]:
102
+ """Return the ``[models]`` table from *path* as ``role -> slug``.
103
+
104
+ Returns {} when the file is missing, unreadable, unparsable, or has
105
+ no ``[models]`` table — a broken config file degrades to built-in
106
+ defaults rather than breaking every LLM call.
107
+ """
108
+ try:
109
+ data = _parse_toml(path.read_text(encoding="utf-8"))
110
+ except (OSError, ValueError): # missing file / TOML syntax error
111
+ return {}
112
+ models = data.get("models")
113
+ if not isinstance(models, dict):
114
+ return {}
115
+ return {
116
+ str(role): str(slug).strip()
117
+ for role, slug in models.items()
118
+ if str(slug).strip()
119
+ }
120
+
121
+
122
+ def _file_models() -> dict[str, str]:
123
+ """Return the parsed ``[models]`` table, re-reading only when the file changed."""
124
+ path = config_path()
125
+ try:
126
+ mtime = path.stat().st_mtime_ns
127
+ except OSError:
128
+ # No file (the common case) — reset the cache so a file created
129
+ # later is picked up instead of being shadowed by a stale table.
130
+ _cache["mtime"] = None
131
+ _cache["models"] = {}
132
+ return {}
133
+ if _cache["mtime"] != mtime:
134
+ _cache["models"] = _read_models_table(path)
135
+ _cache["mtime"] = mtime
136
+ models: dict[str, str] = _cache["models"] # type: ignore[assignment]
137
+ return models
138
+
139
+
140
+ def get_model(role: str) -> str:
141
+ """Return the model slug for *role*.
142
+
143
+ Resolution order: role env var → ``config.toml`` ``[models]`` →
144
+ built-in default. Unknown roles raise ``KeyError`` — a typo'd role
145
+ is a programming error, not a runtime condition.
146
+ """
147
+ entry = MODEL_ROLES.get(role)
148
+ if entry is None:
149
+ raise KeyError(f"unknown model role: {role!r} (known: {', '.join(sorted(MODEL_ROLES))})")
150
+
151
+ env_value = (os.environ.get(entry["env"]) or "").strip()
152
+ if env_value:
153
+ return env_value
154
+
155
+ file_value = _file_models().get(role, "").strip()
156
+ if file_value:
157
+ return file_value
158
+
159
+ return entry["default"]
160
+
161
+
162
+ # ---------------------------------------------------------------------------
163
+ # Explicit writes — the only way config.toml comes into existence
164
+ # ---------------------------------------------------------------------------
165
+
166
+
167
+ def set_model(role: str, slug: str) -> None:
168
+ """Persist *role* → *slug* in ``config.toml``, creating the file.
169
+
170
+ Preserves any other TOML content conservatively: when the existing
171
+ file parses, the ``[models]`` table is updated in place and the rest
172
+ of the document is re-emitted unchanged. When it does not parse,
173
+ the write is refused rather than destroying the user's file.
174
+
175
+ Unknown roles are rejected — ``codelith config set`` is the only
176
+ writer, and it validates against :data:`MODEL_ROLES` first.
177
+ """
178
+ if role not in MODEL_ROLES:
179
+ raise KeyError(f"unknown model role: {role!r} (known: {', '.join(sorted(MODEL_ROLES))})")
180
+ slug = slug.strip()
181
+ if not slug:
182
+ raise ValueError("model slug must not be empty")
183
+
184
+ path = config_path()
185
+ existing_text = ""
186
+ try:
187
+ existing_text = path.read_text(encoding="utf-8")
188
+ except OSError:
189
+ pass # new file
190
+
191
+ if existing_text.strip():
192
+ try:
193
+ data = _parse_toml(existing_text)
194
+ except ValueError as exc:
195
+ raise ValueError(
196
+ f"{path} is not valid TOML ({exc}); fix or remove it before running "
197
+ "`codelith config set`."
198
+ ) from exc
199
+ data.setdefault("models", {})
200
+ data["models"][role] = slug
201
+ body = _dump_toml(data)
202
+ else:
203
+ body = _dump_toml({"models": {role: slug}})
204
+
205
+ path.parent.mkdir(parents=True, exist_ok=True)
206
+ path.write_text(body, encoding="utf-8")
207
+ _cache["mtime"] = None # force re-read on next resolve
208
+
209
+
210
+ def unset_model(role: str) -> bool:
211
+ """Remove *role* from ``config.toml``. Returns True if it was present.
212
+
213
+ The file itself is kept (other settings may live there); an empty
214
+ ``[models]`` table is left behind rather than deleting the file,
215
+ which keeps hand-edited comments and unrelated sections intact.
216
+ """
217
+ if role not in MODEL_ROLES:
218
+ raise KeyError(f"unknown model role: {role!r} (known: {', '.join(sorted(MODEL_ROLES))})")
219
+
220
+ path = config_path()
221
+ try:
222
+ data = _parse_toml(path.read_text(encoding="utf-8"))
223
+ except (OSError, ValueError):
224
+ return False
225
+
226
+ models = data.get("models")
227
+ if not isinstance(models, dict) or role not in models:
228
+ return False
229
+
230
+ del models[role]
231
+ path.write_text(_dump_toml(data), encoding="utf-8")
232
+ _cache["mtime"] = None
233
+ return True
234
+
235
+
236
+ def _dump_toml(data: dict) -> str:
237
+ """Serialize *data* to a readable TOML string (tables one key deep)."""
238
+ lines: list[str] = []
239
+ for key, value in data.items():
240
+ if isinstance(value, dict):
241
+ lines.append(f"[{key}]")
242
+ for sub_key, sub_value in value.items():
243
+ lines.append(f"{sub_key} = {_toml_string(str(sub_value))}")
244
+ lines.append("")
245
+ else:
246
+ lines.append(f"{key} = {_toml_string(str(value))}")
247
+ lines.append("")
248
+ return "\n".join(lines).rstrip() + "\n"
249
+
250
+
251
+ def _toml_string(value: str) -> str:
252
+ """Quote *value* as a basic TOML string."""
253
+ escaped = value.replace("\\", "\\\\").replace('"', '\\"')
254
+ return f'"{escaped}"'