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,1217 @@
1
+ """Concept detection — shared engine used by the assessment and teacher agents.
2
+
3
+ This module owns the full detection pipeline:
4
+
5
+ 1. **Registry scan** — file contents written/edited by the coding agent are
6
+ matched against :data:`CONCEPT_PATTERNS`, a curated registry of known
7
+ patterns, each with a name, category, description, and (usually) a Mermaid
8
+ diagram.
9
+ 2. **LLM detection** — for code the registry does not recognise, the LLM
10
+ identifies additional concepts (mode-gated: skipped when
11
+ ``llm_detection`` is off since it costs an LLM call per written file).
12
+
13
+ The graph runs this once per turn in the ``detect_concepts`` node and stores
14
+ the result in ``state["concepts_detected"]``. The assessment and teacher
15
+ agents then read that shared result instead of each re-scanning
16
+ ``tool_calls_log`` — halving the LLM calls per turn and keeping the teacher
17
+ agent's rich per-concept diagram for the dashboard.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import re
24
+ from dataclasses import dataclass
25
+ from typing import Any
26
+
27
+ from backend.agents.concept_categories import (
28
+ is_valid_category,
29
+ normalize_category,
30
+ )
31
+ from backend.database.concept_slug import concept_slug
32
+ from backend.database.concepts import (
33
+ content_hash,
34
+ file_scan_cached,
35
+ get_cached_diagram,
36
+ )
37
+ from backend.llm.client import resolve_api_key, get_client
38
+ from backend.llm.config import get_model
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # Concept registry — known patterns and their short explanations
42
+ # ---------------------------------------------------------------------------
43
+
44
+ CONCEPT_PATTERNS: dict[str, dict[str, str]] = {
45
+ # React hooks
46
+ "useEffect": {
47
+ "name": "useEffect",
48
+ "category": "api",
49
+ "subcategory": "React Hook",
50
+ "description": (
51
+ "A React hook that runs side effects after render. "
52
+ "Commonly used for data fetching, subscriptions, and DOM manipulation. "
53
+ "Accepts a cleanup function and a dependency array to control re-runs."
54
+ ),
55
+ "diagram": (
56
+ "flowchart LR\n"
57
+ " R[Render] --> E[Run effect]\n"
58
+ " E --> D{deps changed?}\n"
59
+ " D -- yes --> E\n"
60
+ " D -- no --> S[Skip]\n"
61
+ " E --> C[Cleanup fn]\n"
62
+ " C --> R"
63
+ ),
64
+ },
65
+ "useState": {
66
+ "name": "useState",
67
+ "category": "api",
68
+ "subcategory": "React Hook",
69
+ "description": (
70
+ "A React hook that adds state to a functional component. "
71
+ "Returns a state value and a setter function. "
72
+ "Re-renders the component when the state changes."
73
+ ),
74
+ "diagram": (
75
+ "flowchart LR\n"
76
+ " S[Current state] --> C[Component renders]\n"
77
+ " C --> U[User event calls setter]\n"
78
+ " U --> N[State updated]\n"
79
+ " N --> C"
80
+ ),
81
+ },
82
+ "useMemo": {
83
+ "name": "useMemo",
84
+ "category": "api",
85
+ "subcategory": "React Hook",
86
+ "description": (
87
+ "A React hook that memoizes an expensive computation. "
88
+ "Only recalculates when its dependencies change, "
89
+ "preventing unnecessary re-renders."
90
+ ),
91
+ "diagram": (
92
+ "flowchart LR\n"
93
+ " R[Render] --> Q{deps changed?}\n"
94
+ " Q -- yes --> F[Recompute value]\n"
95
+ " Q -- no --> M[Reuse cached value]\n"
96
+ " F --> M"
97
+ ),
98
+ },
99
+ "useCallback": {
100
+ "name": "useCallback",
101
+ "category": "api",
102
+ "subcategory": "React Hook",
103
+ "description": (
104
+ "A React hook that memoizes a callback function. "
105
+ "Useful when passing callbacks to child components that "
106
+ "depend on referential equality."
107
+ ),
108
+ "diagram": (
109
+ "flowchart LR\n"
110
+ " R[Render] --> Q{deps changed?}\n"
111
+ " Q -- yes --> F[New function identity]\n"
112
+ " Q -- no --> M[Same function identity]\n"
113
+ " M --> P[Child skips re-render]\n"
114
+ " F --> P"
115
+ ),
116
+ },
117
+ "useRef": {
118
+ "name": "useRef",
119
+ "category": "api",
120
+ "subcategory": "React Hook",
121
+ "description": (
122
+ "A React hook that creates a mutable ref object. "
123
+ "Persists across renders without causing re-renders. "
124
+ "Commonly used for DOM access and storing previous values."
125
+ ),
126
+ "diagram": (
127
+ "flowchart LR\n"
128
+ " R[Render] --> B[Read ref.current]\n"
129
+ " B --> W[Write ref.current]\n"
130
+ " W --> N[No re-render] --> R"
131
+ ),
132
+ },
133
+ "useContext": {
134
+ "name": "useContext",
135
+ "category": "api",
136
+ "subcategory": "React Hook",
137
+ "description": (
138
+ "A React hook that reads values from the nearest Context Provider. "
139
+ "Avoids prop drilling by letting components access shared state."
140
+ ),
141
+ "diagram": (
142
+ "flowchart TD\n"
143
+ " P[Provider holds value] --> A[Component A]\n"
144
+ " A --> B[Component B]\n"
145
+ " B --> C[useContext reads value]\n"
146
+ " C -.no prop drilling.-> P"
147
+ ),
148
+ },
149
+ # JavaScript / TypeScript patterns
150
+ "async function": {
151
+ "name": "Async/Await",
152
+ "category": "abstract",
153
+ "subcategory": "Asynchronous Pattern",
154
+ "description": (
155
+ "Syntactic sugar over Promises. An async function returns a "
156
+ "Promise and can use 'await' to pause until a Promise resolves, "
157
+ "making asynchronous code read like synchronous code."
158
+ ),
159
+ "diagram": (
160
+ "sequenceDiagram\n"
161
+ " participant C as Caller\n"
162
+ " participant F as async fn\n"
163
+ " C->>F: call (returns Promise)\n"
164
+ " F-->>C: promise pending\n"
165
+ " Note over F: awaits I/O...\n"
166
+ " F-->>C: resolve with value\n"
167
+ " C->>C: continue after await"
168
+ ),
169
+ },
170
+ "Promise": {
171
+ "name": "Promises",
172
+ "category": "abstract",
173
+ "subcategory": "Asynchronous Pattern",
174
+ "description": (
175
+ "An object representing the eventual completion or failure of "
176
+ "an asynchronous operation. Chains of .then()/.catch() handle "
177
+ "success and error paths."
178
+ ),
179
+ "diagram": (
180
+ "stateDiagram-v2\n"
181
+ " [*] --> pending\n"
182
+ " pending --> fulfilled: .then()\n"
183
+ " pending --> rejected: error\n"
184
+ " rejected --> [*]: .catch()\n"
185
+ " fulfilled --> [*]"
186
+ ),
187
+ },
188
+ "export default": {
189
+ "name": "Default Export",
190
+ "category": "abstract",
191
+ "subcategory": "Module System",
192
+ "description": (
193
+ "ES module syntax that marks one value as the module's primary "
194
+ "export. Importers can name it anything: import Foo from './mod'."
195
+ ),
196
+ "diagram": (
197
+ "flowchart LR\n"
198
+ " M[Module] -- default export --> V[One primary value]\n"
199
+ " V --> I1[import Foo from mod]\n"
200
+ " V --> I2[import Bar from mod]\n"
201
+ " I1 -.any name works.-> V"
202
+ ),
203
+ },
204
+ "interface ": {
205
+ "name": "TypeScript Interface",
206
+ "category": "data_model",
207
+ "subcategory": "TypeScript",
208
+ "description": (
209
+ "Defines the shape of an object — its properties and their types. "
210
+ "Interfaces are checked at compile time and erased in the "
211
+ "generated JavaScript."
212
+ ),
213
+ "diagram": (
214
+ "erDiagram\n"
215
+ " USER {\n"
216
+ " string id\n"
217
+ " string email\n"
218
+ " }\n"
219
+ " ORDER ||--o{ USER : placed-by"
220
+ ),
221
+ },
222
+ "type ": {
223
+ "name": "TypeScript Type Alias",
224
+ "category": "data_model",
225
+ "subcategory": "TypeScript",
226
+ "description": (
227
+ "Gives a name to a type expression (union, intersection, object, "
228
+ "primitive). Unlike interfaces, type aliases can represent "
229
+ "unions and mapped types."
230
+ ),
231
+ "diagram": (
232
+ "erDiagram\n"
233
+ " USER {\n"
234
+ " string id\n"
235
+ " string status\n"
236
+ " }\n"
237
+ " USER ||--o| PROFILE : one-to-optional"
238
+ ),
239
+ },
240
+ # Python patterns
241
+ "def __init__": {
242
+ "name": "__init__ (Constructor)",
243
+ "category": "structure",
244
+ "subcategory": "Python OOP",
245
+ "description": (
246
+ "The constructor method for a Python class. Called when a new "
247
+ "instance is created. Initializes the object's attributes."
248
+ ),
249
+ "diagram": (
250
+ "classDiagram\n"
251
+ " class Animal {\n"
252
+ " +String name\n"
253
+ " +__init__(name)\n"
254
+ " +speak()\n"
255
+ " }\n"
256
+ " Animal : constructor initializes attributes"
257
+ ),
258
+ },
259
+ "async def": {
260
+ "name": "Python Async Functions",
261
+ "category": "abstract",
262
+ "subcategory": "Asynchronous Pattern",
263
+ "description": (
264
+ "Defines a coroutine that can be awaited. Used with asyncio "
265
+ "for non-blocking I/O operations like network requests and "
266
+ "file access."
267
+ ),
268
+ "diagram": (
269
+ "sequenceDiagram\n"
270
+ " participant L as Event loop\n"
271
+ " participant C as Coroutine\n"
272
+ " L->>C: start coroutine\n"
273
+ " C-->>L: await I/O (yield)\n"
274
+ " L->>C: I/O done, resume\n"
275
+ " C-->>L: return result"
276
+ ),
277
+ },
278
+ "decorator": {
279
+ "name": "Decorators",
280
+ "category": "abstract",
281
+ "subcategory": "Python Pattern",
282
+ "description": (
283
+ "Functions that modify other functions or classes. Applied with "
284
+ "@syntax above the target. Common uses: logging, caching, "
285
+ "authentication checks."
286
+ ),
287
+ "diagram": (
288
+ "sequenceDiagram\n"
289
+ " participant C as Caller\n"
290
+ " participant W as Wrapper\n"
291
+ " participant F as Original fn\n"
292
+ " C->>W: call decorated fn\n"
293
+ " W->>F: delegate\n"
294
+ " F-->>W: result\n"
295
+ " W-->>C: result (plus extra behavior)"
296
+ ),
297
+ },
298
+ # General patterns
299
+ "for ": {
300
+ "name": "For Loops",
301
+ "category": "algorithm",
302
+ "subcategory": "Control Flow",
303
+ "description": (
304
+ "Definite iteration over a sequence (range, list, string, or "
305
+ "iterator). The loop variable takes each element in turn; "
306
+ "'break' and 'continue' control the flow."
307
+ ),
308
+ "diagram": (
309
+ "flowchart LR\n"
310
+ " S[Start] --> C{more items?}\n"
311
+ " C -- yes --> B[run body] --> N[next item] --> C\n"
312
+ " C -- no --> D[done]"
313
+ ),
314
+ },
315
+ "while ": {
316
+ "name": "While Loops",
317
+ "category": "algorithm",
318
+ "subcategory": "Control Flow",
319
+ "description": (
320
+ "Indefinite iteration: the body runs as long as the condition "
321
+ "holds. Requires the condition to eventually become false "
322
+ "(or a break) to avoid an infinite loop."
323
+ ),
324
+ "diagram": (
325
+ "flowchart LR\n"
326
+ " S[Start] --> C{condition true?}\n"
327
+ " C -- yes --> B[run body] --> C\n"
328
+ " C -- no --> D[done]"
329
+ ),
330
+ },
331
+ "class ": {
332
+ "name": "Classes / OOP",
333
+ "category": "structure",
334
+ "subcategory": "Object-Oriented Programming",
335
+ "description": (
336
+ "Blueprints for creating objects. Combine state (attributes) "
337
+ "and behavior (methods) into a single unit. Support "
338
+ "inheritance, encapsulation, and polymorphism."
339
+ ),
340
+ "diagram": (
341
+ "classDiagram\n"
342
+ " class Animal {\n"
343
+ " +String name\n"
344
+ " +speak()\n"
345
+ " }\n"
346
+ " Animal <|-- Dog : inheritance\n"
347
+ " Animal : +attributes\n"
348
+ " Animal : +methods()"
349
+ ),
350
+ },
351
+ "try:": {
352
+ "name": "Try/Except (Error Handling)",
353
+ "category": "abstract",
354
+ "subcategory": "Error Handling",
355
+ "description": (
356
+ "Gracefully handles runtime errors. Code in the 'try' block "
357
+ "runs normally; if an exception occurs, control jumps to "
358
+ "'except' instead of crashing."
359
+ ),
360
+ "diagram": (
361
+ "flowchart TD\n"
362
+ " T[Try block] --> E{exception?}\n"
363
+ " E -- no --> N[continue normally]\n"
364
+ " E -- yes --> X[Except block]\n"
365
+ " X --> N"
366
+ ),
367
+ },
368
+ "switch": {
369
+ "name": "Switch Statement",
370
+ "category": "algorithm",
371
+ "subcategory": "Control Flow",
372
+ "description": (
373
+ "Multi-way branching on one value. Each 'case' matches a "
374
+ "possible value and runs its block; 'default' handles anything "
375
+ "else. Cleaner than long if/else-if chains on the same value."
376
+ ),
377
+ "diagram": (
378
+ "flowchart TD\n"
379
+ " V[Value to match] --> C1{case 1}\n"
380
+ " C1 -- match --> B1[Run block 1]\n"
381
+ " C1 -- no --> C2{case 2}\n"
382
+ " C2 -- match --> B2[Run block 2]\n"
383
+ " C2 -- no --> D[Default block]"
384
+ ),
385
+ },
386
+ " ? ": {
387
+ "name": "Ternary Operator",
388
+ "category": "algorithm",
389
+ "subcategory": "Control Flow",
390
+ "description": (
391
+ "Inline conditional: 'condition ? a : b' evaluates to 'a' when "
392
+ "the condition is true, otherwise 'b'. A compact alternative "
393
+ "to a two-branch if/else used inside expressions."
394
+ ),
395
+ "diagram": (
396
+ "flowchart LR\n"
397
+ " C{condition?} -- true --> A[value a]\n"
398
+ " C -- false --> B[value b]\n"
399
+ " A --> R[result]\n"
400
+ " B --> R"
401
+ ),
402
+ },
403
+ "import ": {
404
+ "name": "Imports / Modules",
405
+ "category": "abstract",
406
+ "subcategory": "Module System",
407
+ "description": (
408
+ "Brings code from other files or packages into the current "
409
+ "namespace. Enables code reuse and separation of concerns."
410
+ ),
411
+ "diagram": (
412
+ "flowchart LR\n"
413
+ " M[Other module] --> N[import brings names here]\n"
414
+ " N --> U[Use without redefining]\n"
415
+ " P[Package] --> N"
416
+ ),
417
+ },
418
+ "lambda": {
419
+ "name": "Lambda Functions",
420
+ "category": "algorithm",
421
+ "subcategory": "Functional Programming",
422
+ "description": (
423
+ "Anonymous, inline functions defined with the 'lambda' keyword. "
424
+ "Useful for short callbacks in map(), filter(), and sorted()."
425
+ ),
426
+ "diagram": (
427
+ "flowchart LR\n"
428
+ " A[lambda x: expression] --> C[Passed as callback]\n"
429
+ " C --> H[HOF: map, filter, sorted]\n"
430
+ " H --> R[Result per element]"
431
+ ),
432
+ },
433
+ "map(": {
434
+ "name": "map()",
435
+ "category": "algorithm",
436
+ "subcategory": "Functional Programming",
437
+ "description": (
438
+ "Applies a function to every element of an iterable, returning "
439
+ "a new iterable of results. Often combined with list() to "
440
+ "produce a list."
441
+ ),
442
+ "diagram": (
443
+ "flowchart LR\n"
444
+ " I[1, 2, 3] --> M[map fn]\n"
445
+ " M --> O[fn 1, fn 2, fn 3]\n"
446
+ " O --> L[list gathers results]"
447
+ ),
448
+ },
449
+ "filter(": {
450
+ "name": "filter()",
451
+ "category": "algorithm",
452
+ "subcategory": "Functional Programming",
453
+ "description": (
454
+ "Returns an iterable of elements for which the predicate "
455
+ "function returned True. Useful for selecting a subset of data."
456
+ ),
457
+ "diagram": (
458
+ "flowchart LR\n"
459
+ " I[All elements] --> P{predicate true?}\n"
460
+ " P -- yes --> K[Kept]\n"
461
+ " P -- no --> D[Dropped]"
462
+ ),
463
+ },
464
+ "querySelector": {
465
+ "name": "DOM Querying",
466
+ "category": "api",
467
+ "subcategory": "DOM / Browser API",
468
+ "description": (
469
+ "Selects a single element in the DOM using a CSS selector. "
470
+ "querySelectorAll() selects all matching elements."
471
+ ),
472
+ "diagram": (
473
+ "flowchart TD\n"
474
+ " S[CSS selector string] --> D[DOM tree search]\n"
475
+ " D --> F[First matching element]\n"
476
+ " D --> A[querySelectorAll: all matches]"
477
+ ),
478
+ },
479
+ "addEventListener": {
480
+ "name": "Event Listeners",
481
+ "category": "api",
482
+ "subcategory": "DOM / Browser API",
483
+ "description": (
484
+ "Registers a callback that runs when a specific event fires on "
485
+ "an element (click, submit, keydown, etc.). Crucial for "
486
+ "interactive web applications."
487
+ ),
488
+ "diagram": (
489
+ "sequenceDiagram\n"
490
+ " participant U as User\n"
491
+ " participant E as Element\n"
492
+ " participant CB as Callback\n"
493
+ " U->>E: click / keydown\n"
494
+ " E->>CB: invoke listener\n"
495
+ " CB-->>E: handle event"
496
+ ),
497
+ },
498
+ "fetch(": {
499
+ "name": "Fetch API",
500
+ "category": "api",
501
+ "subcategory": "Networking",
502
+ "description": (
503
+ "Makes HTTP requests from the browser or Node.js. Returns a "
504
+ "Promise that resolves to a Response object. Typically "
505
+ "combined with .json() to parse the body."
506
+ ),
507
+ "diagram": (
508
+ "sequenceDiagram\n"
509
+ " participant A as App\n"
510
+ " participant N as Network\n"
511
+ " A->>N: fetch(url)\n"
512
+ " N-->>A: Promise<Response>\n"
513
+ " A->>N: response.json()\n"
514
+ " N-->>A: parsed data"
515
+ ),
516
+ },
517
+ }
518
+
519
+
520
+ @dataclass
521
+ class DetectedConcept:
522
+ """A concept identified from code."""
523
+
524
+ name: str
525
+ category: str
526
+ description: str
527
+ # Human-readable sub-label (e.g. "React Hook") under the canonical
528
+ # taxonomy ``category`` (e.g. "api").
529
+ subcategory: str = ""
530
+ source_file: str = ""
531
+ line_range: tuple[int, int] = (0, 0)
532
+ # Optional Mermaid diagram definition rendered on the dashboard.
533
+ diagram: str = ""
534
+ # The author's core choice behind this concept — why THIS approach
535
+ # over the alternative, a trade-off accepted, a constraint honored.
536
+ # Empty for textbook-only concepts with no author choice, and for
537
+ # registry matches (the registry teaches the technique, not this
538
+ # file's use of it). Always populated for ``decisions`` concepts,
539
+ # where the choice itself is the concept.
540
+ decision: str = ""
541
+
542
+
543
+ # ---------------------------------------------------------------------------
544
+ # Mermaid syntax validation (lightweight, no parser dependency)
545
+ # ---------------------------------------------------------------------------
546
+
547
+ MERMAID_DIAGRAM_TYPES = (
548
+ "flowchart",
549
+ "classDiagram",
550
+ "sequenceDiagram",
551
+ "erDiagram",
552
+ "stateDiagram-v2",
553
+ )
554
+
555
+ _FENCE_RE = re.compile(r"^\s*```\w*\s*$", re.MULTILINE)
556
+
557
+ # One general mechanism for every structural check: replace each
558
+ # label/quoted span with a neutral placeholder, then validate the
559
+ # remaining "skeleton". A label span is a single-line, well-paired
560
+ # (...), [...] or {...} group with no nested delimiters; a quoted span
561
+ # honors ``\"`` escapes and may contain brackets of any kind. Content
562
+ # inside a span is exempt from balance checks — this one rule is what
563
+ # lets quotes inside labels (``A["say \"hi\""]``) and ER cardinality
564
+ # braces (``ORDER ||--o{ USER``) coexist with strict checks outside
565
+ # spans, with no per-character exemption logic to keep in sync.
566
+ _STRIP_SPAN_RE = re.compile(
567
+ r'"(?:\\.|[^"\\\n])*"' # quoted span, \" escapes honored
568
+ r"|\([^()\[\]{}\"\n]*\)" # (...) label
569
+ r"|\[[^()\[\]{}\"\n]*\]" # [...] label
570
+ r"|\{[^()\[\]{}\"\n]*\}" # {...} decision-node label
571
+ )
572
+ _PLACEHOLDER = "\uFFFD"
573
+
574
+
575
+ def _strip_labels(diagram: str) -> str:
576
+ """Stage 1: replace every label/quoted span with a placeholder.
577
+
578
+ Single left-to-right pass, quoted spans first so a quote inside a
579
+ label is consumed before label matching ever sees it. Spans cannot
580
+ cross newlines, so ER relation braces (``||--o{``) survive unpaired
581
+ on their line — correctly, since they are structural there.
582
+ """
583
+ return _STRIP_SPAN_RE.sub(_PLACEHOLDER, diagram)
584
+
585
+
586
+ def is_valid_mermaid(diagram: str) -> bool:
587
+ """Lightweight sanity check that *diagram* parses as Mermaid.
588
+
589
+ Two stages:
590
+
591
+ 1. :func:`_strip_labels` extracts every ``[...]``/``(...)``/``{...}``
592
+ label span and quoted string into placeholders, producing a
593
+ skeleton of pure structural characters.
594
+ 2. The skeleton is checked for: a routed diagram-type header,
595
+ balanced ``( )``/``[ ]`` brackets, braces only where they are
596
+ structural (ER relations, class attribute blocks), and no stray
597
+ double quotes outside spans.
598
+
599
+ Not a full parser — it catches the failure modes that make the
600
+ dashboard's Mermaid renderer show a syntax error instead of a
601
+ diagram. Empty/whitespace-only input is invalid: the save path
602
+ treats a blank diagram as "no diagram", but callers who backfill
603
+ need the distinction made explicit.
604
+ """
605
+ if not isinstance(diagram, str) or not diagram.strip():
606
+ return False
607
+
608
+ text = _FENCE_RE.sub("", diagram).strip()
609
+ if not text:
610
+ return False
611
+ skeleton = _strip_labels(text)
612
+ lines = skeleton.splitlines()
613
+ if not lines[0].strip().startswith(MERMAID_DIAGRAM_TYPES):
614
+ return False
615
+
616
+ first_word = lines[0].strip().split(None, 1)[0]
617
+ braces_structural = first_word in ("erDiagram", "classDiagram")
618
+
619
+ for line in lines:
620
+ stripped = line.strip()
621
+ if not stripped or stripped.startswith("%%"):
622
+ continue
623
+
624
+ if not braces_structural and ("{" in stripped or "}" in stripped):
625
+ # Braces surviving the strip are only legal in ER relations
626
+ # and class attribute blocks.
627
+ return False
628
+
629
+ depth = 0
630
+ for ch in stripped:
631
+ if ch in "[(":
632
+ depth += 1
633
+ elif ch in ")]":
634
+ depth -= 1
635
+ if depth < 0:
636
+ return False # closer before opener
637
+ elif ch == '"':
638
+ return False # quote outside any span
639
+ if depth != 0:
640
+ return False # unclosed bracket
641
+
642
+ return True
643
+
644
+
645
+ # ---------------------------------------------------------------------------
646
+ # Detection engine
647
+ # ---------------------------------------------------------------------------
648
+
649
+ def detect_concepts_from_file(file_path: str, content: str) -> list[DetectedConcept]:
650
+ """Scan *content* for known concept patterns and return matches."""
651
+ concepts: list[DetectedConcept] = []
652
+ seen: set[str] = set()
653
+
654
+ lines = content.splitlines()
655
+ for line_no, line in enumerate(lines, start=1):
656
+ for pattern, info in CONCEPT_PATTERNS.items():
657
+ if pattern in line and info["name"] not in seen:
658
+ seen.add(info["name"])
659
+ concepts.append(
660
+ DetectedConcept(
661
+ name=info["name"],
662
+ category=info["category"],
663
+ subcategory=info.get("subcategory", ""),
664
+ description=info["description"],
665
+ source_file=file_path,
666
+ line_range=(line_no, line_no),
667
+ diagram=info.get("diagram", ""),
668
+ )
669
+ )
670
+
671
+ return concepts
672
+
673
+
674
+ def detect_concepts_from_tool_calls(
675
+ tool_calls: list[dict[str, Any]],
676
+ ) -> list[DetectedConcept]:
677
+ """Analyze a batch of tool-call dicts (from the coding agent) and
678
+ return any detected concepts.
679
+
680
+ Each tool call is expected to have ``function.name`` and
681
+ ``function.arguments`` (JSON string) with keys like ``file_path``
682
+ and ``content`` (for write_file) or ``old_string``/``new_string``
683
+ (for edit_file).
684
+ """
685
+ concepts: list[DetectedConcept] = []
686
+ seen: set[str] = set()
687
+
688
+ for tc in tool_calls:
689
+ fn = tc.get("function", {})
690
+ name = fn.get("name", "")
691
+ try:
692
+ args = json.loads(fn.get("arguments", "{}"))
693
+ except (json.JSONDecodeError, TypeError):
694
+ continue
695
+
696
+ # Gather the text to scan
697
+ text_to_scan = ""
698
+ file_path = ""
699
+
700
+ if name == "write_file":
701
+ file_path = args.get("file_path", "")
702
+ text_to_scan = args.get("content", "")
703
+ elif name == "edit_file":
704
+ file_path = args.get("file_path", "")
705
+ text_to_scan = args.get("new_string", "")
706
+ elif name == "read_file":
707
+ # We don't detect concepts from reads
708
+ continue
709
+ else:
710
+ continue
711
+
712
+ if not text_to_scan:
713
+ continue
714
+
715
+ detected = detect_concepts_from_file(file_path, text_to_scan)
716
+ for c in detected:
717
+ if c.name not in seen:
718
+ seen.add(c.name)
719
+ concepts.append(c)
720
+
721
+ return concepts
722
+
723
+
724
+ # ---------------------------------------------------------------------------
725
+ # LLM-enhanced detection (for patterns not in the registry)
726
+ # ---------------------------------------------------------------------------
727
+
728
+ LLM_DETECT_PROMPT = """\
729
+ You are a code-analysis assistant. Given the following code snippet,
730
+ list the key programming concepts, patterns, or techniques used.
731
+ Return ONLY a JSON array of objects with keys: "name", "category",
732
+ "description", "decision", "diagram".
733
+ If there are no notable concepts, return an empty array [].
734
+
735
+ The "category" value MUST be exactly one of these six strings — no
736
+ other values, no capitalization changes, no invented labels:
737
+ - "algorithm" — loops, sorting, searching, recursion, big-O reasoning
738
+ - "structure" — classes, interfaces, inheritance, encapsulation, OOP
739
+ - "api" — calls into a defined interface: fetch, DOM, SDKs, hooks
740
+ - "data_model" — how data is shaped or moved: schemas, type aliases,
741
+ JSON payloads, ORMs, validation
742
+ - "decisions" — architecture and integration choices made in THIS
743
+ codebase: which service, library, or tool was picked for a job
744
+ (Supabase for auth or the database, Firebase, Stripe, Redis, ...)
745
+ and how features/modules are wired together (who owns what, which
746
+ module reaches which)
747
+ - "abstract" — cross-cutting ideas: error handling, modules, async,
748
+ design patterns
749
+
750
+ "decisions" concepts are about the CODEBASE, not a technique. Emit one
751
+ ONLY when the snippet itself shows the evidence — an import, an SDK
752
+ client being constructed, a config/env read, or a call from one module
753
+ into another. Never speculate about files you cannot see. Name it
754
+ concretely ("Supabase session auth" beats "Authentication"; "Teacher
755
+ agent reads detect_concepts state" beats "Shared state"), and ALWAYS
756
+ fill "decision" for a decisions concept — the choice and the reason for
757
+ it IS the concept. Two or three decisions per snippet is plenty; skip
758
+ them entirely when the code shows no such choice.
759
+
760
+ The "decision" value records the CHOICE THE AUTHOR MADE — one sentence,
761
+ specific to this code, never generic:
762
+ - why THIS approach was chosen over the obvious alternative
763
+ - a trade-off accepted (e.g. "re-renders the whole list on toggle;
764
+ acceptable because lists stay under ~50 items")
765
+ - a constraint honored (e.g. "keeps polling on the main thread because
766
+ the target API has no webhook support")
767
+ Write it as a plain sentence. If a concept is textbook-only with no
768
+ author choice behind it, use "" — an empty decision is always better
769
+ than an invented one.
770
+
771
+ The "diagram" value must be a small, valid Mermaid diagram WHOSE TYPE
772
+ matches the concept's category — this routing is required:
773
+ - "algorithm" -> flowchart (process steps and decision points)
774
+ - "structure" -> classDiagram (classes, methods, inheritance)
775
+ - "api" -> sequenceDiagram (participants exchanging messages)
776
+ - "data_model" -> erDiagram (entities with attributes and relations)
777
+ - "decisions" -> flowchart (how the pieces connect: feature -> tool
778
+ or service -> data store), few nodes, one arrow per real connection
779
+ - "abstract" -> flowchart (a small concept map: the idea at the center,
780
+ what it touches or enables around it)
781
+ Aim for 3-6 nodes. Use simple ASCII labels, wrap each
782
+ label in square brackets (e.g. A[Label]), and never put parentheses or special
783
+ characters inside labels.
784
+
785
+ erDiagram extra rule: relation labels with spaces MUST be quoted, e.g.
786
+ ``OperatorMap ||--o{{ Function : "maps to"`` — an unquoted
787
+ multi-word label is a parse error.
788
+
789
+ Name concepts SPECIFICALLY when the code warrants it: "Token refresh
790
+ via single-flight queue" teaches more than "Async Programming". Keep
791
+ generic names ("Recursion", "Event Listeners") for genuinely generic
792
+ code.
793
+
794
+ Code file: {file_path}
795
+ ```{lang}
796
+ {code}
797
+ ```
798
+ """
799
+
800
+ CATEGORY_FIX_PROMPT = """\
801
+ Your previous response described programming concepts but some
802
+ "category" values were missing or not from the allowed set:
803
+ algorithm, structure, api, data_model, decisions, abstract.
804
+
805
+ Re-send ONLY a JSON object mapping each concept name below to an object
806
+ with its corrected category, e.g. {{"Concept Name": {{"category":
807
+ "api"}}}}. Choose from the six allowed values only.
808
+
809
+ Concepts needing a category:
810
+ {concepts}
811
+ """
812
+
813
+ DIAGRAM_BACKFILL_PROMPT = """\For each concept below, produce ONE small, valid Mermaid diagram whose
814
+ TYPE matches the concept's category — flowchart for algorithm and
815
+ decisions (the latter showing how the wired pieces connect),
816
+ classDiagram for structure, sequenceDiagram for api, erDiagram for
817
+ data_model, and a simple concept-map flowchart for abstract. Aim for
818
+ 3-6 nodes. Use simple ASCII labels, wrap each
819
+ label in square brackets (e.g. A[Label]), and never put parentheses or special
820
+ characters inside labels. erDiagram relation labels with spaces MUST
821
+ be quoted, e.g. ``OperatorMap ||--o{{ Function : "maps to"``.
822
+
823
+ Concepts (name | category | description):
824
+ {concepts}
825
+
826
+ Return ONLY a JSON object mapping each concept name to its Mermaid
827
+ diagram string (use "" only if no diagram can make sense).
828
+ """
829
+
830
+
831
+ def _norm_concept_key(name: str) -> str:
832
+ """Normalize a concept name for tolerant matching.
833
+
834
+ Models echoing the prompt's '- Name (Category): ...' format tend to
835
+ return JSON keys like ``"DOM Manipulation (DOM / Browser API)"``
836
+ instead of the bare concept name — compare on the part before the
837
+ first parenthesis, lowercased.
838
+ """
839
+ base = name.split("(", 1)[0]
840
+ return " ".join(base.lower().split())
841
+
842
+
843
+ def _backfill_diagrams(concepts: list[DetectedConcept]) -> None:
844
+ """Ask the LLM for diagrams for concepts detected without one.
845
+
846
+ Models sometimes return the concept list but skip the "diagram"
847
+ field (nondeterministically). One focused follow-up call recovers
848
+ most of those cases. Best-effort: any failure leaves the diagrams
849
+ empty and the text explanation remains the fallback.
850
+
851
+ Mutates *concepts* in place.
852
+ """
853
+ api_key = resolve_api_key()
854
+ if not api_key:
855
+ return
856
+
857
+ if not concepts:
858
+ return
859
+
860
+ concept_lines = [
861
+ f"- {c.name} | {c.category} | {c.description[:120]}"
862
+ for c in concepts
863
+ ]
864
+ prompt = DIAGRAM_BACKFILL_PROMPT.format(concepts="\n".join(concept_lines))
865
+
866
+ try:
867
+ client = get_client()
868
+ completion = client.chat.completions.create(
869
+ model=get_model("detection"),
870
+ messages=[{"role": "user", "content": prompt}],
871
+ # gpt-oss spends most of its budget on reasoning tokens before
872
+ # writing content — 1024 truncated the JSON mid-output
873
+ # (finish_reason: length). 2048 matches the detection call,
874
+ # which is proven to leave room for the full answer.
875
+ max_completion_tokens=2048,
876
+ )
877
+ raw = (completion.choices[0].message.content or "").strip()
878
+ if raw.startswith("```"):
879
+ raw = re.sub(r"^```(?:json)?\s*", "", raw)
880
+ raw = re.sub(r"\s*```$", "", raw)
881
+ diagrams = json.loads(raw)
882
+ if not isinstance(diagrams, dict):
883
+ return
884
+ # Tolerant lookup: models may return keys like
885
+ # "Name (Category)" instead of the bare concept name.
886
+ by_key = {_norm_concept_key(k): v for k, v in diagrams.items()}
887
+ for c in concepts:
888
+ value = by_key.get(_norm_concept_key(c.name), "")
889
+ if isinstance(value, str) and value.strip():
890
+ candidate = value.strip()
891
+ if is_valid_mermaid(candidate):
892
+ c.diagram = candidate
893
+ # An invalid replacement is worse than none: the
894
+ # dashboard would render a syntax error instead of
895
+ # falling back to prose. Leave the (invalid) diagram
896
+ # field as-is; the save path strips it.
897
+ except Exception: # noqa: BLE001 - best-effort backfill only
898
+ return
899
+
900
+
901
+ def _retry_categories(concepts: list[DetectedConcept]) -> None:
902
+ """Ask the LLM to re-tag concepts whose category was missing/invalid.
903
+
904
+ ONE corrective call is made, listing only the offending concepts.
905
+ Concepts whose category is still missing or outside the taxonomy are
906
+ rejected by the caller — a detection is never default-filled.
907
+ Mutates *concepts* in place: fixed entries get a canonical category;
908
+ unfixed entries keep ``category == ""`` (invalid) so the caller can
909
+ drop them.
910
+ """
911
+ api_key = resolve_api_key()
912
+ if not api_key:
913
+ return
914
+
915
+ concept_lines = [
916
+ f"- {c.name}: {c.description[:120]}"
917
+ for c in concepts
918
+ ]
919
+ prompt = CATEGORY_FIX_PROMPT.format(concepts="\n".join(concept_lines))
920
+ try:
921
+ client = get_client()
922
+ completion = client.chat.completions.create(
923
+ model=get_model("detection"),
924
+ messages=[{"role": "user", "content": prompt}],
925
+ max_completion_tokens=1024,
926
+ )
927
+ raw = (completion.choices[0].message.content or "").strip()
928
+ if raw.startswith("```"):
929
+ raw = re.sub(r"^```(?:json)?\s*", "", raw)
930
+ raw = re.sub(r"\s*```$", "", raw)
931
+ fixes = json.loads(raw)
932
+ if not isinstance(fixes, dict):
933
+ return
934
+ # Tolerant lookup: keys may be decorated ("Name (hint)").
935
+ by_key = {_norm_concept_key(k): v for k, v in fixes.items()}
936
+ for c in concepts:
937
+ fix = by_key.get(_norm_concept_key(c.name))
938
+ if isinstance(fix, dict):
939
+ fixed = normalize_category(fix.get("category"))
940
+ elif isinstance(fix, str):
941
+ # Tolerate a bare {"Name": "api"} mapping.
942
+ fixed = normalize_category(fix)
943
+ else:
944
+ fixed = None
945
+ if fixed is not None:
946
+ c.category = fixed
947
+ except Exception: # noqa: BLE001 - best-effort retry only
948
+ return
949
+
950
+
951
+ def detect_concepts_with_llm(
952
+ file_path: str,
953
+ content: str,
954
+ known_names: set[str],
955
+ session: str = "default",
956
+ code_hash: str = "",
957
+ ) -> list[DetectedConcept]:
958
+ """Ask the LLM to identify concepts not already in our registry.
959
+
960
+ Falls back gracefully if the API key is missing or the call fails.
961
+ When *code_hash* is provided, concepts detected without a usable
962
+ diagram first try the store's identity cache (same concept slug +
963
+ same code hash) before spending the backfill LLM call — the cache
964
+ is only consulted for non-empty hashes so tests and hash-less
965
+ callers never touch the database.
966
+ """
967
+ api_key = resolve_api_key()
968
+ if not api_key:
969
+ return []
970
+
971
+ # Determine language hint from extension
972
+ ext = file_path.rsplit(".", 1)[-1] if "." in file_path else ""
973
+ lang_map = {
974
+ "py": "python",
975
+ "js": "javascript",
976
+ "ts": "typescript",
977
+ "tsx": "tsx",
978
+ "jsx": "jsx",
979
+ "rs": "rust",
980
+ "go": "go",
981
+ }
982
+ lang = lang_map.get(ext, "")
983
+
984
+ # Truncate to avoid huge prompts
985
+ if len(content) > 4000:
986
+ content = content[:4000] + "\n... (truncated)"
987
+
988
+ prompt = LLM_DETECT_PROMPT.format(
989
+ file_path=file_path, lang=lang, code=content
990
+ )
991
+
992
+ try:
993
+ client = get_client()
994
+ completion = client.chat.completions.create(
995
+ model=get_model("detection"),
996
+ messages=[{"role": "user", "content": prompt}],
997
+ # Diagrams per concept make the output longer than plain
998
+ # detection, so allow more completion tokens here.
999
+ max_completion_tokens=2048,
1000
+ )
1001
+ raw = completion.choices[0].message.content or "[]"
1002
+ # Extract JSON array from the response (handle markdown fences)
1003
+ raw = raw.strip()
1004
+ if raw.startswith("```"):
1005
+ raw = re.sub(r"^```(?:json)?\s*", "", raw)
1006
+ raw = re.sub(r"\s*```$", "", raw)
1007
+ concepts_raw = json.loads(raw)
1008
+ except Exception: # noqa: BLE001
1009
+ return []
1010
+
1011
+ concepts: list[DetectedConcept] = []
1012
+ needs_category: list[DetectedConcept] = []
1013
+ for item in concepts_raw:
1014
+ if not isinstance(item, dict):
1015
+ continue
1016
+ name = str(item.get("name", "")).strip()
1017
+ if not name or name in known_names:
1018
+ continue
1019
+ description = str(item.get("description", ""))
1020
+ decision = str(item.get("decision", "") or "").strip()
1021
+ diagram = str(item.get("diagram", "") or "")
1022
+ raw_category = item.get("category")
1023
+ category = normalize_category(raw_category)
1024
+ raw_label = (
1025
+ str(raw_category).strip() if isinstance(raw_category, str) else ""
1026
+ )
1027
+ if category is None:
1028
+ # Missing or unrecognizable — queue for the corrective retry;
1029
+ # never default-fill.
1030
+ concept = DetectedConcept(
1031
+ name=name,
1032
+ category="",
1033
+ description=description,
1034
+ subcategory=raw_label,
1035
+ source_file=file_path,
1036
+ diagram=diagram,
1037
+ decision=decision,
1038
+ )
1039
+ concepts.append(concept)
1040
+ needs_category.append(concept)
1041
+ continue
1042
+ concepts.append(
1043
+ DetectedConcept(
1044
+ name=name,
1045
+ category=category,
1046
+ description=description,
1047
+ # Keep the model's own label as the human-readable
1048
+ # subcategory when it adds detail beyond the slug.
1049
+ subcategory=(
1050
+ raw_label if raw_label and raw_label.lower() != category else ""
1051
+ ),
1052
+ source_file=file_path,
1053
+ diagram=diagram,
1054
+ decision=decision,
1055
+ )
1056
+ )
1057
+
1058
+ # Guard: one corrective retry for concepts whose category was
1059
+ # missing or outside the taxonomy, then reject whatever is still
1060
+ # invalid. Responses are never accepted with a default category.
1061
+ if needs_category:
1062
+ _retry_categories(needs_category)
1063
+ valid: list[DetectedConcept] = [
1064
+ c for c in concepts if is_valid_category(c.category)
1065
+ ]
1066
+
1067
+ # Identity cache before the expensive thing: a concept whose code
1068
+ # is unchanged reuses its stored diagram instead of a backfill call.
1069
+ if code_hash:
1070
+ for c in valid:
1071
+ if not c.diagram or not is_valid_mermaid(c.diagram):
1072
+ cached = get_cached_diagram(session, concept_slug(c.name), code_hash)
1073
+ if cached:
1074
+ c.diagram = cached
1075
+
1076
+ # Models sometimes skip the diagram field or emit malformed Mermaid —
1077
+ # recover both with one focused follow-up call before giving up on a
1078
+ # visual. Every category may carry a diagram, including abstract.
1079
+ missing = [
1080
+ c for c in valid
1081
+ if not c.diagram or not is_valid_mermaid(c.diagram)
1082
+ ]
1083
+ if missing:
1084
+ _backfill_diagrams(missing)
1085
+
1086
+ return valid
1087
+
1088
+
1089
+ # ---------------------------------------------------------------------------
1090
+ # Graph node — run detection once per turn for both downstream agents
1091
+ # ---------------------------------------------------------------------------
1092
+
1093
+ def detect_concepts(state: dict[str, Any]) -> dict[str, Any]:
1094
+ """LangGraph node: detect concepts from the coding agent's tool calls.
1095
+
1096
+ Combines the registry scan with mode-gated LLM detection and writes
1097
+ the merged, deduplicated result to ``state["concepts_detected"]``.
1098
+ The assessment and teacher agents read that shared result instead of
1099
+ re-scanning the tool calls themselves.
1100
+
1101
+ Expects ``state["tool_calls_log"]`` from the coding agent.
1102
+ Expects ``state["concepts"]`` for already-known concepts.
1103
+ Expects ``state["current_mode_config"]`` for mode settings.
1104
+
1105
+ Files whose exact content already produced a stored concept (same
1106
+ path, same content_hash) skip the LLM generation call entirely —
1107
+ the identity cache gates the expensive call itself, not just its
1108
+ backfill or the final write.
1109
+
1110
+ Returns:
1111
+ - ``concepts_detected``: list of dicts with ``name``, ``category``,
1112
+ ``description``, ``diagram`` (plus ``source_file``/``line_range``
1113
+ where known)
1114
+ """
1115
+ tool_calls_log: list[dict[str, Any]] = state.get("tool_calls_log", [])
1116
+ concepts: list[dict[str, Any]] = state.get("concepts", [])
1117
+ mode_config: dict[str, Any] | None = state.get("current_mode_config")
1118
+ session: str = state.get("session", "default")
1119
+
1120
+ # 1. Registry-based detection from write/edit tool calls.
1121
+ detected: list[DetectedConcept] = []
1122
+ seen: set[str] = set()
1123
+ for c in detect_concepts_from_tool_calls(tool_calls_log):
1124
+ if c.name not in seen:
1125
+ seen.add(c.name)
1126
+ detected.append(c)
1127
+
1128
+ # Stamp each tool call's file with the hash of its content — the
1129
+ # store keys its identity cache on (slug, content_hash), so "same
1130
+ # concept, unchanged code" is recognizable both here (cache check)
1131
+ # and downstream (teacher → save_teaching).
1132
+ code_hashes: dict[str, str] = {}
1133
+ for tc in tool_calls_log:
1134
+ fn = tc.get("function", {})
1135
+ name = fn.get("name", "")
1136
+ try:
1137
+ args = json.loads(fn.get("arguments", "{}"))
1138
+ except (json.JSONDecodeError, TypeError):
1139
+ continue
1140
+ if name == "write_file":
1141
+ file_path = args.get("file_path", "")
1142
+ file_content = args.get("content", "")
1143
+ elif name == "edit_file":
1144
+ file_path = args.get("file_path", "")
1145
+ file_content = args.get("new_string", "")
1146
+ else:
1147
+ continue
1148
+ if file_path and file_content:
1149
+ code_hashes[file_path] = content_hash(file_content)
1150
+
1151
+ # 2. LLM detection on file contents from write/edit tool calls
1152
+ # (pattern-based detection misses many concepts like HTML structure,
1153
+ # CSS patterns, DOM APIs, etc.) Mode-gated: skipped when
1154
+ # llm_detection is off (autonomous mode) since it costs an LLM call
1155
+ # per written file.
1156
+ llm_detection: bool = True if mode_config is None else bool(
1157
+ mode_config.get("llm_detection", True)
1158
+ )
1159
+ # Names already found by the registry scan this turn are merged in
1160
+ # here too: the LLM would otherwise detect them again, and a
1161
+ # diagram-less duplicate could even trigger a backfill call — for a
1162
+ # concept the merge below discards anyway.
1163
+ known_names: set[str] = {c["name"] for c in concepts} | seen
1164
+
1165
+ for tc in (tool_calls_log if llm_detection else []):
1166
+ fn = tc.get("function", {})
1167
+ name = fn.get("name", "")
1168
+ try:
1169
+ args = json.loads(fn.get("arguments", "{}"))
1170
+ except (json.JSONDecodeError, TypeError):
1171
+ continue
1172
+ if name == "write_file":
1173
+ file_path = args.get("file_path", "")
1174
+ content = args.get("content", "")
1175
+ elif name == "edit_file":
1176
+ file_path = args.get("file_path", "")
1177
+ content = args.get("new_string", "")
1178
+ else:
1179
+ continue
1180
+ if not content:
1181
+ continue
1182
+ # Generation-time identity cache: identical file content was
1183
+ # already LLM-scanned — skip the expensive generation call
1184
+ # itself, not merely its backfill or the final write. This is
1185
+ # the check that keeps a resurfacing concept with unchanged
1186
+ # code from paying for a detection whose output save_teaching
1187
+ # would only discard.
1188
+ file_hash = content_hash(content)
1189
+ if file_hash and file_scan_cached(session, file_path, file_hash):
1190
+ continue
1191
+ llm_detected = detect_concepts_with_llm(
1192
+ file_path, content, known_names,
1193
+ session=session, code_hash=file_hash,
1194
+ )
1195
+ for c in llm_detected:
1196
+ if c.name not in known_names and c.name not in seen:
1197
+ known_names.add(c.name)
1198
+ seen.add(c.name)
1199
+ detected.append(c)
1200
+
1201
+ return {
1202
+ "concepts_detected": [
1203
+ {
1204
+ "name": c.name,
1205
+ "slug": concept_slug(c.name),
1206
+ "category": c.category,
1207
+ "subcategory": c.subcategory,
1208
+ "description": c.description,
1209
+ "decision": c.decision,
1210
+ "diagram": c.diagram,
1211
+ "source_file": c.source_file,
1212
+ "content_hash": code_hashes.get(c.source_file, ""),
1213
+ "line_range": list(c.line_range),
1214
+ }
1215
+ for c in detected
1216
+ ],
1217
+ }