gini-toolkit 6.0.1.dev0__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 (278) hide show
  1. gini/__init__.py +12 -0
  2. gini/__main__.py +107 -0
  3. gini/_version.py +24 -0
  4. gini/agent/__init__.py +17 -0
  5. gini/agent/agent_gamemaster.py +140 -0
  6. gini/agent/api.py +291 -0
  7. gini/agent/ask.py +123 -0
  8. gini/agent/authoring.py +72 -0
  9. gini/agent/blackboard.py +114 -0
  10. gini/agent/contracts.py +142 -0
  11. gini/agent/domains.py +91 -0
  12. gini/agent/embed.py +123 -0
  13. gini/agent/gamemaster.py +256 -0
  14. gini/agent/kb.py +148 -0
  15. gini/agent/lesson_resolver.py +261 -0
  16. gini/agent/llm/__init__.py +5 -0
  17. gini/agent/llm/backend.py +43 -0
  18. gini/agent/llm/fake.py +25 -0
  19. gini/agent/llm/ollama.py +206 -0
  20. gini/agent/loop.py +258 -0
  21. gini/agent/mcp_server.py +86 -0
  22. gini/agent/meaning.py +225 -0
  23. gini/agent/mission.py +210 -0
  24. gini/agent/mission_controller.py +208 -0
  25. gini/agent/narration.py +116 -0
  26. gini/agent/notifier.py +86 -0
  27. gini/agent/personas.py +79 -0
  28. gini/agent/reasoning.py +172 -0
  29. gini/agent/recall.py +248 -0
  30. gini/agent/session.py +79 -0
  31. gini/agent/teaching_center.py +482 -0
  32. gini/agent/tools/__init__.py +3 -0
  33. gini/agent/tools/registry.py +193 -0
  34. gini/agent/twin/__init__.py +28 -0
  35. gini/agent/twin/authoring.py +71 -0
  36. gini/agent/twin/contracts.py +54 -0
  37. gini/agent/twin/dialectic.py +189 -0
  38. gini/agent/twin/harness.py +93 -0
  39. gini/agent/twin/justify.py +156 -0
  40. gini/agent/twin/learner.py +64 -0
  41. gini/agent/twin/mission.py +60 -0
  42. gini/agent/twin/os_coach.py +79 -0
  43. gini/agent/twin/salience.py +30 -0
  44. gini/agent/understand.py +250 -0
  45. gini/agent/verifiers.py +106 -0
  46. gini/agent/wizard.py +178 -0
  47. gini/agent/xv6_pack.py +74 -0
  48. gini/app/__init__.py +3 -0
  49. gini/app/context.py +368 -0
  50. gini/app/paths.py +121 -0
  51. gini/data/README.md +21 -0
  52. gini/domain/__init__.py +9 -0
  53. gini/domain/assembly.py +209 -0
  54. gini/domain/authoring.py +353 -0
  55. gini/domain/blueprints.py +5 -0
  56. gini/domain/capabilities.py +177 -0
  57. gini/domain/catalog.py +85 -0
  58. gini/domain/certify.py +201 -0
  59. gini/domain/compose.py +413 -0
  60. gini/domain/composition.py +88 -0
  61. gini/domain/concepts.py +383 -0
  62. gini/domain/connection_rules.py +269 -0
  63. gini/domain/constraints.py +153 -0
  64. gini/domain/content.py +59 -0
  65. gini/domain/cpu_journey.py +89 -0
  66. gini/domain/devices.py +747 -0
  67. gini/domain/diagnose.py +201 -0
  68. gini/domain/element_guide.py +327 -0
  69. gini/domain/explain.py +90 -0
  70. gini/domain/fingerprint.py +201 -0
  71. gini/domain/firewall.py +34 -0
  72. gini/domain/flowlog.py +61 -0
  73. gini/domain/flowtable.py +179 -0
  74. gini/domain/fragment_yaml.py +230 -0
  75. gini/domain/fragments.py +169 -0
  76. gini/domain/games/__init__.py +2 -0
  77. gini/domain/games/paging_games.py +119 -0
  78. gini/domain/games/policy_game.py +86 -0
  79. gini/domain/games/process_game.py +48 -0
  80. gini/domain/games/thrash_game.py +75 -0
  81. gini/domain/games/translate_game.py +60 -0
  82. gini/domain/games/trap_game.py +86 -0
  83. gini/domain/grader.py +155 -0
  84. gini/domain/grouping.py +67 -0
  85. gini/domain/legality.py +103 -0
  86. gini/domain/lesson.py +241 -0
  87. gini/domain/lexicon.py +150 -0
  88. gini/domain/machine_state.py +410 -0
  89. gini/domain/missions/networking/basic-lan.yaml +32 -0
  90. gini/domain/missions/networking/cache-in-front.yaml +23 -0
  91. gini/domain/missions/networking/decouple-with-queue.yaml +31 -0
  92. gini/domain/missions/networking/drive-load.yaml +20 -0
  93. gini/domain/missions/networking/fix-the-address.yaml +75 -0
  94. gini/domain/missions/networking/fix-the-lan.yaml +43 -0
  95. gini/domain/missions/networking/inspect-flows.yaml +16 -0
  96. gini/domain/missions/networking/k8s-autoscale.yaml +27 -0
  97. gini/domain/missions/networking/least-privilege.yaml +21 -0
  98. gini/domain/missions/networking/load-balanced-web.yaml +29 -0
  99. gini/domain/missions/networking/observe-it.yaml +24 -0
  100. gini/domain/missions/networking/put-in-vpc.yaml +30 -0
  101. gini/domain/missions/networking/reachability-boundary.yaml +56 -0
  102. gini/domain/missions/networking/sdn-reactive.yaml +35 -0
  103. gini/domain/missions/networking/send-request.yaml +19 -0
  104. gini/domain/missions/networking/serverless-api.yaml +25 -0
  105. gini/domain/missions/networking/service-chain.yaml +33 -0
  106. gini/domain/missions/os/lottery-fix.yaml +19 -0
  107. gini/domain/missions/os/priority-fix.yaml +24 -0
  108. gini/domain/missions.py +111 -0
  109. gini/domain/modulechain.py +36 -0
  110. gini/domain/objectives.py +488 -0
  111. gini/domain/os_zoo.py +79 -0
  112. gini/domain/paging_sim.py +141 -0
  113. gini/domain/pricing.py +199 -0
  114. gini/domain/probes.py +226 -0
  115. gini/domain/profile.py +142 -0
  116. gini/domain/recipes.py +738 -0
  117. gini/domain/riders.py +309 -0
  118. gini/domain/router_modules.py +224 -0
  119. gini/domain/routetable.py +67 -0
  120. gini/domain/scoring.py +76 -0
  121. gini/domain/staging.py +122 -0
  122. gini/domain/syscall_builder.py +144 -0
  123. gini/domain/topic_cloud.py +62 -0
  124. gini/domain/topology.py +213 -0
  125. gini/domain/vocabulary.py +51 -0
  126. gini/domain/xv6.py +808 -0
  127. gini/domain/xv6_fs.py +250 -0
  128. gini/domain/xv6_runner.py +113 -0
  129. gini/domain/xv6_vm.py +385 -0
  130. gini/gloader.py +17 -0
  131. gini/runtime/__init__.py +18 -0
  132. gini/runtime/cloudfabric_agent.py +370 -0
  133. gini/runtime/console.py +68 -0
  134. gini/runtime/control.py +70 -0
  135. gini/runtime/frame.py +138 -0
  136. gini/runtime/gbridge.py +638 -0
  137. gini/runtime/grouter.py +223 -0
  138. gini/runtime/hostsim.py +90 -0
  139. gini/runtime/shuttle.py +348 -0
  140. gini/runtime/switch.py +109 -0
  141. gini/runtime/transport.py +77 -0
  142. gini/runtime/xv6_bridge.py +312 -0
  143. gini/server/__init__.py +22 -0
  144. gini/server/__main__.py +74 -0
  145. gini/server/app.py +140 -0
  146. gini/server/auth.py +82 -0
  147. gini/server/policy.py +57 -0
  148. gini/server/session.py +23 -0
  149. gini/services/__init__.py +15 -0
  150. gini/services/boardflash.py +248 -0
  151. gini/services/boardsetup.py +374 -0
  152. gini/services/cloud_catalog.py +143 -0
  153. gini/services/compiler.py +1858 -0
  154. gini/services/discovery.py +324 -0
  155. gini/services/gloader.py +183 -0
  156. gini/services/orchestrator.py +1460 -0
  157. gini/services/persistence.py +28 -0
  158. gini/services/probe_runner.py +149 -0
  159. gini/services/project.py +217 -0
  160. gini/services/remote.py +93 -0
  161. gini/services/rider_runner.py +96 -0
  162. gini/services/rider_session.py +171 -0
  163. gini/services/shadow_store.py +52 -0
  164. gini/services/terminal.py +45 -0
  165. gini/setup/__init__.py +17 -0
  166. gini/setup/cli.py +109 -0
  167. gini/setup/images.py +33 -0
  168. gini/setup/marker.py +43 -0
  169. gini/setup/runtime.py +69 -0
  170. gini/ui/__init__.py +3 -0
  171. gini/ui/assets/app_icon.icns +0 -0
  172. gini/ui/assets/app_icon.ico +0 -0
  173. gini/ui/assets/app_icon.png +0 -0
  174. gini/ui/assets/app_icon_1024.png +0 -0
  175. gini/ui/assets/cue/_w.txt +1 -0
  176. gini/ui/assets/cue/ai.png +0 -0
  177. gini/ui/assets/cue/canvas.png +0 -0
  178. gini/ui/assets/cue/cloud.png +0 -0
  179. gini/ui/assets/cue/cost.png +0 -0
  180. gini/ui/assets/cue/dark/ai.png +0 -0
  181. gini/ui/assets/cue/dark/canvas.png +0 -0
  182. gini/ui/assets/cue/dark/cloud.png +0 -0
  183. gini/ui/assets/cue/dark/cost.png +0 -0
  184. gini/ui/assets/cue/dark/metrics.png +0 -0
  185. gini/ui/assets/cue/dark/router.png +0 -0
  186. gini/ui/assets/cue/dark/run.png +0 -0
  187. gini/ui/assets/cue/dark/serverless.png +0 -0
  188. gini/ui/assets/cue/dark/settings.png +0 -0
  189. gini/ui/assets/cue/dark/welcome.png +0 -0
  190. gini/ui/assets/cue/dark/wizard.png +0 -0
  191. gini/ui/assets/cue/ginibrand/ai.png +0 -0
  192. gini/ui/assets/cue/ginibrand/canvas.png +0 -0
  193. gini/ui/assets/cue/ginibrand/cloud.png +0 -0
  194. gini/ui/assets/cue/ginibrand/cost.png +0 -0
  195. gini/ui/assets/cue/ginibrand/metrics.png +0 -0
  196. gini/ui/assets/cue/ginibrand/router.png +0 -0
  197. gini/ui/assets/cue/ginibrand/run.png +0 -0
  198. gini/ui/assets/cue/ginibrand/serverless.png +0 -0
  199. gini/ui/assets/cue/ginibrand/settings.png +0 -0
  200. gini/ui/assets/cue/ginibrand/welcome.png +0 -0
  201. gini/ui/assets/cue/ginibrand/wizard.png +0 -0
  202. gini/ui/assets/cue/highcontrast/ai.png +0 -0
  203. gini/ui/assets/cue/highcontrast/canvas.png +0 -0
  204. gini/ui/assets/cue/highcontrast/cloud.png +0 -0
  205. gini/ui/assets/cue/highcontrast/cost.png +0 -0
  206. gini/ui/assets/cue/highcontrast/metrics.png +0 -0
  207. gini/ui/assets/cue/highcontrast/router.png +0 -0
  208. gini/ui/assets/cue/highcontrast/run.png +0 -0
  209. gini/ui/assets/cue/highcontrast/serverless.png +0 -0
  210. gini/ui/assets/cue/highcontrast/settings.png +0 -0
  211. gini/ui/assets/cue/highcontrast/welcome.png +0 -0
  212. gini/ui/assets/cue/highcontrast/wizard.png +0 -0
  213. gini/ui/assets/cue/light/ai.png +0 -0
  214. gini/ui/assets/cue/light/canvas.png +0 -0
  215. gini/ui/assets/cue/light/cloud.png +0 -0
  216. gini/ui/assets/cue/light/cost.png +0 -0
  217. gini/ui/assets/cue/light/metrics.png +0 -0
  218. gini/ui/assets/cue/light/router.png +0 -0
  219. gini/ui/assets/cue/light/run.png +0 -0
  220. gini/ui/assets/cue/light/serverless.png +0 -0
  221. gini/ui/assets/cue/light/settings.png +0 -0
  222. gini/ui/assets/cue/light/welcome.png +0 -0
  223. gini/ui/assets/cue/light/wizard.png +0 -0
  224. gini/ui/assets/cue/metrics.png +0 -0
  225. gini/ui/assets/cue/router.png +0 -0
  226. gini/ui/assets/cue/run.png +0 -0
  227. gini/ui/assets/cue/serverless.png +0 -0
  228. gini/ui/assets/cue/settings.png +0 -0
  229. gini/ui/assets/cue/welcome.png +0 -0
  230. gini/ui/assets/cue/wizard.png +0 -0
  231. gini/ui/assistant.py +2111 -0
  232. gini/ui/author_dialog.py +184 -0
  233. gini/ui/board_dialog.py +247 -0
  234. gini/ui/branding.py +21 -0
  235. gini/ui/canvas.py +2007 -0
  236. gini/ui/chat_panel.py +7 -0
  237. gini/ui/cpu_journey.py +212 -0
  238. gini/ui/cpu_lab.py +306 -0
  239. gini/ui/cue_cards.py +214 -0
  240. gini/ui/dashboard.py +222 -0
  241. gini/ui/diagnose_game.py +336 -0
  242. gini/ui/fingerprint_lab.py +219 -0
  243. gini/ui/flash_dialog.py +244 -0
  244. gini/ui/flow_layout.py +63 -0
  245. gini/ui/fragment_manager.py +1415 -0
  246. gini/ui/game_catalog.py +184 -0
  247. gini/ui/game_renderers.py +340 -0
  248. gini/ui/games_lab.py +90 -0
  249. gini/ui/inspector.py +1055 -0
  250. gini/ui/live_metrics.py +130 -0
  251. gini/ui/machine_lab.py +1412 -0
  252. gini/ui/main_window.py +3153 -0
  253. gini/ui/memory_lab.py +371 -0
  254. gini/ui/mission_panel.py +302 -0
  255. gini/ui/mode_indicator.py +227 -0
  256. gini/ui/palette.py +112 -0
  257. gini/ui/peripherals.py +218 -0
  258. gini/ui/process_tree.py +130 -0
  259. gini/ui/reset_dialog.py +179 -0
  260. gini/ui/router_lab.py +776 -0
  261. gini/ui/run_button.py +183 -0
  262. gini/ui/settings_dialog.py +234 -0
  263. gini/ui/signin_dialog.py +111 -0
  264. gini/ui/storage_lab.py +219 -0
  265. gini/ui/syscall_builder.py +235 -0
  266. gini/ui/syscall_lab.py +152 -0
  267. gini/ui/theme/__init__.py +5 -0
  268. gini/ui/theme/icons.py +145 -0
  269. gini/ui/theme/manager.py +291 -0
  270. gini/ui/theme/tokens.py +194 -0
  271. gini/ui/trap_lab.py +270 -0
  272. gini/ui/worker_host.py +102 -0
  273. gini/ui/zoo_lab.py +112 -0
  274. gini_toolkit-6.0.1.dev0.dist-info/METADATA +77 -0
  275. gini_toolkit-6.0.1.dev0.dist-info/RECORD +278 -0
  276. gini_toolkit-6.0.1.dev0.dist-info/WHEEL +5 -0
  277. gini_toolkit-6.0.1.dev0.dist-info/entry_points.txt +3 -0
  278. gini_toolkit-6.0.1.dev0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,256 @@
1
+ """The Game Master — the pivotal per-turn reasoning loop of a Mission.
2
+
3
+ Each turn the game master reasons over three inputs and emits ONE move:
4
+
5
+ TEACHER INTENT — the lesson's goal + spirit + misconceptions (from its Game-Catalog
6
+ archetype); what success really means, mechanism-free.
7
+ STUDENT INTENT — what the student means / is attempting, interpreted (never keyword-matched)
8
+ from their words and the change in the canvas.
9
+ RUNTIME FACTS — the objective results GINI's runtime produced (met / unmet / pending).
10
+
11
+ Deterministic *guards* frame the decision (completion, expiry, help level), but the interpretation
12
+ of the student and the phrasing of every message are the reasoning LLM's job — this is where the AI
13
+ is genuinely drawn into the loop. GINI owns the facts; the game master owns the understanding.
14
+
15
+ Missions are hard-gated on a reasoning LLM: with `llm=None` the game master is inert (returns a
16
+ `quiet` move), matching the design's "no model → no Mission." Pure orchestration + prompts; the
17
+ `llm` callable is injected, so the whole loop is unit-testable with a scripted model.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import re
23
+ from dataclasses import dataclass
24
+
25
+ # student progress reads
26
+ ATTEMPTING, STUCK, ASKING_HINT, SYMPTOM, OFF_TASK, CELEBRATING, SILENT = (
27
+ "attempting", "stuck", "asking_hint", "reporting_symptom", "off_task", "celebrating", "silent")
28
+
29
+ # move kinds
30
+ BRIEF, NUDGE, HINT, INTERPRET, ANSWER, CELEBRATE, DEFEAT, QUIET = (
31
+ "brief", "nudge", "hint", "interpret", "answer", "celebrate", "defeat", "quiet")
32
+
33
+ _PERSONA_VOICE = {
34
+ "coach": "You are a warm, encouraging coach on the student's side. Be supportive and concise.",
35
+ "challenger": "You are a playful challenger running a contest. Be spirited and a little "
36
+ "competitive, but never mean. Keep it short.",
37
+ }
38
+
39
+
40
+ @dataclass
41
+ class StudentRead:
42
+ progress: str = SILENT
43
+ objective_ref: str = ""
44
+ is_question: bool = False
45
+ raw: str = ""
46
+
47
+
48
+ @dataclass
49
+ class Move:
50
+ kind: str
51
+ text: str = ""
52
+ objective_ref: str = ""
53
+ logged: bool = False # help usage recorded to the profile (full_tutor_logged)
54
+
55
+
56
+ def _first_json(text: str):
57
+ depth, start = 0, None
58
+ for i, ch in enumerate(text or ""):
59
+ if ch == "{":
60
+ if depth == 0:
61
+ start = i
62
+ depth += 1
63
+ elif ch == "}" and depth > 0:
64
+ depth -= 1
65
+ if depth == 0 and start is not None:
66
+ try:
67
+ return json.loads(text[start:i + 1])
68
+ except json.JSONDecodeError:
69
+ start = None
70
+ return None
71
+
72
+
73
+ class GameMaster:
74
+ def __init__(self, lesson, llm=None, *, persona: str | None = None) -> None:
75
+ self.lesson = lesson
76
+ self.llm = llm
77
+ self.persona = persona or getattr(lesson, "persona", "coach")
78
+ self._prev_met: int | None = None # for warmer/colder deltas
79
+
80
+ # -- LLM plumbing ------------------------------------------------------- #
81
+ def _ask(self, prompt: str) -> str:
82
+ if self.llm is None:
83
+ return ""
84
+ try:
85
+ return self.llm(prompt) or ""
86
+ except Exception:
87
+ return ""
88
+
89
+ def _voice(self) -> str:
90
+ return _PERSONA_VOICE.get(self.persona, _PERSONA_VOICE["coach"])
91
+
92
+ def _intent(self) -> str:
93
+ it = self.lesson.intent
94
+ parts = [f"Lesson goal: {it.goal}"] if it.goal else []
95
+ if it.spirit:
96
+ parts.append(f"What success means (the SPIRIT — any mechanism counts): {it.spirit}")
97
+ if it.misconceptions:
98
+ parts.append("Common misconceptions: " + "; ".join(it.misconceptions))
99
+ return "\n".join(parts)
100
+
101
+ # -- student intent interpretation (reasoning, not keyword) ------------- #
102
+ def interpret(self, utterance: str, results) -> StudentRead:
103
+ """Interpret what the student means / is attempting. With an utterance the LLM classifies
104
+ it against THIS lesson's objectives; with none, infer a light read from the facts."""
105
+ if not (utterance or "").strip():
106
+ met = sum(1 for r in results if r.met)
107
+ return StudentRead(progress=ATTEMPTING if met < len(results) else CELEBRATING)
108
+ objs = "; ".join(f"{r.id}: {r.say} [{r.status}]" for r in results)
109
+ prompt = (
110
+ f"{self._voice()}\nYou interpret a student mid-lab. {self._intent()}\n"
111
+ f"Objectives (id: goal [state]): {objs}\n"
112
+ f'Student said: {utterance!r}\n'
113
+ "Reply ONLY as JSON: {\"progress\": attempting|stuck|asking_hint|reporting_symptom|"
114
+ "off_task|celebrating, \"objective_ref\": \"<objective id or empty>\", "
115
+ "\"is_question\": true|false}. No prose.")
116
+ obj = _first_json(self._ask(prompt))
117
+ if not isinstance(obj, dict):
118
+ # couldn't parse → treat a '?' as a question, else attempting (never keyword-decide meaning)
119
+ return StudentRead(progress=ASKING_HINT if "?" in utterance else ATTEMPTING,
120
+ is_question="?" in utterance, raw=utterance)
121
+ prog = obj.get("progress")
122
+ prog = prog if prog in (ATTEMPTING, STUCK, ASKING_HINT, SYMPTOM, OFF_TASK, CELEBRATING) \
123
+ else ATTEMPTING
124
+ return StudentRead(progress=prog, objective_ref=str(obj.get("objective_ref", "")),
125
+ is_question=bool(obj.get("is_question", False)), raw=utterance)
126
+
127
+ # -- the reasoning loop ------------------------------------------------- #
128
+ def decide(self, mission, results, *, utterance: str = "", world_digest: str = "") -> Move:
129
+ """Reason over teacher intent + student intent + runtime facts → one move."""
130
+ if self.llm is None:
131
+ return Move(QUIET) # no model → inert (Missions are LLM-gated)
132
+
133
+ help_level = self.lesson.help
134
+ met_now = sum(1 for r in results if r.met)
135
+ first_turn = self._prev_met is None
136
+ delta = 0 if first_turn else met_now - self._prev_met
137
+ self._prev_met = met_now
138
+ read = self.interpret(utterance, results)
139
+
140
+ # --- deterministic guards frame the move; the LLM interprets + phrases ---
141
+ if mission.complete:
142
+ return Move(CELEBRATE, self._say_victory(mission))
143
+ if getattr(mission, "state", "") == "done" and not mission.complete:
144
+ return Move(DEFEAT, self._say_encourage(results))
145
+
146
+ # a question / explicit ask — governed by the lesson's help level
147
+ if read.is_question or read.progress in (ASKING_HINT, SYMPTOM):
148
+ if help_level == "none":
149
+ return Move(QUIET) # proctored / no-help: stay silent
150
+ if help_level == "full_tutor_logged":
151
+ return Move(ANSWER, self._answer(utterance, results), logged=True)
152
+ return Move(HINT, self._hint(read, results), objective_ref=read.objective_ref)
153
+
154
+ # warmer / colder — grounded in the change in met objectives
155
+ if help_level != "none" and not first_turn and delta > 0:
156
+ return Move(NUDGE, self._warmer(results))
157
+ if help_level != "none" and not first_turn and delta < 0:
158
+ return Move(NUDGE, self._colder(results))
159
+
160
+ # stuck or drifting → interpret + guide (spirit-aware), if help allows
161
+ if help_level != "none" and read.progress == STUCK:
162
+ return Move(HINT, self._hint(read, results), objective_ref=read.objective_ref)
163
+ if read.progress == OFF_TASK:
164
+ return Move(INTERPRET, self._redirect(results))
165
+
166
+ return Move(QUIET)
167
+
168
+ def observe(self, mission, results) -> Move:
169
+ """Convenience for an action-only turn (no utterance) — e.g. the canvas changed."""
170
+ return self.decide(mission, results)
171
+
172
+ # -- phrasing (the LLM's voice, grounded in intent + facts) ------------- #
173
+ def _unmet(self, results):
174
+ return [r for r in results if not r.met]
175
+
176
+ def _facts(self, results) -> str:
177
+ return "; ".join(f"{r.say} [{r.status}]" for r in results)
178
+
179
+ def brief_line(self) -> str:
180
+ """Narrate the mission brief in character (falls back to the authored brief text)."""
181
+ return self._ask(
182
+ f"{self._voice()}\n{self._intent()}\nBrief the student on this mission in 1-2 short "
183
+ f"lines, in character, ending by starting the clock. The task: {self.lesson.brief}"
184
+ ) or self.lesson.brief
185
+
186
+ # -- guided beats (multi-turn) ----------------------------------------- #
187
+ def present_step(self, step, index: int, total: int, *, acked: str = "") -> str:
188
+ """Present ONE beat in character. `acked` (optional) is a one-line acknowledgement of what
189
+ the student just did, so consecutive beats feel like a conversation, not a list."""
190
+ lead = (f"The student just completed the previous beat ({acked}). Acknowledge it in a few "
191
+ "words, then " if acked else "")
192
+ return self._ask(
193
+ f"{self._voice()}\n{self._intent()}\nThis is a guided lab, beat {index}/{total}. "
194
+ f"{lead}give the student THIS one instruction, in character, in 1-2 short lines — just "
195
+ f"this step, don't reveal later steps: {step.say}") or step.say
196
+
197
+ def react_reply(self, step, utterance: str, results) -> str:
198
+ """Respond to the student's answer on a read/reflect beat, then they move on."""
199
+ return self._ask(
200
+ f"{self._voice()}\n{self._intent()}\nThe student was asked: {step.say!r} and replied: "
201
+ f"{utterance!r}. Respond in 1-2 short lines — affirm or gently correct, grounded in the "
202
+ "lesson's goal; then we move on.") or "Good — let's continue."
203
+
204
+ def off_step_nudge(self, step, results) -> str:
205
+ """The student did something that doesn't satisfy the current beat — a spirit-aware nudge."""
206
+ return self._ask(
207
+ f"{self._voice()}\n{self._intent()}\nThe current beat asks: {step.say!r}. The student "
208
+ f"acted but hasn't satisfied it yet. Objectives: {self._facts(results)}. Give ONE short "
209
+ "nudge toward THIS beat's goal — don't do it for them.") or "Not quite yet — keep at this step."
210
+
211
+ def flag_note(self, reasons) -> str:
212
+ """Call out an off-task / wrongly-wired move the student just made — playfully firm, and
213
+ it tells them to FIX it (the game master never deletes anything itself)."""
214
+ joined = "; ".join(reasons)
215
+ return self._ask(
216
+ f"{self._voice()}\n{self._intent()}\nThe student just did something that doesn't fit "
217
+ f"this mission: {joined}. In ONE short, in-character line, flag it and tell them to fix "
218
+ "it — do NOT remove it for them.") or ("Heads up — " + joined)
219
+
220
+ def _say_victory(self, mission) -> str:
221
+ return self._ask(f"{self._voice()}\n{self._intent()}\nThe student just completed the "
222
+ f"mission ({mission.score().summary}). Congratulate them in ONE short, "
223
+ "specific line tied to what they achieved.") or "Nice — you did it!"
224
+
225
+ def _say_encourage(self, results) -> str:
226
+ return self._ask(f"{self._voice()}\nThe attempt ended incomplete. Objectives: "
227
+ f"{self._facts(results)}. Encourage the student in ONE short line and "
228
+ "point at what's still open — no solution.") or "Out of time — give it another go."
229
+
230
+ def _warmer(self, results) -> str:
231
+ return self._ask(f"{self._voice()}\n{self._intent()}\nThe student just got CLOSER. "
232
+ f"Objectives: {self._facts(results)}. Say 'warmer' in ONE short line, "
233
+ "naming what improved — do NOT reveal the next step.") or "Warmer — keep going."
234
+
235
+ def _colder(self, results) -> str:
236
+ return self._ask(f"{self._voice()}\nThe student just moved AWAY from the goal. Objectives: "
237
+ f"{self._facts(results)}. Say 'colder' gently in ONE short line — no "
238
+ "solution.") or "Hmm, that went the wrong way."
239
+
240
+ def _hint(self, read: StudentRead, results) -> str:
241
+ # prefer an authored hint for the referenced/nearest-unmet objective if the lesson has one
242
+ target = read.objective_ref or (self._unmet(results)[0].id if self._unmet(results) else "")
243
+ return self._ask(
244
+ f"{self._voice()}\n{self._intent()}\nThe student seems stuck on objective {target!r}. "
245
+ f"Objectives: {self._facts(results)}. Give ONE nudging hint toward the GOAL (spirit, "
246
+ "not a specific mechanism) — do not hand them the answer.") or "Think about the goal, not the tool."
247
+
248
+ def _answer(self, utterance: str, results) -> str:
249
+ return self._ask(
250
+ f"{self._voice()}\n{self._intent()}\nObjectives: {self._facts(results)}. The student "
251
+ f"asked: {utterance!r}. Answer helpfully and concisely, grounded in the lesson's goal; "
252
+ "guide, don't just solve it for them.") or "Let's think it through together."
253
+
254
+ def _redirect(self, results) -> str:
255
+ return self._ask(f"{self._voice()}\n{self._intent()}\nThe student drifted off-task. In ONE "
256
+ "friendly line, steer them back to the mission's goal.") or "Let's refocus on the task."
gini/agent/kb.py ADDED
@@ -0,0 +1,148 @@
1
+ """GINI knowledge base — retrieval over the four assets that ground the agent.
2
+
3
+ Assets: the element catalog (`devices`), the connection grammar (`connection_rules`), the
4
+ concept notes (`concepts`), and the recipes (`recipes`). Two tiers:
5
+
6
+ • `always_on_context()` — the compact element index + connection groups injected EVERY
7
+ turn (~1.3K tokens, flat as the palette grows). Keeps the model on GINI's vocabulary.
8
+ • `retrieve(intent, topology)` — the question-specific detail: concept notes for the
9
+ topics (depth-gated), per-element cards with grammar-valid partners, and the recipe to
10
+ describe/build. Pulled on demand and folded into the session accumulator by the caller.
11
+
12
+ Pure logic (no Qt); `topology` is duck-typed (needs `.devices` values with `.name`/`.type`).
13
+ `intent` is duck-typed too (`.topics`, `.anchor`, `.output_form`, `.refs`, `.depth`) so this
14
+ module doesn't depend on `understand` and stays independently testable.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass, field
19
+
20
+ from ..domain import connection_rules as _cr
21
+ from ..domain.devices import REGISTRY, all_devices
22
+
23
+ _HIDDEN = {"k8s_node"} # not on the palette; never surface it
24
+
25
+
26
+ @dataclass
27
+ class Card:
28
+ """A titled knowledge snippet destined for the reasoning prompt."""
29
+ kind: str # element | grammar | concept | recipe
30
+ key: str
31
+ title: str
32
+ text: str
33
+
34
+
35
+ @dataclass
36
+ class Retrieval:
37
+ cards: list[Card] = field(default_factory=list)
38
+ recipe: object | None = None # recipes.Recipe when a build/show is implied
39
+ strength: str = "empty" # recall confidence (strong|thin|empty) → grounding stance
40
+
41
+ def as_context(self) -> str:
42
+ """Flatten to a context block for the model (empty string if nothing retrieved)."""
43
+ if not self.cards:
44
+ return ""
45
+ return "Relevant GINI knowledge:\n" + "\n".join(f"- {c.text}" for c in self.cards)
46
+
47
+
48
+ # -- always-on tier --------------------------------------------------------- #
49
+ def _palette():
50
+ return [d for d in all_devices() if d.key not in _HIDDEN]
51
+
52
+
53
+ def element_index() -> str:
54
+ """Compact one-line-per-element catalog, grouped by category."""
55
+ by_cat: dict[str, list[str]] = {}
56
+ for d in _palette():
57
+ by_cat.setdefault(d.category.value, []).append(f"{d.key} — {d.description}")
58
+ lines = [f"[{cat}] " + "; ".join(items) for cat, items in by_cat.items()]
59
+ return "GINI elements (the ONLY elements available):\n" + "\n".join(lines)
60
+
61
+
62
+ def group_defs() -> str:
63
+ """The connection grammar's reusable groups — the compressed shape of the matrix."""
64
+ parts = [f"{g}={{{', '.join(v)}}}" for g, v in _cr.GROUPS.items()]
65
+ return "Connection groups: " + "; ".join(parts) + "."
66
+
67
+
68
+ def always_on_context() -> str:
69
+ return element_index() + "\n" + group_defs()
70
+
71
+
72
+ # -- per-question tier ------------------------------------------------------ #
73
+ def _partners_line(type_key: str, cap: int = 10) -> str:
74
+ ps = _cr.partners_for(type_key)
75
+ if not ps:
76
+ return "no standard connections"
77
+ bits = [f"{p.type_key}{'*' if p.required else ''}" for p in ps[:cap]]
78
+ more = "" if len(ps) <= cap else f", +{len(ps) - cap} more"
79
+ return "connects to " + ", ".join(bits) + more + " (*=required)"
80
+
81
+
82
+ def element_card(type_key: str) -> Card | None:
83
+ dt = REGISTRY.get(type_key)
84
+ if dt is None or type_key in _HIDDEN:
85
+ return None
86
+ text = f"{dt.key} ({dt.label}): {dt.description} — {_partners_line(type_key)}"
87
+ return Card("element", type_key, dt.label, text)
88
+
89
+
90
+ def concept_card(c) -> Card:
91
+ return Card("concept", c.key, c.title, f"{c.title}: {c.body}")
92
+
93
+
94
+ def retrieve(intent, topology=None, *, llm=None, embedder=None) -> Retrieval:
95
+ """Question-specific knowledge for an Intent. See module docstring for the contract.
96
+
97
+ Retrieval runs through the hybrid `recall` layer (lexical → LLM-expansion → embeddings);
98
+ pass `llm`/`embedder` to enable the L1/L2 fallbacks (they do I/O, so the caller only wires
99
+ them on the worker thread). `out.strength` reports match confidence for the grounding
100
+ stance."""
101
+ from . import recall as _recall
102
+
103
+ anchor = getattr(intent, "anchor", "concept")
104
+ output_form = getattr(intent, "output_form", "tell")
105
+ depth = getattr(intent, "depth", "shallow")
106
+ topics = list(getattr(intent, "topics", []) or [])
107
+ refs = list(getattr(intent, "refs", []) or [])
108
+ query = getattr(intent, "query", "") or " ".join(topics)
109
+
110
+ out = Retrieval()
111
+ elem_keys: list[str] = []
112
+
113
+ res = _recall.recall(query, topics, llm=llm, embedder=embedder, deep=(depth == "deep"))
114
+ out.strength = res.strength
115
+
116
+ # concept notes (depth-gated): deep questions get more of the "how it works" layer
117
+ for h in res.concept_hits[: (2 if depth == "deep" else 1)]:
118
+ c = h.obj
119
+ out.cards.append(concept_card(c))
120
+ elem_keys.extend(c.elements)
121
+
122
+ # recipe pick — for generative/build asks, choose the pattern to describe or build
123
+ if anchor in ("concept", "hybrid") and res.recipe_hits:
124
+ out.recipe = res.recipe_hits[0].obj
125
+ elem_keys.extend(el.type_key for el in out.recipe.elements)
126
+ uniq = list(dict.fromkeys(el.type_key for el in out.recipe.elements))
127
+ verb = "Example to build" if output_form in ("show", "guide") else "Canonical pattern"
128
+ out.cards.append(Card("recipe", out.recipe.id, out.recipe.name,
129
+ f"{verb} — {out.recipe.name}: {out.recipe.summary} "
130
+ f"(uses ONLY these GINI elements: {', '.join(uniq)})"))
131
+
132
+ # canvas anchor: pull the referenced devices' element cards (map name -> type)
133
+ if anchor in ("canvas", "hybrid") and refs and topology is not None:
134
+ by_name = {d.name: d for d in topology.devices.values()}
135
+ for r in refs:
136
+ d = by_name.get(r)
137
+ if d is not None:
138
+ elem_keys.append(d.type.key if hasattr(d, "type") else d.type_key)
139
+
140
+ # element cards (dedup, order-preserving)
141
+ seen: set[str] = set()
142
+ for k in elem_keys:
143
+ if k and k not in seen and k not in _HIDDEN:
144
+ seen.add(k)
145
+ card = element_card(k)
146
+ if card is not None:
147
+ out.cards.append(card)
148
+ return out
@@ -0,0 +1,261 @@
1
+ """Lesson resolver — the "teacher can be vague" path.
2
+
3
+ Because the arena is bounded, the games are pre-authored (the Game Catalog). So a professor need
4
+ not hand-write objectives: they express intent loosely ("teach keeping a database private, ~25
5
+ min"), the resolver **matches it to an archetype and proposes a concrete Lesson**, and the
6
+ professor **ratifies** (approves/tweaks). This keeps the professor sovereign — the AI drafts, the
7
+ human disposes.
8
+
9
+ Two stages, cheap-first:
10
+ 1. a lexical prefilter shortlists candidate archetypes (fast, deterministic);
11
+ 2. a reasoning LLM picks the best archetype from the shortlist, fills its params, and suggests a
12
+ time limit / title (the reasoning step). With no model, the resolver degrades to the top
13
+ lexical candidate + demo params — still a usable proposal to ratify.
14
+
15
+ Returns a `Proposal` (never auto-releases). Pure except the injected `llm`, so it's testable with
16
+ a scripted model.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ from dataclasses import dataclass, field
22
+
23
+ from ..domain import catalog as _catalog
24
+ from ..domain import lexicon as _lex
25
+ from ..domain import lesson as _lesson
26
+
27
+
28
+ @dataclass
29
+ class Proposal:
30
+ archetype_id: str
31
+ params: dict
32
+ lesson: object # domain.lesson.Lesson (None when infeasible)
33
+ rationale: str = ""
34
+ candidates: list = field(default_factory=list) # shortlisted archetype ids (for the UI)
35
+ infeasible: str = "" # set (with lesson=None) when DOs and DON'Ts conflict
36
+ suppressed: str = "" # human note: what was left out to honour the DON'Ts
37
+
38
+
39
+ def _archetype_text(a) -> str:
40
+ return " ".join((a.id.replace("-", " "), a.summary, a.spirit, a.teaches.replace("-", " ")))
41
+
42
+
43
+ def shortlist(intent_text: str, k: int = 5) -> list:
44
+ """Lexically rank archetypes against the teacher's intent (the cheap prefilter)."""
45
+ want = set(_lex.normalize(intent_text, query=True))
46
+ scored = []
47
+ for a in _catalog.all_archetypes():
48
+ toks = set(_lex.normalize(_archetype_text(a)))
49
+ overlap = len(want & toks)
50
+ if overlap:
51
+ scored.append((overlap, a))
52
+ scored.sort(key=lambda s: -s[0])
53
+ picks = [a for _, a in scored[:k]]
54
+ return picks or list(_catalog.all_archetypes())[:k]
55
+
56
+
57
+ def lexical_scores(intent_text: str) -> list[tuple[str, int]]:
58
+ """Archetypes scored by overlap with the intent, best first (score > 0 only). A word in the
59
+ archetype's DEFINING fields (id + summary) counts double a word that only appears incidentally in
60
+ the spirit/teaches — so a game that IS about a firewall (service-chain) outranks one that merely
61
+ mentions a firewall as one option (reachability-boundary)."""
62
+ want = set(_lex.normalize(intent_text, query=True))
63
+ scored = []
64
+ for a in _catalog.all_archetypes():
65
+ strong = set(_lex.normalize(a.id.replace("-", " ") + " " + a.summary))
66
+ weak = set(_lex.normalize(a.spirit + " " + a.teaches.replace("-", " ")))
67
+ score = 2 * len(want & strong) + len(want & (weak - strong))
68
+ if score:
69
+ scored.append((a.id, score))
70
+ scored.sort(key=lambda s: -s[1])
71
+ return scored
72
+
73
+
74
+ def best_lexical(intent_text: str) -> str:
75
+ """The single best lexical match (or '' if nothing overlaps)."""
76
+ s = lexical_scores(intent_text)
77
+ return s[0][0] if s else ""
78
+
79
+
80
+ _PROMPT = (
81
+ "A teacher wants a lab. Their intent: {intent!r}. Choose the best-fitting game from this "
82
+ "catalog and fill its parameters with short device names.\n{menu}\n"
83
+ "Reply ONLY as JSON: {{\"archetype\": \"<id>\", \"params\": {{<ref>: <name>, ...}}, "
84
+ "\"time_limit\": \"<e.g. 25m>\", \"title\": \"<short title>\"}}. No prose."
85
+ )
86
+
87
+
88
+ def _menu(cands) -> str:
89
+ lines = []
90
+ for a in cands:
91
+ refs = ", ".join(a.params)
92
+ lines.append(f"- {a.id} (params: {refs}) — {a.summary}")
93
+ return "Catalog:\n" + "\n".join(lines)
94
+
95
+
96
+ def _first_json(text: str):
97
+ depth, start = 0, None
98
+ for i, ch in enumerate(text or ""):
99
+ if ch == "{":
100
+ if depth == 0:
101
+ start = i
102
+ depth += 1
103
+ elif ch == "}" and depth > 0:
104
+ depth -= 1
105
+ if depth == 0 and start is not None:
106
+ try:
107
+ return json.loads(text[start:i + 1])
108
+ except json.JSONDecodeError:
109
+ start = None
110
+ return None
111
+
112
+
113
+ def resolve(intent_text: str, llm=None, *, lesson_id: str = "", **overrides) -> Proposal | None:
114
+ """Resolve a vague teacher intent into a proposed Lesson to ratify. `llm(prompt)->str` is the
115
+ reasoning step; omit it for a deterministic (lexical-only) proposal."""
116
+ cands = shortlist(intent_text)
117
+ if not cands:
118
+ return None
119
+
120
+ pick_id, params, time_limit, title, rationale = "", {}, "", "", ""
121
+ if llm is not None:
122
+ try:
123
+ obj = _first_json(llm(_PROMPT.format(intent=intent_text, menu=_menu(cands))))
124
+ except Exception:
125
+ obj = None
126
+ if isinstance(obj, dict):
127
+ pick_id = str(obj.get("archetype", ""))
128
+ if isinstance(obj.get("params"), dict):
129
+ params = {str(k): str(v) for k, v in obj["params"].items()}
130
+ time_limit = str(obj.get("time_limit", ""))
131
+ title = str(obj.get("title", ""))
132
+
133
+ arch = _catalog.get(pick_id) or cands[0] # fall back to the top lexical candidate
134
+ # ensure every ref is bound: fill any gaps from the archetype's demo params
135
+ demo = _catalog.demo_params(arch.id)
136
+ for ref in arch.params:
137
+ params.setdefault(ref, demo.get(ref, ref.upper()))
138
+ params = {ref: params[ref] for ref in arch.params} # drop stray keys, keep order
139
+
140
+ les = _lesson.from_archetype(
141
+ arch.id, params, id=lesson_id or f"resolved-{arch.id}",
142
+ title=title or arch.summary, brief=arch.summary,
143
+ time_limit=time_limit or overrides.get("time_limit", "20m"),
144
+ **{k: v for k, v in overrides.items() if k != "time_limit"})
145
+ return Proposal(archetype_id=arch.id, params=params, lesson=les,
146
+ rationale=rationale or f"Matched intent to '{arch.id}'.",
147
+ candidates=[a.id for a in cands])
148
+
149
+
150
+ # -- student-facing composer ("describe a mission and I'll build it") -------- #
151
+ # Unlike `resolve` (a teacher drafting a lesson to ratify), `compose` is the STUDENT path: it turns
152
+ # a free-form wish into a playable mission on the spot. Crucially it does NOT invent objectives — it
153
+ # *selects* the closest catalog archetype and may *combine* a second, so every win-condition comes
154
+ # from a verified, gradable archetype. The LLM only interprets intent + reskins the framing; a
155
+ # `validate()` guardrail drops the composition back to the primary archetype if it isn't gradable.
156
+ # That keeps the "it made a mission just for me" magic without ever dropping a student into an
157
+ # unwinnable mission — the exact split the design asks for (GINI owns structure; the LLM owns
158
+ # understanding).
159
+
160
+ _COMPOSE_PROMPT = (
161
+ "A student wants to practise with a hands-on lab mission. They said: {intent!r}.\n"
162
+ "Pick the SINGLE best-fitting game below. Only if their goal clearly needs a second game to be "
163
+ "complete, also pick a secondary to combine with it. Then write a short title and a 1-2 "
164
+ "sentence brief that frames the mission in the STUDENT'S own words. Optionally pick a genre — "
165
+ "experience (build it & watch), expedition (investigate toward a goal), or challenge (prove it, "
166
+ "little help). List anything the student explicitly does NOT want (e.g. 'metrics', 'dashboard') "
167
+ "in \"exclude\".\n{menu}\n"
168
+ "Reply ONLY as JSON: {{\"primary\": \"<id>\", \"secondary\": \"<id or empty>\", "
169
+ "\"genre\": \"<experience|expedition|challenge or empty>\", \"exclude\": [\"<thing to leave out>\"], "
170
+ "\"title\": \"<short title>\", \"brief\": \"<1-2 sentences>\"}}. No prose."
171
+ )
172
+
173
+
174
+ def compose(intent_text: str, llm=None, *, lesson_id: str = "", persona: str = "coach",
175
+ genre: str | None = None, level: int | None = None, fill: bool = False,
176
+ **overrides) -> Proposal | None:
177
+ """Compose a playable Lesson from a student's free-form wish by ASSEMBLING catalog fragments
178
+ (select the closest core, optionally a second the model explicitly names). Objectives are never
179
+ authored — only copied from verified fragments. Returns a Proposal (its `.lesson` is ready to
180
+ launch), or None if nothing is even lexically close.
181
+
182
+ `fill` defaults to FALSE for the describe box: a described mission stays LEAN — just what the
183
+ student asked for. We do NOT auto-add exercise/observe layers (load generators, dashboards,
184
+ metrics) they never requested. Enrichment is opt-in / for authored curricula, not terse wishes."""
185
+ from ..domain import assembly as _assembly
186
+ from ..domain import constraints as _con
187
+ from ..domain import fragments as _frag
188
+ cands = shortlist(intent_text)
189
+ if not cands:
190
+ return None
191
+
192
+ primary_id = secondary_id = title = brief = ""
193
+ g = genre
194
+ if llm is not None:
195
+ try:
196
+ obj = _first_json(llm(_COMPOSE_PROMPT.format(intent=intent_text, menu=_menu(cands))))
197
+ except Exception:
198
+ obj = None
199
+ if isinstance(obj, dict):
200
+ primary_id = str(obj.get("primary", ""))
201
+ secondary_id = str(obj.get("secondary", ""))
202
+ title = str(obj.get("title", ""))
203
+ brief = str(obj.get("brief", ""))
204
+ if g is None and obj.get("genre") in (_assembly.EXPERIENCE, _assembly.EXPEDITION,
205
+ _assembly.CHALLENGE):
206
+ g = str(obj.get("genre"))
207
+
208
+ # DON'Ts: a reliable, model-free negation scan of the intent (the model's self-volunteered
209
+ # `exclude` was dropped — small models hallucinate exclusions, e.g. an unasked "no dashboard").
210
+ excl = _con.from_text(intent_text)
211
+
212
+ # Core selection is faithful to the LITERAL words: a STRONG lexical match is the primary (so the
213
+ # student gets what they typed, even if a small model over-elaborates and picks a different game).
214
+ # For fuzzy intent (no strong match) we trust the model's interpretation. A SECOND core is added
215
+ # only when the model explicitly names one — we do NOT auto-combine, which caused "private db,
216
+ # public web" to also drag in an unasked message-queue mission.
217
+ scores = lexical_scores(_con.positive_text(intent_text))
218
+ if scores and scores[0][1] >= 3:
219
+ primary = _catalog.get(scores[0][0]) or cands[0]
220
+ else:
221
+ primary = _catalog.get(primary_id) or cands[0]
222
+
223
+ # A mission must never MISREPRESENT itself. The model wrote title/brief from ITS reading of the
224
+ # intent, BEFORE we knew which fragments would actually be assembled — so that narrative is only
225
+ # a guess about the mission. Throw it away unconditionally; we re-narrate below against the
226
+ # assembled objectives, which is the only text that can be checked for truth.
227
+ title = brief = ""
228
+
229
+ core_ids = [primary.id]
230
+ if secondary_id and secondary_id != primary.id and _catalog.get(secondary_id) is not None:
231
+ core_ids.append(secondary_id) # a second core only on an explicit request
232
+ core_ids = core_ids[:2]
233
+
234
+ lid = lesson_id or f"described-{primary.id}"
235
+ lesson = _assembly.assemble(core_ids, genre=g, level=level, lesson_id=lid, title=title,
236
+ brief=brief, persona=persona, exclude=excl, fill=fill, **overrides)
237
+
238
+ # feasibility: if a thing the student asked FOR still needs a thing they asked to leave OUT,
239
+ # don't quietly build a mismatched mission — report the conflict back
240
+ # NOW the mission exists, so now it can be described truthfully. The model writes a short title
241
+ # and a real description FROM the assembled objectives, and `narrate` rejects any prose that
242
+ # claims an element nothing grades. If it can't tell the truth we keep the fragments' own
243
+ # summary — which is true by construction, just less personal.
244
+ if llm is not None:
245
+ from . import narration as _narr
246
+ t, d = _narr.narrate(lesson, intent_text, llm)
247
+ if t and d:
248
+ lesson.title, lesson.brief = t, d
249
+
250
+ conflict = _con.objective_conflicts(lesson.objectives, excl)
251
+ if conflict:
252
+ want = ", ".join(conflict)
253
+ return Proposal(archetype_id=primary.id, params={}, lesson=None,
254
+ candidates=[a.id for a in cands],
255
+ infeasible=f"That doesn't quite work: this mission needs {want}, but you "
256
+ f"asked to leave that out. Drop that exclusion, or ask for a "
257
+ f"topology that doesn't rely on {want}.")
258
+ suppressed = excl.label() if excl else ""
259
+ return Proposal(archetype_id=primary.id, params={}, lesson=lesson, suppressed=suppressed,
260
+ rationale=f"Assembled from {', '.join(lesson.fragments)}.",
261
+ candidates=[a.id for a in cands])
@@ -0,0 +1,5 @@
1
+ from .backend import Chunk, LLMBackend, Message, ToolCall
2
+ from .fake import ScriptedBackend
3
+ from .ollama import OllamaBackend
4
+
5
+ __all__ = ["Chunk", "LLMBackend", "Message", "ToolCall", "ScriptedBackend", "OllamaBackend"]