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.
- gini/__init__.py +12 -0
- gini/__main__.py +107 -0
- gini/_version.py +24 -0
- gini/agent/__init__.py +17 -0
- gini/agent/agent_gamemaster.py +140 -0
- gini/agent/api.py +291 -0
- gini/agent/ask.py +123 -0
- gini/agent/authoring.py +72 -0
- gini/agent/blackboard.py +114 -0
- gini/agent/contracts.py +142 -0
- gini/agent/domains.py +91 -0
- gini/agent/embed.py +123 -0
- gini/agent/gamemaster.py +256 -0
- gini/agent/kb.py +148 -0
- gini/agent/lesson_resolver.py +261 -0
- gini/agent/llm/__init__.py +5 -0
- gini/agent/llm/backend.py +43 -0
- gini/agent/llm/fake.py +25 -0
- gini/agent/llm/ollama.py +206 -0
- gini/agent/loop.py +258 -0
- gini/agent/mcp_server.py +86 -0
- gini/agent/meaning.py +225 -0
- gini/agent/mission.py +210 -0
- gini/agent/mission_controller.py +208 -0
- gini/agent/narration.py +116 -0
- gini/agent/notifier.py +86 -0
- gini/agent/personas.py +79 -0
- gini/agent/reasoning.py +172 -0
- gini/agent/recall.py +248 -0
- gini/agent/session.py +79 -0
- gini/agent/teaching_center.py +482 -0
- gini/agent/tools/__init__.py +3 -0
- gini/agent/tools/registry.py +193 -0
- gini/agent/twin/__init__.py +28 -0
- gini/agent/twin/authoring.py +71 -0
- gini/agent/twin/contracts.py +54 -0
- gini/agent/twin/dialectic.py +189 -0
- gini/agent/twin/harness.py +93 -0
- gini/agent/twin/justify.py +156 -0
- gini/agent/twin/learner.py +64 -0
- gini/agent/twin/mission.py +60 -0
- gini/agent/twin/os_coach.py +79 -0
- gini/agent/twin/salience.py +30 -0
- gini/agent/understand.py +250 -0
- gini/agent/verifiers.py +106 -0
- gini/agent/wizard.py +178 -0
- gini/agent/xv6_pack.py +74 -0
- gini/app/__init__.py +3 -0
- gini/app/context.py +368 -0
- gini/app/paths.py +121 -0
- gini/data/README.md +21 -0
- gini/domain/__init__.py +9 -0
- gini/domain/assembly.py +209 -0
- gini/domain/authoring.py +353 -0
- gini/domain/blueprints.py +5 -0
- gini/domain/capabilities.py +177 -0
- gini/domain/catalog.py +85 -0
- gini/domain/certify.py +201 -0
- gini/domain/compose.py +413 -0
- gini/domain/composition.py +88 -0
- gini/domain/concepts.py +383 -0
- gini/domain/connection_rules.py +269 -0
- gini/domain/constraints.py +153 -0
- gini/domain/content.py +59 -0
- gini/domain/cpu_journey.py +89 -0
- gini/domain/devices.py +747 -0
- gini/domain/diagnose.py +201 -0
- gini/domain/element_guide.py +327 -0
- gini/domain/explain.py +90 -0
- gini/domain/fingerprint.py +201 -0
- gini/domain/firewall.py +34 -0
- gini/domain/flowlog.py +61 -0
- gini/domain/flowtable.py +179 -0
- gini/domain/fragment_yaml.py +230 -0
- gini/domain/fragments.py +169 -0
- gini/domain/games/__init__.py +2 -0
- gini/domain/games/paging_games.py +119 -0
- gini/domain/games/policy_game.py +86 -0
- gini/domain/games/process_game.py +48 -0
- gini/domain/games/thrash_game.py +75 -0
- gini/domain/games/translate_game.py +60 -0
- gini/domain/games/trap_game.py +86 -0
- gini/domain/grader.py +155 -0
- gini/domain/grouping.py +67 -0
- gini/domain/legality.py +103 -0
- gini/domain/lesson.py +241 -0
- gini/domain/lexicon.py +150 -0
- gini/domain/machine_state.py +410 -0
- gini/domain/missions/networking/basic-lan.yaml +32 -0
- gini/domain/missions/networking/cache-in-front.yaml +23 -0
- gini/domain/missions/networking/decouple-with-queue.yaml +31 -0
- gini/domain/missions/networking/drive-load.yaml +20 -0
- gini/domain/missions/networking/fix-the-address.yaml +75 -0
- gini/domain/missions/networking/fix-the-lan.yaml +43 -0
- gini/domain/missions/networking/inspect-flows.yaml +16 -0
- gini/domain/missions/networking/k8s-autoscale.yaml +27 -0
- gini/domain/missions/networking/least-privilege.yaml +21 -0
- gini/domain/missions/networking/load-balanced-web.yaml +29 -0
- gini/domain/missions/networking/observe-it.yaml +24 -0
- gini/domain/missions/networking/put-in-vpc.yaml +30 -0
- gini/domain/missions/networking/reachability-boundary.yaml +56 -0
- gini/domain/missions/networking/sdn-reactive.yaml +35 -0
- gini/domain/missions/networking/send-request.yaml +19 -0
- gini/domain/missions/networking/serverless-api.yaml +25 -0
- gini/domain/missions/networking/service-chain.yaml +33 -0
- gini/domain/missions/os/lottery-fix.yaml +19 -0
- gini/domain/missions/os/priority-fix.yaml +24 -0
- gini/domain/missions.py +111 -0
- gini/domain/modulechain.py +36 -0
- gini/domain/objectives.py +488 -0
- gini/domain/os_zoo.py +79 -0
- gini/domain/paging_sim.py +141 -0
- gini/domain/pricing.py +199 -0
- gini/domain/probes.py +226 -0
- gini/domain/profile.py +142 -0
- gini/domain/recipes.py +738 -0
- gini/domain/riders.py +309 -0
- gini/domain/router_modules.py +224 -0
- gini/domain/routetable.py +67 -0
- gini/domain/scoring.py +76 -0
- gini/domain/staging.py +122 -0
- gini/domain/syscall_builder.py +144 -0
- gini/domain/topic_cloud.py +62 -0
- gini/domain/topology.py +213 -0
- gini/domain/vocabulary.py +51 -0
- gini/domain/xv6.py +808 -0
- gini/domain/xv6_fs.py +250 -0
- gini/domain/xv6_runner.py +113 -0
- gini/domain/xv6_vm.py +385 -0
- gini/gloader.py +17 -0
- gini/runtime/__init__.py +18 -0
- gini/runtime/cloudfabric_agent.py +370 -0
- gini/runtime/console.py +68 -0
- gini/runtime/control.py +70 -0
- gini/runtime/frame.py +138 -0
- gini/runtime/gbridge.py +638 -0
- gini/runtime/grouter.py +223 -0
- gini/runtime/hostsim.py +90 -0
- gini/runtime/shuttle.py +348 -0
- gini/runtime/switch.py +109 -0
- gini/runtime/transport.py +77 -0
- gini/runtime/xv6_bridge.py +312 -0
- gini/server/__init__.py +22 -0
- gini/server/__main__.py +74 -0
- gini/server/app.py +140 -0
- gini/server/auth.py +82 -0
- gini/server/policy.py +57 -0
- gini/server/session.py +23 -0
- gini/services/__init__.py +15 -0
- gini/services/boardflash.py +248 -0
- gini/services/boardsetup.py +374 -0
- gini/services/cloud_catalog.py +143 -0
- gini/services/compiler.py +1858 -0
- gini/services/discovery.py +324 -0
- gini/services/gloader.py +183 -0
- gini/services/orchestrator.py +1460 -0
- gini/services/persistence.py +28 -0
- gini/services/probe_runner.py +149 -0
- gini/services/project.py +217 -0
- gini/services/remote.py +93 -0
- gini/services/rider_runner.py +96 -0
- gini/services/rider_session.py +171 -0
- gini/services/shadow_store.py +52 -0
- gini/services/terminal.py +45 -0
- gini/setup/__init__.py +17 -0
- gini/setup/cli.py +109 -0
- gini/setup/images.py +33 -0
- gini/setup/marker.py +43 -0
- gini/setup/runtime.py +69 -0
- gini/ui/__init__.py +3 -0
- gini/ui/assets/app_icon.icns +0 -0
- gini/ui/assets/app_icon.ico +0 -0
- gini/ui/assets/app_icon.png +0 -0
- gini/ui/assets/app_icon_1024.png +0 -0
- gini/ui/assets/cue/_w.txt +1 -0
- gini/ui/assets/cue/ai.png +0 -0
- gini/ui/assets/cue/canvas.png +0 -0
- gini/ui/assets/cue/cloud.png +0 -0
- gini/ui/assets/cue/cost.png +0 -0
- gini/ui/assets/cue/dark/ai.png +0 -0
- gini/ui/assets/cue/dark/canvas.png +0 -0
- gini/ui/assets/cue/dark/cloud.png +0 -0
- gini/ui/assets/cue/dark/cost.png +0 -0
- gini/ui/assets/cue/dark/metrics.png +0 -0
- gini/ui/assets/cue/dark/router.png +0 -0
- gini/ui/assets/cue/dark/run.png +0 -0
- gini/ui/assets/cue/dark/serverless.png +0 -0
- gini/ui/assets/cue/dark/settings.png +0 -0
- gini/ui/assets/cue/dark/welcome.png +0 -0
- gini/ui/assets/cue/dark/wizard.png +0 -0
- gini/ui/assets/cue/ginibrand/ai.png +0 -0
- gini/ui/assets/cue/ginibrand/canvas.png +0 -0
- gini/ui/assets/cue/ginibrand/cloud.png +0 -0
- gini/ui/assets/cue/ginibrand/cost.png +0 -0
- gini/ui/assets/cue/ginibrand/metrics.png +0 -0
- gini/ui/assets/cue/ginibrand/router.png +0 -0
- gini/ui/assets/cue/ginibrand/run.png +0 -0
- gini/ui/assets/cue/ginibrand/serverless.png +0 -0
- gini/ui/assets/cue/ginibrand/settings.png +0 -0
- gini/ui/assets/cue/ginibrand/welcome.png +0 -0
- gini/ui/assets/cue/ginibrand/wizard.png +0 -0
- gini/ui/assets/cue/highcontrast/ai.png +0 -0
- gini/ui/assets/cue/highcontrast/canvas.png +0 -0
- gini/ui/assets/cue/highcontrast/cloud.png +0 -0
- gini/ui/assets/cue/highcontrast/cost.png +0 -0
- gini/ui/assets/cue/highcontrast/metrics.png +0 -0
- gini/ui/assets/cue/highcontrast/router.png +0 -0
- gini/ui/assets/cue/highcontrast/run.png +0 -0
- gini/ui/assets/cue/highcontrast/serverless.png +0 -0
- gini/ui/assets/cue/highcontrast/settings.png +0 -0
- gini/ui/assets/cue/highcontrast/welcome.png +0 -0
- gini/ui/assets/cue/highcontrast/wizard.png +0 -0
- gini/ui/assets/cue/light/ai.png +0 -0
- gini/ui/assets/cue/light/canvas.png +0 -0
- gini/ui/assets/cue/light/cloud.png +0 -0
- gini/ui/assets/cue/light/cost.png +0 -0
- gini/ui/assets/cue/light/metrics.png +0 -0
- gini/ui/assets/cue/light/router.png +0 -0
- gini/ui/assets/cue/light/run.png +0 -0
- gini/ui/assets/cue/light/serverless.png +0 -0
- gini/ui/assets/cue/light/settings.png +0 -0
- gini/ui/assets/cue/light/welcome.png +0 -0
- gini/ui/assets/cue/light/wizard.png +0 -0
- gini/ui/assets/cue/metrics.png +0 -0
- gini/ui/assets/cue/router.png +0 -0
- gini/ui/assets/cue/run.png +0 -0
- gini/ui/assets/cue/serverless.png +0 -0
- gini/ui/assets/cue/settings.png +0 -0
- gini/ui/assets/cue/welcome.png +0 -0
- gini/ui/assets/cue/wizard.png +0 -0
- gini/ui/assistant.py +2111 -0
- gini/ui/author_dialog.py +184 -0
- gini/ui/board_dialog.py +247 -0
- gini/ui/branding.py +21 -0
- gini/ui/canvas.py +2007 -0
- gini/ui/chat_panel.py +7 -0
- gini/ui/cpu_journey.py +212 -0
- gini/ui/cpu_lab.py +306 -0
- gini/ui/cue_cards.py +214 -0
- gini/ui/dashboard.py +222 -0
- gini/ui/diagnose_game.py +336 -0
- gini/ui/fingerprint_lab.py +219 -0
- gini/ui/flash_dialog.py +244 -0
- gini/ui/flow_layout.py +63 -0
- gini/ui/fragment_manager.py +1415 -0
- gini/ui/game_catalog.py +184 -0
- gini/ui/game_renderers.py +340 -0
- gini/ui/games_lab.py +90 -0
- gini/ui/inspector.py +1055 -0
- gini/ui/live_metrics.py +130 -0
- gini/ui/machine_lab.py +1412 -0
- gini/ui/main_window.py +3153 -0
- gini/ui/memory_lab.py +371 -0
- gini/ui/mission_panel.py +302 -0
- gini/ui/mode_indicator.py +227 -0
- gini/ui/palette.py +112 -0
- gini/ui/peripherals.py +218 -0
- gini/ui/process_tree.py +130 -0
- gini/ui/reset_dialog.py +179 -0
- gini/ui/router_lab.py +776 -0
- gini/ui/run_button.py +183 -0
- gini/ui/settings_dialog.py +234 -0
- gini/ui/signin_dialog.py +111 -0
- gini/ui/storage_lab.py +219 -0
- gini/ui/syscall_builder.py +235 -0
- gini/ui/syscall_lab.py +152 -0
- gini/ui/theme/__init__.py +5 -0
- gini/ui/theme/icons.py +145 -0
- gini/ui/theme/manager.py +291 -0
- gini/ui/theme/tokens.py +194 -0
- gini/ui/trap_lab.py +270 -0
- gini/ui/worker_host.py +102 -0
- gini/ui/zoo_lab.py +112 -0
- gini_toolkit-6.0.1.dev0.dist-info/METADATA +77 -0
- gini_toolkit-6.0.1.dev0.dist-info/RECORD +278 -0
- gini_toolkit-6.0.1.dev0.dist-info/WHEEL +5 -0
- gini_toolkit-6.0.1.dev0.dist-info/entry_points.txt +3 -0
- gini_toolkit-6.0.1.dev0.dist-info/top_level.txt +1 -0
gini/agent/gamemaster.py
ADDED
|
@@ -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])
|