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/meaning.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""The meaning-side agents around the Reasoning persona: Understanding, Critic, the always-on
|
|
2
|
+
fast-path Classifier, and the MissionAgent that orchestrates one turn (GINI_MISSIONS_AGENT_ARCHITECTURE
|
|
3
|
+
.md §3-5). All are the SAME model in different personas; each is a narrow task with a strict small
|
|
4
|
+
output — the way we keep a small model reliable.
|
|
5
|
+
|
|
6
|
+
Turn flow: classify (always) → understand (if an utterance and routed) → reason → critique + verify
|
|
7
|
+
claims (if routed) → optionally one revision. Understanding & Critic are stateless; Reasoning owns the
|
|
8
|
+
shared memory.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
from .contracts import CONCEPT, ENTITY, META, NEXT_STEP, OBJECTIVE, OFF_TASK, Intent, Move
|
|
15
|
+
from .personas import Persona, PersonaRunner, first_json
|
|
16
|
+
from .reasoning import ReasoningAgent
|
|
17
|
+
|
|
18
|
+
_KINDS = {CONCEPT, ENTITY, OBJECTIVE, NEXT_STEP, OFF_TASK, META}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ---- Understanding -------------------------------------------------------- #
|
|
22
|
+
class UnderstandingAgent:
|
|
23
|
+
persona = Persona("Understanding",
|
|
24
|
+
system="You classify a student's message during a hands-on lab. Output ONLY "
|
|
25
|
+
"JSON: {\"kind\": concept|entity|objective|next_step|off_task|meta, "
|
|
26
|
+
"\"objective_ref\": \"<objective id or empty>\", \"refs\": [\"<name>\"]}. "
|
|
27
|
+
"No prose.", temperature=0.1)
|
|
28
|
+
|
|
29
|
+
def __init__(self, runner: PersonaRunner, lesson) -> None:
|
|
30
|
+
self.runner = runner
|
|
31
|
+
self.lesson = lesson
|
|
32
|
+
|
|
33
|
+
def parse(self, utterance: str) -> Intent:
|
|
34
|
+
objs = "; ".join(f"{o.id}: {o.say}" for o in self.lesson.objectives)
|
|
35
|
+
out = self.runner.call(self.persona, context=f"Objectives — {objs}",
|
|
36
|
+
task=f"Message: {utterance!r}")
|
|
37
|
+
obj = first_json(out)
|
|
38
|
+
if not isinstance(obj, dict):
|
|
39
|
+
# tolerant fallback (never keyword-decide meaning): a '?' reads as a question
|
|
40
|
+
return Intent(kind=OBJECTIVE if "?" in utterance else META, text=utterance)
|
|
41
|
+
kind = obj.get("kind") if obj.get("kind") in _KINDS else META
|
|
42
|
+
return Intent(kind=kind, text=utterance,
|
|
43
|
+
refs=tuple(str(r) for r in obj.get("refs", []) or []),
|
|
44
|
+
objective_ref=str(obj.get("objective_ref", "")))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ---- Critic --------------------------------------------------------------- #
|
|
48
|
+
@dataclass
|
|
49
|
+
class Critique:
|
|
50
|
+
ok: bool = True
|
|
51
|
+
missing: tuple[str, ...] = ()
|
|
52
|
+
unsupported: tuple[str, ...] = ()
|
|
53
|
+
|
|
54
|
+
def note(self) -> str:
|
|
55
|
+
bits = []
|
|
56
|
+
if self.missing:
|
|
57
|
+
bits.append("missing: " + "; ".join(self.missing))
|
|
58
|
+
if self.unsupported:
|
|
59
|
+
bits.append("unsupported: " + "; ".join(self.unsupported))
|
|
60
|
+
return " / ".join(bits)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class CriticAgent:
|
|
64
|
+
persona = Persona("Critic",
|
|
65
|
+
system="You audit a draft tutor line against the FACTS. Output ONLY JSON: "
|
|
66
|
+
"{\"ok\": true|false, \"missing\": [\"...\"], \"unsupported\": [\"...\"]}. "
|
|
67
|
+
"'unsupported' = claims not backed by the facts; 'missing' = the question "
|
|
68
|
+
"isn't actually answered. No prose.", temperature=0.0)
|
|
69
|
+
|
|
70
|
+
def __init__(self, runner: PersonaRunner) -> None:
|
|
71
|
+
self.runner = runner
|
|
72
|
+
|
|
73
|
+
def audit(self, move: Move, facts: str, *, question: str = "") -> Critique:
|
|
74
|
+
out = self.runner.call(self.persona, context="FACTS:\n" + facts,
|
|
75
|
+
task=f"Question: {question!r}\nDraft: {move.text!r}")
|
|
76
|
+
obj = first_json(out)
|
|
77
|
+
if not isinstance(obj, dict):
|
|
78
|
+
return Critique(ok=True)
|
|
79
|
+
return Critique(ok=bool(obj.get("ok", True)),
|
|
80
|
+
missing=tuple(str(x) for x in obj.get("missing", []) or []),
|
|
81
|
+
unsupported=tuple(str(x) for x in obj.get("unsupported", []) or []))
|
|
82
|
+
|
|
83
|
+
@staticmethod
|
|
84
|
+
def verify_claims(move: Move, blackboard) -> list[str]:
|
|
85
|
+
"""Deterministic grounding check: any Claim whose expected value contradicts the blackboard.
|
|
86
|
+
This is the oracle re-checking the model, independent of the LLM Critic."""
|
|
87
|
+
bad = []
|
|
88
|
+
for c in move.claims:
|
|
89
|
+
v = blackboard.verdict(c.predicate)
|
|
90
|
+
if v is not None and v.value != c.expected:
|
|
91
|
+
bad.append(c.predicate)
|
|
92
|
+
return bad
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ---- fast-path classifier (always-on) ------------------------------------- #
|
|
96
|
+
@dataclass
|
|
97
|
+
class Route:
|
|
98
|
+
reason: bool = True
|
|
99
|
+
understand: bool = False
|
|
100
|
+
critic: bool = False
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class Classifier:
|
|
104
|
+
persona = Persona("Router",
|
|
105
|
+
system="You route one turn of a lab tutor. Output ONLY JSON: {\"reason\": "
|
|
106
|
+
"true|false, \"understand\": true|false, \"critic\": true|false}. "
|
|
107
|
+
"reason=does it need a reply; understand=is there a message to interpret; "
|
|
108
|
+
"critic=is it worth auditing (complex/answering a question). No prose.",
|
|
109
|
+
temperature=0.0)
|
|
110
|
+
|
|
111
|
+
def __init__(self, runner: PersonaRunner) -> None:
|
|
112
|
+
self.runner = runner
|
|
113
|
+
|
|
114
|
+
def route(self, *, change: str = "", utterance: str = "") -> Route:
|
|
115
|
+
# deterministic fallback when offline: reply to any trigger, understand iff there's a message
|
|
116
|
+
if self.runner is None or self.runner._llm is None:
|
|
117
|
+
return Route(reason=bool(change or utterance), understand=bool(utterance), critic=False)
|
|
118
|
+
out = self.runner.call(self.persona,
|
|
119
|
+
context=f"change={change or 'none'} utterance={utterance!r}",
|
|
120
|
+
task="Route this turn.")
|
|
121
|
+
obj = first_json(out) or {}
|
|
122
|
+
understand = bool(obj.get("understand", bool(utterance))) and bool(utterance)
|
|
123
|
+
return Route(reason=bool(obj.get("reason", True)), understand=understand,
|
|
124
|
+
critic=bool(obj.get("critic", False)))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ---- orchestration -------------------------------------------------------- #
|
|
128
|
+
class MissionAgent:
|
|
129
|
+
"""One model wearing all three hats, plus the deterministic blackboard, run as a single turn.
|
|
130
|
+
With a `twin` (Reasoning 2.0), the turn is additionally audited for COVERAGE: the Twin
|
|
131
|
+
enumerates the concerns that matter, diffs the persona's coverage report exactly, objects
|
|
132
|
+
once ("why not X?"), and flags whatever survives. `twin=None` ≡ the pre-Twin behavior."""
|
|
133
|
+
|
|
134
|
+
def __init__(self, runner: PersonaRunner, blackboard, lesson, twin=None) -> None:
|
|
135
|
+
self.runner = runner
|
|
136
|
+
self.bb = blackboard
|
|
137
|
+
self.lesson = lesson
|
|
138
|
+
self.classifier = Classifier(runner)
|
|
139
|
+
self.understanding = UnderstandingAgent(runner, lesson)
|
|
140
|
+
self.reasoning = ReasoningAgent(runner, blackboard, lesson)
|
|
141
|
+
self.critic = CriticAgent(runner)
|
|
142
|
+
self.twin = twin
|
|
143
|
+
self.last_twin_result = None # TwinResult of the last audited turn (tests/metrics)
|
|
144
|
+
|
|
145
|
+
def turn(self, trigger=None, *, utterance: str = "") -> Move:
|
|
146
|
+
change = getattr(trigger, "change", "") if trigger is not None else ""
|
|
147
|
+
route = self.classifier.route(change=change, utterance=utterance) # always-on
|
|
148
|
+
if not route.reason:
|
|
149
|
+
return Move("quiet")
|
|
150
|
+
intent = self.understanding.parse(utterance) if (route.understand and utterance) else None
|
|
151
|
+
react_trigger = intent if intent is not None else (
|
|
152
|
+
trigger if trigger is not None else Intent(text=utterance))
|
|
153
|
+
concerns = self._concerns()
|
|
154
|
+
move = self.reasoning.react(react_trigger, coverage_concerns=concerns or None)
|
|
155
|
+
|
|
156
|
+
bad = self.critic.verify_claims(move, self.bb) # deterministic oracle check (always)
|
|
157
|
+
if route.critic or bad:
|
|
158
|
+
crit = self.critic.audit(move, self.reasoning._context(), question=utterance)
|
|
159
|
+
note = crit.note()
|
|
160
|
+
if bad:
|
|
161
|
+
note = (note + " / " if note else "") + "contradicts facts: " + "; ".join(bad)
|
|
162
|
+
if not crit.ok or crit.unsupported or bad:
|
|
163
|
+
move = self.reasoning.react(react_trigger, note=note, # one revision pass
|
|
164
|
+
coverage_concerns=concerns or None)
|
|
165
|
+
return self._twin_audit(move, react_trigger, concerns)
|
|
166
|
+
|
|
167
|
+
# -- Reasoning 2.0: the Twin's coverage audit --------------------------- #
|
|
168
|
+
def _concerns(self) -> list:
|
|
169
|
+
if self.twin is None:
|
|
170
|
+
return []
|
|
171
|
+
try:
|
|
172
|
+
from .twin.mission import mission_concerns
|
|
173
|
+
return mission_concerns(self.bb, self.lesson)
|
|
174
|
+
except Exception:
|
|
175
|
+
return [] # the Twin is strictly additive — never break a turn
|
|
176
|
+
|
|
177
|
+
def _twin_ctx(self, move: Move, react_trigger):
|
|
178
|
+
"""The adjudication context for this turn: what shape of move this is, what (if anything)
|
|
179
|
+
was asked, the live board, the covered-history, and the schema-constrained translator for
|
|
180
|
+
state claims (LLM proposes the predicate; the oracle evaluates it)."""
|
|
181
|
+
from .twin.dialectic import TwinContext
|
|
182
|
+
from .twin.justify import llm_translate
|
|
183
|
+
utterance = react_trigger.text if isinstance(react_trigger, Intent) else ""
|
|
184
|
+
return TwinContext(move_kind=move.kind, utterance=utterance,
|
|
185
|
+
world=getattr(self.bb, "_world", None),
|
|
186
|
+
history=self.twin.history,
|
|
187
|
+
translate=lambda why: llm_translate(self.runner, why))
|
|
188
|
+
|
|
189
|
+
def _twin_audit(self, move: Move, react_trigger, concerns: list) -> Move:
|
|
190
|
+
if self.twin is None or not concerns:
|
|
191
|
+
return move
|
|
192
|
+
try:
|
|
193
|
+
import time
|
|
194
|
+
from .twin.dialectic import TwinResult
|
|
195
|
+
twin = self.twin
|
|
196
|
+
twin.metrics["turns"] += 1
|
|
197
|
+
cov = self.reasoning.last_coverage
|
|
198
|
+
first_cov = cov
|
|
199
|
+
if cov is None:
|
|
200
|
+
twin.metrics["coverage_silent"] += 1
|
|
201
|
+
objections = twin.audit(concerns, cov, self._twin_ctx(move, react_trigger))
|
|
202
|
+
first = tuple(objections)
|
|
203
|
+
twin.metrics["objections"] += len(first)
|
|
204
|
+
# the bounded dialectic: revise while objections stand, up to max_rounds or budget
|
|
205
|
+
rounds, deadline = 0, time.monotonic() + twin.budget_s
|
|
206
|
+
while objections and rounds < twin.max_rounds and time.monotonic() < deadline:
|
|
207
|
+
move = self.reasoning.react(react_trigger, note=twin.note(objections),
|
|
208
|
+
coverage_concerns=concerns)
|
|
209
|
+
rounds += 1
|
|
210
|
+
twin.metrics["revisions"] += 1
|
|
211
|
+
cov = self.reasoning.last_coverage
|
|
212
|
+
objections = twin.audit(concerns, cov, self._twin_ctx(move, react_trigger))
|
|
213
|
+
surviving = tuple(objections)
|
|
214
|
+
if surviving:
|
|
215
|
+
move = self.twin.flag(move, list(surviving)) # visible, never a silent ship
|
|
216
|
+
twin.metrics["flags"] += 1
|
|
217
|
+
twin.record(cov) # covered ids -> mission history
|
|
218
|
+
accepted, rejected = twin.split_omissions(concerns, cov)
|
|
219
|
+
self.last_twin_result = TwinResult(
|
|
220
|
+
concerns=tuple(concerns), coverage_silent=first_cov is None,
|
|
221
|
+
objections=first, surviving=surviving,
|
|
222
|
+
accepted_omissions=accepted, rejected_omissions=rejected, rounds=rounds)
|
|
223
|
+
return move
|
|
224
|
+
except Exception:
|
|
225
|
+
return move # additive: a Twin fault must not lose the turn
|
gini/agent/mission.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""The Mission runtime — a per-attempt state machine (pure logic; the UI drives it).
|
|
2
|
+
|
|
3
|
+
One Mission plays one Lesson. It runs the clock and the attempt (life) counter, evaluates the
|
|
4
|
+
lesson's objectives against the live topology (structural objectives resolve instantly in Phase 1;
|
|
5
|
+
behavioral ones stay `pending` until Phase 2's probe harness), detects completion via the lesson's
|
|
6
|
+
`complete_when`, and computes the broad band. It persists (`to_dict`/`from_dict`) so a student can
|
|
7
|
+
close GINI mid-lab and resume.
|
|
8
|
+
|
|
9
|
+
No Qt, no LLM, no wall-clock coupling — the clock is an injected `now()` callable, so time and
|
|
10
|
+
completion are fully unit-testable. GINI's runtime/objective engine is the oracle; this module is
|
|
11
|
+
just the bookkeeping around it.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import time
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
|
|
18
|
+
from ..domain import objectives as _obj
|
|
19
|
+
from ..domain import scoring as _scoring
|
|
20
|
+
from ..domain.lesson import Lesson
|
|
21
|
+
|
|
22
|
+
# states
|
|
23
|
+
STAGED = "staged" # board pre-built, not yet started
|
|
24
|
+
BRIEFED = "briefed" # brief shown, awaiting start
|
|
25
|
+
PLAYING = "playing" # clock running, live evaluation
|
|
26
|
+
WITNESSED = "witnessed" # an attempt was judged (band computed)
|
|
27
|
+
DONE = "done" # complete, or out of lives/time
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Mission:
|
|
32
|
+
lesson: Lesson
|
|
33
|
+
now: callable = time.monotonic # injected clock (seconds, monotonic)
|
|
34
|
+
state: str = STAGED
|
|
35
|
+
attempt: int = 0 # lives spent
|
|
36
|
+
started_at: float | None = None
|
|
37
|
+
ended_at: float | None = None
|
|
38
|
+
last_results: list = field(default_factory=list)
|
|
39
|
+
last_band: str = ""
|
|
40
|
+
step_index: int = 0 # guided missions: which beat we're on
|
|
41
|
+
|
|
42
|
+
# -- lifecycle ---------------------------------------------------------- #
|
|
43
|
+
def brief(self) -> None:
|
|
44
|
+
if self.state == STAGED:
|
|
45
|
+
self.state = BRIEFED
|
|
46
|
+
|
|
47
|
+
def start(self) -> None:
|
|
48
|
+
"""Begin (or begin the next) attempt: clock runs, a life is spent."""
|
|
49
|
+
if self.state not in (STAGED, BRIEFED, WITNESSED):
|
|
50
|
+
return
|
|
51
|
+
if self.state == WITNESSED and not self.can_retry():
|
|
52
|
+
return
|
|
53
|
+
self.attempt += 1
|
|
54
|
+
self.started_at = self.now()
|
|
55
|
+
self.ended_at = None
|
|
56
|
+
self.step_index = 0 # a (re)start walks the guided beats from the top
|
|
57
|
+
self.state = PLAYING
|
|
58
|
+
|
|
59
|
+
# -- clock -------------------------------------------------------------- #
|
|
60
|
+
def elapsed(self) -> float:
|
|
61
|
+
if self.started_at is None:
|
|
62
|
+
return 0.0
|
|
63
|
+
end = self.ended_at if self.ended_at is not None else self.now()
|
|
64
|
+
return max(0.0, end - self.started_at)
|
|
65
|
+
|
|
66
|
+
def remaining(self) -> float | None:
|
|
67
|
+
"""Seconds left, or None if the lesson is untimed."""
|
|
68
|
+
if self.lesson.time_limit_s is None:
|
|
69
|
+
return None
|
|
70
|
+
return self.lesson.time_limit_s - self.elapsed()
|
|
71
|
+
|
|
72
|
+
def expired(self) -> bool:
|
|
73
|
+
r = self.remaining()
|
|
74
|
+
return r is not None and r <= 0
|
|
75
|
+
|
|
76
|
+
def on_time(self) -> bool:
|
|
77
|
+
return not self.expired()
|
|
78
|
+
|
|
79
|
+
# -- evaluation --------------------------------------------------------- #
|
|
80
|
+
def evaluate(self, world, runner=None) -> list:
|
|
81
|
+
"""Re-evaluate objectives against the live world (structural = instant; behavioral resolve
|
|
82
|
+
when a `runner`/runtime is supplied, else pending). Auto-witnesses when the mission genuinely
|
|
83
|
+
completes or the clock expires — including from an already-WITNESSED attempt, so a band can
|
|
84
|
+
still be *upgraded* if the student pushes on and finishes."""
|
|
85
|
+
self.last_results = _obj.evaluate_all(self.lesson.objectives, world, runner)
|
|
86
|
+
# difficulty forks are evaluated too, but they NEVER block completion — they only lift the
|
|
87
|
+
# band. Kept in a side channel so the core ladder the panel shows is unchanged.
|
|
88
|
+
self._fork_results = {}
|
|
89
|
+
for fk in getattr(self.lesson, "forks", []) or []:
|
|
90
|
+
objs = fk.get("objectives", [])
|
|
91
|
+
self._fork_results[fk.get("id", "")] = _obj.evaluate_all(objs, world, runner) if objs else []
|
|
92
|
+
if self.state in (PLAYING, WITNESSED) and self._should_finish():
|
|
93
|
+
self._witness()
|
|
94
|
+
return self.last_results
|
|
95
|
+
|
|
96
|
+
def _should_finish(self) -> bool:
|
|
97
|
+
"""When to end the attempt: the clock expired, OR — for a GUIDED mission — the student
|
|
98
|
+
finished all the beats, OR — for a free-form mission — the win conditions are met. (A
|
|
99
|
+
guided mission is NOT ended early by objective completion, so read/reflect beats still run.)"""
|
|
100
|
+
if self.expired():
|
|
101
|
+
return True
|
|
102
|
+
if self.guided:
|
|
103
|
+
return self.steps_done
|
|
104
|
+
return self._complete()
|
|
105
|
+
|
|
106
|
+
def check(self, world, runner=None) -> "_scoring.Score":
|
|
107
|
+
"""Explicit 'Run/Check' — resolve the behavioral probes against the running system.
|
|
108
|
+
|
|
109
|
+
It must NOT end the attempt on its own. The live checks are REQUIRED to finish, so the
|
|
110
|
+
student presses Run precisely in order to complete — forcing a witness here froze a PARTIAL
|
|
111
|
+
band the moment they ran, and the mission could never be re-judged once it finished
|
|
112
|
+
("PARTIAL — 9/9"). `evaluate()` witnesses when the mission is genuinely complete or expired."""
|
|
113
|
+
self.evaluate(world, runner)
|
|
114
|
+
return self.score()
|
|
115
|
+
|
|
116
|
+
def _complete(self) -> bool:
|
|
117
|
+
return _scoring.is_complete(self.last_results, self.lesson.complete_when)
|
|
118
|
+
|
|
119
|
+
def _witness(self) -> None:
|
|
120
|
+
self.ended_at = self.now()
|
|
121
|
+
self.last_band = self.score().band
|
|
122
|
+
self.state = WITNESSED
|
|
123
|
+
if self._complete() or not self.can_retry():
|
|
124
|
+
self.state = DONE
|
|
125
|
+
|
|
126
|
+
def score(self) -> "_scoring.Score":
|
|
127
|
+
forks = getattr(self.lesson, "forks", []) or []
|
|
128
|
+
fr = getattr(self, "_fork_results", {})
|
|
129
|
+
# a fork is "done" only when every objective in it is met (an empty fork can't count)
|
|
130
|
+
done = sum(1 for fk in forks
|
|
131
|
+
if fr.get(fk.get("id", "")) and all(r.met for r in fr[fk.get("id", "")]))
|
|
132
|
+
return _scoring.score(self.last_results, complete_when=self.lesson.complete_when,
|
|
133
|
+
on_time=self.on_time(), forks_done=done, forks_total=len(forks))
|
|
134
|
+
|
|
135
|
+
# -- attempts ----------------------------------------------------------- #
|
|
136
|
+
def lives_left(self) -> int:
|
|
137
|
+
return max(0, self.lesson.attempts - self.attempt)
|
|
138
|
+
|
|
139
|
+
def can_retry(self) -> bool:
|
|
140
|
+
return self.lives_left() > 0 and not self._complete()
|
|
141
|
+
|
|
142
|
+
def retry(self) -> None:
|
|
143
|
+
if self.state == WITNESSED and self.can_retry():
|
|
144
|
+
self.start()
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def complete(self) -> bool:
|
|
148
|
+
return self._complete()
|
|
149
|
+
|
|
150
|
+
# -- guided steps ------------------------------------------------------- #
|
|
151
|
+
@property
|
|
152
|
+
def guided(self) -> bool:
|
|
153
|
+
return self.lesson.guided
|
|
154
|
+
|
|
155
|
+
def current_step(self):
|
|
156
|
+
steps = self.lesson.steps
|
|
157
|
+
return steps[self.step_index] if 0 <= self.step_index < len(steps) else None
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def steps_done(self) -> bool:
|
|
161
|
+
return self.step_index >= len(self.lesson.steps)
|
|
162
|
+
|
|
163
|
+
def step_number(self) -> tuple[int, int]:
|
|
164
|
+
return (min(self.step_index + 1, len(self.lesson.steps)), len(self.lesson.steps))
|
|
165
|
+
|
|
166
|
+
def step_satisfied(self, world, runner=None, replied: bool = False) -> bool:
|
|
167
|
+
"""Is the CURRENT step's advance condition met? reply/ack → the student sent a message;
|
|
168
|
+
structural → the canvas satisfies the predicate; behavioral → the probe passes (needs a
|
|
169
|
+
runner/run, else not yet)."""
|
|
170
|
+
step = self.current_step()
|
|
171
|
+
if step is None:
|
|
172
|
+
return False
|
|
173
|
+
kind = step.kind()
|
|
174
|
+
if kind == "reply":
|
|
175
|
+
return replied
|
|
176
|
+
if kind == "structural":
|
|
177
|
+
try:
|
|
178
|
+
return _obj.evaluate_check(step.advance, world)
|
|
179
|
+
except _obj.PredicateError:
|
|
180
|
+
return False
|
|
181
|
+
# behavioral
|
|
182
|
+
if runner is None or not getattr(runner, "available", lambda: False)():
|
|
183
|
+
return False
|
|
184
|
+
from ..domain import probes as _probes
|
|
185
|
+
try:
|
|
186
|
+
return _probes.evaluate(step.advance, runner)
|
|
187
|
+
except _probes.ProbeError:
|
|
188
|
+
return False
|
|
189
|
+
|
|
190
|
+
def advance_step(self):
|
|
191
|
+
"""Move to the next beat; returns the new current step (or None when the guided path ends)."""
|
|
192
|
+
if self.step_index < len(self.lesson.steps):
|
|
193
|
+
self.step_index += 1
|
|
194
|
+
return self.current_step()
|
|
195
|
+
|
|
196
|
+
# -- persistence (save/resume) ----------------------------------------- #
|
|
197
|
+
def to_dict(self) -> dict:
|
|
198
|
+
return {"lesson_id": self.lesson.id, "state": self.state, "attempt": self.attempt,
|
|
199
|
+
"started_at": self.started_at, "ended_at": self.ended_at,
|
|
200
|
+
"last_band": self.last_band}
|
|
201
|
+
|
|
202
|
+
@classmethod
|
|
203
|
+
def from_dict(cls, d: dict, lesson: Lesson, now=time.monotonic) -> "Mission":
|
|
204
|
+
m = cls(lesson=lesson, now=now)
|
|
205
|
+
m.state = d.get("state", STAGED)
|
|
206
|
+
m.attempt = int(d.get("attempt", 0))
|
|
207
|
+
m.started_at = d.get("started_at")
|
|
208
|
+
m.ended_at = d.get("ended_at")
|
|
209
|
+
m.last_band = d.get("last_band", "")
|
|
210
|
+
return m
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""MissionController — the coordinator that runs a live Mission inside GINI.
|
|
2
|
+
|
|
3
|
+
Ties together the pieces built in Phases 1–4: the Mission state machine, the objective engine
|
|
4
|
+
(with a Runner for behavioral probes), the GameMaster reasoning loop, the MissionPanel HUD, and
|
|
5
|
+
the student Profile. It's the "brain" the UI drives.
|
|
6
|
+
|
|
7
|
+
Kept UI-agnostic and fully testable by taking its couplings as callables:
|
|
8
|
+
• get_topology() → the live topology (source of the world); a `get_world` override may
|
|
9
|
+
be passed instead (e.g. a snapshot world) so the game master's
|
|
10
|
+
reasoning can run off the UI thread without touching the live canvas
|
|
11
|
+
• make_runner() → a probes.Runner for behavioral eval on Run/Check (or None)
|
|
12
|
+
• post(role, text) → deliver a game-master line to the chat
|
|
13
|
+
• llm(prompt)->str → the reasoning model (required; Missions are LLM-gated)
|
|
14
|
+
• on_change(cb) → subscribe to canvas changes (returns unsubscribe or None)
|
|
15
|
+
• panel → an optional MissionPanel to refresh (any object with refresh/…)
|
|
16
|
+
|
|
17
|
+
Flow: `start(lesson)` → brief. Canvas changes → live structural refresh + a game-master
|
|
18
|
+
observation (warmer/colder, nudges). `run_check()` → behavioral probes via the runner, witness,
|
|
19
|
+
record to the profile, and a celebrate/defeat line. `ask(text)` → a game-master reply.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from ..domain import objectives as _obj
|
|
24
|
+
from .gamemaster import GameMaster, QUIET
|
|
25
|
+
from .mission import Mission
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class MissionController:
|
|
29
|
+
def __init__(self, *, llm, post, get_topology=None, get_world=None, make_runner=None,
|
|
30
|
+
panel=None, profile=None, now=None, gm_factory=None, submit=None) -> None:
|
|
31
|
+
self.submit = submit # submit(lesson_id, mission) -> report to the Teaching Center
|
|
32
|
+
self.get_topology = get_topology
|
|
33
|
+
self._get_world = get_world # optional: a snapshot world (thread-safe reasoning)
|
|
34
|
+
self.llm = llm
|
|
35
|
+
self.post = post
|
|
36
|
+
self.make_runner = make_runner or (lambda: None)
|
|
37
|
+
self.panel = panel
|
|
38
|
+
self.profile = profile
|
|
39
|
+
self._now = now
|
|
40
|
+
self._gm_factory = gm_factory or GameMaster # swappable brain (AgentGameMaster in prod)
|
|
41
|
+
self.mission: Mission | None = None
|
|
42
|
+
self.gm: GameMaster | None = None
|
|
43
|
+
self._recorded = False
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def active(self) -> bool:
|
|
47
|
+
return self.mission is not None and self.mission.state != "done"
|
|
48
|
+
|
|
49
|
+
def available(self) -> bool:
|
|
50
|
+
"""Missions require a reasoning LLM (hard gate)."""
|
|
51
|
+
return self.llm is not None
|
|
52
|
+
|
|
53
|
+
# -- lifecycle ---------------------------------------------------------- #
|
|
54
|
+
def start(self, lesson) -> bool:
|
|
55
|
+
if not self.available():
|
|
56
|
+
self.post("GINI", "Missions need a local model — enable one in Settings.")
|
|
57
|
+
return False
|
|
58
|
+
self.mission = Mission(lesson) if self._now is None else Mission(lesson, now=self._now)
|
|
59
|
+
self.gm = self._gm_factory(lesson, llm=self.llm)
|
|
60
|
+
if hasattr(self.gm, "bind_world"): # let the game master ground diagnosis (explainer)
|
|
61
|
+
self.gm.bind_world(self._world)
|
|
62
|
+
self._recorded = False
|
|
63
|
+
self.mission.brief()
|
|
64
|
+
self.mission.start()
|
|
65
|
+
if self.panel is not None:
|
|
66
|
+
self.panel.set_mission(self.mission)
|
|
67
|
+
if self.mission.guided:
|
|
68
|
+
# multi-turn: present just the first beat, not the whole wall of objectives
|
|
69
|
+
self._advance_guided(self._world()) # skip any beats already satisfied
|
|
70
|
+
self._present_current_step()
|
|
71
|
+
else:
|
|
72
|
+
self.post("GINI", self.gm.brief_line() or lesson.brief) # free-form: the brief
|
|
73
|
+
self._refresh_live()
|
|
74
|
+
return True
|
|
75
|
+
|
|
76
|
+
# -- guided-beat driving ------------------------------------------------ #
|
|
77
|
+
def _present_current_step(self, acked: str = "") -> None:
|
|
78
|
+
step = self.mission.current_step()
|
|
79
|
+
if step is None:
|
|
80
|
+
return
|
|
81
|
+
idx, total = self.mission.step_number()
|
|
82
|
+
self.post("GINI", self.gm.present_step(step, idx, total, acked=acked))
|
|
83
|
+
if self.panel is not None and hasattr(self.panel, "set_step"):
|
|
84
|
+
self.panel.set_step(step.say, idx, total)
|
|
85
|
+
|
|
86
|
+
def _advance_guided(self, world, *, replied: bool = False, runner=None) -> bool:
|
|
87
|
+
"""Advance through every currently-satisfied beat, presenting each one we land on. Returns
|
|
88
|
+
True if we moved. A `replied`/`runner` lets reply/behavioral beats advance."""
|
|
89
|
+
moved = False
|
|
90
|
+
while not self.mission.steps_done and self.mission.step_satisfied(world, runner, replied):
|
|
91
|
+
prev = self.mission.current_step()
|
|
92
|
+
self.mission.advance_step()
|
|
93
|
+
replied = False # a reply advances only the current beat
|
|
94
|
+
moved = True
|
|
95
|
+
if self.mission.steps_done:
|
|
96
|
+
if self.panel is not None and hasattr(self.panel, "clear_step"):
|
|
97
|
+
self.panel.clear_step()
|
|
98
|
+
self.mission.evaluate(world, runner) # guided path done → witness → band
|
|
99
|
+
break
|
|
100
|
+
self._present_current_step(acked=prev.say)
|
|
101
|
+
return moved
|
|
102
|
+
|
|
103
|
+
def _maybe_finish(self) -> bool:
|
|
104
|
+
"""If the attempt has been witnessed, deliver the game master's closing line and record it."""
|
|
105
|
+
if self.mission is not None and self.mission.state == "done":
|
|
106
|
+
self._speak(self.gm.decide(self.mission, self.mission.last_results))
|
|
107
|
+
self._finish()
|
|
108
|
+
return True
|
|
109
|
+
return False
|
|
110
|
+
|
|
111
|
+
def _world(self):
|
|
112
|
+
if self._get_world is not None:
|
|
113
|
+
return self._get_world()
|
|
114
|
+
return _obj.TopologyWorld(self.get_topology())
|
|
115
|
+
|
|
116
|
+
# -- canvas changed → live structural eval + a game-master observation --- #
|
|
117
|
+
def on_canvas_changed(self) -> None:
|
|
118
|
+
if not self.active:
|
|
119
|
+
return
|
|
120
|
+
world = self._world()
|
|
121
|
+
results = self.mission.evaluate(world) # structural live (no runner)
|
|
122
|
+
if self.panel is not None:
|
|
123
|
+
self.panel.render_current()
|
|
124
|
+
if self._maybe_finish(): # free-form win / expiry
|
|
125
|
+
return
|
|
126
|
+
if self.mission.guided:
|
|
127
|
+
self._advance_guided(world) # a drop/connect advanced a beat?
|
|
128
|
+
self._maybe_finish() # ...maybe that finished the path
|
|
129
|
+
else:
|
|
130
|
+
self._speak(self.gm.decide(self.mission, results)) # free-form warmer/colder/nudge
|
|
131
|
+
|
|
132
|
+
def _refresh_live(self) -> None:
|
|
133
|
+
if self.mission is None:
|
|
134
|
+
return
|
|
135
|
+
self.mission.evaluate(self._world())
|
|
136
|
+
if self.panel is not None:
|
|
137
|
+
self.panel.render_current()
|
|
138
|
+
|
|
139
|
+
# -- Run/Check → behavioral probes, witness, record --------------------- #
|
|
140
|
+
def run_check(self):
|
|
141
|
+
if self.mission is None:
|
|
142
|
+
return None
|
|
143
|
+
world = self._world()
|
|
144
|
+
runner = self.make_runner()
|
|
145
|
+
# tell the student WHY nothing changed when the live checks can't run (no running stack)
|
|
146
|
+
if self.mission.lesson.behavioral_ids() and (
|
|
147
|
+
runner is None or not getattr(runner, "available", lambda: False)()):
|
|
148
|
+
self.post("GINI", "The live checks (⏳) need the running system — start the topology with "
|
|
149
|
+
"the ▶ Run button on the toolbar first, then press Run / Check again. "
|
|
150
|
+
"Your build already meets the objectives to complete this mission.")
|
|
151
|
+
score = self.mission.check(world, runner)
|
|
152
|
+
if self.panel is not None:
|
|
153
|
+
self.panel.render_current()
|
|
154
|
+
if self.mission.guided and not self.mission.steps_done:
|
|
155
|
+
self._advance_guided(world, runner=runner) # a run advanced a behavioral beat?
|
|
156
|
+
# A DETERMINISTIC summary of the live checks, straight from the probe verdicts — the oracle,
|
|
157
|
+
# not the model, so it can NEVER contradict the objective panel (kills the 'connection test
|
|
158
|
+
# successful' hallucination when a live check actually failed).
|
|
159
|
+
live = [r for r in self.mission.last_results if r.kind == "behavioral"]
|
|
160
|
+
if live:
|
|
161
|
+
mark = {"met": "✓", "unmet": "✗", "pending": "⏳"}
|
|
162
|
+
self.post("GINI", "Live checks — "
|
|
163
|
+
+ "; ".join(f"{mark.get(r.status, '?')} {r.say}" for r in live))
|
|
164
|
+
if not self._maybe_finish():
|
|
165
|
+
failed = [r.say for r in live if not r.met]
|
|
166
|
+
if failed and hasattr(self.gm, "run_note"):
|
|
167
|
+
self.post("GINI", self.gm.run_note(failed)) # grounded: names what FAILED, no false wins
|
|
168
|
+
else:
|
|
169
|
+
self._speak(self.gm.decide(self.mission, self.mission.last_results))
|
|
170
|
+
return score
|
|
171
|
+
|
|
172
|
+
# -- a student message during a mission --------------------------------- #
|
|
173
|
+
def ask(self, text: str) -> None:
|
|
174
|
+
if not self.active:
|
|
175
|
+
return
|
|
176
|
+
world = self._world()
|
|
177
|
+
results = self.mission.evaluate(world)
|
|
178
|
+
if self.panel is not None:
|
|
179
|
+
self.panel.render_current()
|
|
180
|
+
step = self.mission.current_step() if self.mission.guided else None
|
|
181
|
+
if step is not None and step.kind() == "reply":
|
|
182
|
+
# this beat wanted the student to reply (read/reflect) → respond + advance
|
|
183
|
+
self.post("GINI", self.gm.react_reply(step, text, results))
|
|
184
|
+
self._advance_guided(world, replied=True)
|
|
185
|
+
self._maybe_finish()
|
|
186
|
+
return
|
|
187
|
+
# otherwise it's a question/hint mid-beat (or free-form) → the game master reasons
|
|
188
|
+
self._speak(self.gm.decide(self.mission, results, utterance=text))
|
|
189
|
+
if self.mission.guided and not self.mission.steps_done:
|
|
190
|
+
self._advance_guided(world) # maybe they described an action
|
|
191
|
+
self._maybe_finish()
|
|
192
|
+
|
|
193
|
+
# -- helpers ------------------------------------------------------------ #
|
|
194
|
+
def _speak(self, move) -> None:
|
|
195
|
+
if move is not None and move.kind != QUIET and move.text:
|
|
196
|
+
self.post("GINI", move.text)
|
|
197
|
+
|
|
198
|
+
def _finish(self) -> None:
|
|
199
|
+
if self._recorded or self.mission is None:
|
|
200
|
+
return
|
|
201
|
+
if self.profile is not None:
|
|
202
|
+
self.profile.record(self.mission.lesson, self.mission)
|
|
203
|
+
self._recorded = True
|
|
204
|
+
if self.submit is not None: # report the result to the Teaching Center
|
|
205
|
+
try:
|
|
206
|
+
self.submit(self.mission.lesson.id, self.mission)
|
|
207
|
+
except Exception: # noqa: BLE001 — never let a sync break the mission
|
|
208
|
+
pass
|