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/ui/assistant.py
ADDED
|
@@ -0,0 +1,2111 @@
|
|
|
1
|
+
"""In-app "Ask GINI" assistant panel.
|
|
2
|
+
|
|
3
|
+
Students chat here to build, inspect, and understand topologies. It works offline
|
|
4
|
+
using GiniAPI's deterministic command handling and explanations; an LLM backend can
|
|
5
|
+
be attached via `set_llm(fn)` to handle free-form questions, and it calls the very
|
|
6
|
+
same GiniAPI the MCP server exposes — so an external agent and the in-app assistant
|
|
7
|
+
share one brain.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
|
|
16
|
+
from PySide6.QtCore import Qt, QTimer, Signal
|
|
17
|
+
from PySide6.QtGui import QColor, QFont, QTextCharFormat, QTextCursor
|
|
18
|
+
from PySide6.QtWidgets import (
|
|
19
|
+
QButtonGroup, QCheckBox, QHBoxLayout, QLabel, QLineEdit, QPushButton, QScrollArea,
|
|
20
|
+
QStackedWidget, QTextBrowser, QVBoxLayout, QWidget,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
from ..agent.api import GiniAPI
|
|
24
|
+
from ..app import AppContext
|
|
25
|
+
from ..domain import all_devices
|
|
26
|
+
from .mission_panel import MissionPanel
|
|
27
|
+
from .theme import ThemeManager, icons
|
|
28
|
+
from .theme.manager import sp as _sp
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _MissionPanelProxy:
|
|
32
|
+
"""Panel adapter for the MissionController: `set_mission` is a synchronous start-time call (UI
|
|
33
|
+
thread), but live updates are EMITTED as ('op', …) tuples so the game master, running on a
|
|
34
|
+
worker thread, can update the HUD safely on the UI thread via a queued signal."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, real, emit) -> None:
|
|
37
|
+
self._real = real
|
|
38
|
+
self._emit = emit
|
|
39
|
+
|
|
40
|
+
def set_mission(self, mission) -> None:
|
|
41
|
+
self._real.set_mission(mission)
|
|
42
|
+
|
|
43
|
+
def render_current(self) -> None:
|
|
44
|
+
self._emit(("tracker",))
|
|
45
|
+
|
|
46
|
+
def set_step(self, text, index, total) -> None:
|
|
47
|
+
self._emit(("step", text, index, total))
|
|
48
|
+
|
|
49
|
+
def clear_step(self) -> None:
|
|
50
|
+
self._emit(("step_clear",))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Assistant(QWidget):
|
|
54
|
+
# emitted from the LLM worker thread; delivered on the UI thread (queued)
|
|
55
|
+
answer_ready = Signal(str, str) # (device_name_or_empty, text)
|
|
56
|
+
answer_chunk = Signal(str) # a streamed token delta (live "typing")
|
|
57
|
+
status_changed = Signal(str, bool) # (mode label, busy) -> toolbar indicator
|
|
58
|
+
starter_ready = Signal(str, str) # (type_key, reason) for the Wizard's first element
|
|
59
|
+
# mission game-master runs on a worker thread; its chat/panel updates come back via this
|
|
60
|
+
# queued signal so the canvas never blocks on a model call. Payload = an ("op", …) tuple.
|
|
61
|
+
mission_ui_op = Signal(object)
|
|
62
|
+
|
|
63
|
+
def __init__(self, ctx: AppContext, api: GiniAPI, theme: ThemeManager) -> None:
|
|
64
|
+
super().__init__()
|
|
65
|
+
self.setObjectName("Inspector")
|
|
66
|
+
self.ctx = ctx
|
|
67
|
+
self.api = api
|
|
68
|
+
self.theme = theme
|
|
69
|
+
self._llm: Callable[[str], str] | None = None
|
|
70
|
+
self._loop = None # AgentLoop, set when an LLM (Ollama) is configured
|
|
71
|
+
self._tutor = True # tutor mode: explanations highlight/animate on the canvas
|
|
72
|
+
self.explain_mode = False # when on, selecting a device explains it on the canvas
|
|
73
|
+
self.wizard_mode = False # when on, the input box describes a system to scaffold
|
|
74
|
+
self.coach_mode = False # when on, GINI reviews the canvas for problems to fix
|
|
75
|
+
self.missions_mode = False # when on, the student picks + plays an assessed Mission
|
|
76
|
+
self._busy = False # an LLM answer is in flight
|
|
77
|
+
from ..agent.session import SessionKnowledge
|
|
78
|
+
self._session = SessionKnowledge() # accumulated GINI knowledge for this session
|
|
79
|
+
self._messages: list[tuple[str, str, bool, bool]] = [] # (role, text, err, md)
|
|
80
|
+
self._chat_archive: list | None = None # pre-mission transcript, stashed while playing
|
|
81
|
+
self._brief = "" # per-project teacher framing, fed to the model
|
|
82
|
+
self._streaming = False # a streamed answer is being typed into the log
|
|
83
|
+
self._stream_buf = "" # accumulated streamed text (for persistence)
|
|
84
|
+
self._last_ref: tuple[str, str] | None = None # what we last explained (for chips)
|
|
85
|
+
self.answer_ready.connect(self._on_answer)
|
|
86
|
+
self.answer_chunk.connect(self._on_chunk)
|
|
87
|
+
self.starter_ready.connect(self._place_starter)
|
|
88
|
+
self.ctx.bus.canvas_background_clicked.connect(self._on_canvas_background)
|
|
89
|
+
self._ghost_cache: dict = {} # (goal, type_key) -> [(type_key, reason)]
|
|
90
|
+
ctx.bus.wizard_ghosts_requested.connect(self._resolve_ghosts_async)
|
|
91
|
+
ctx.bus.wizard_ghosts_ready.connect(self._learn_on_goal) # endorsed types are on-goal
|
|
92
|
+
ctx.bus.topology_changed.connect(self._show_context_chips) # contextual quick-actions
|
|
93
|
+
ctx.bus.topology_changed.connect(self._refresh_mode_availability) # gate Wizard on xv6
|
|
94
|
+
ctx.bus.machine_events.connect(self._on_machine_events) # proactive OS Coach
|
|
95
|
+
self._coach_last_fire = 0.0 # cooldown clock
|
|
96
|
+
theme.themeChanged.connect(lambda *_: self._rerender()) # recolor on theme switch
|
|
97
|
+
|
|
98
|
+
lay = QVBoxLayout(self)
|
|
99
|
+
lay.setContentsMargins(10, 10, 10, 10)
|
|
100
|
+
lay.setSpacing(8)
|
|
101
|
+
|
|
102
|
+
# the Ask GINI dock's floor. The mode-button row used to force ~490px; with it in a scroll
|
|
103
|
+
# strip we pin a slimmer minimum (~15% narrower) so the panel can be dragged narrower.
|
|
104
|
+
self.setMinimumWidth(416)
|
|
105
|
+
|
|
106
|
+
self.log = QTextBrowser()
|
|
107
|
+
self.log.setOpenExternalLinks(False)
|
|
108
|
+
# empty state = a clickable topic cloud (things to explore/build); it swaps to the
|
|
109
|
+
# conversation once anything is posted. An invitation, not a keyword cage.
|
|
110
|
+
# Mission HUD (objective tracker + clock + lives), shown only while a Mission is active,
|
|
111
|
+
# sitting ABOVE the chat so game-master narration flows in the log below it.
|
|
112
|
+
self._mission_panel = MissionPanel(theme)
|
|
113
|
+
self._mission_panel.setVisible(False)
|
|
114
|
+
self._mission_panel.run_requested.connect(lambda: self._dispatch_mission("run_check"))
|
|
115
|
+
lay.addWidget(self._mission_panel)
|
|
116
|
+
self._mission_ctrl = None # agent.mission_controller.MissionController
|
|
117
|
+
self._mission_profile = None # lazily created student profile
|
|
118
|
+
# the game master reasons on a WORKER thread (so the canvas never blocks on a model call);
|
|
119
|
+
# its chat/panel updates arrive here on the UI thread via the queued mission_ui_op signal
|
|
120
|
+
self._mission_world = None # snapshot world for the current reaction
|
|
121
|
+
self._mission_busy = False # a reaction worker is running
|
|
122
|
+
self._mission_dirty = False # a change arrived while busy (coalesce)
|
|
123
|
+
self.mission_ui_op.connect(self._apply_mission_ui)
|
|
124
|
+
self._mission_debounce = QTimer(self) # coalesce rapid drops/links
|
|
125
|
+
self._mission_debounce.setSingleShot(True)
|
|
126
|
+
self._mission_debounce.setInterval(180)
|
|
127
|
+
self._mission_debounce.timeout.connect(
|
|
128
|
+
lambda: self._dispatch_mission("on_canvas_changed"))
|
|
129
|
+
|
|
130
|
+
# UNIFIED CONVERSATION RIBBON — one surface for GINI *and* people. GINI is just the first
|
|
131
|
+
# target; Instructor / Group / groupmates join it when you're signed in to a course. Same
|
|
132
|
+
# pattern as the Missions ribbon: a switcher on top, one shared transcript below. When you're
|
|
133
|
+
# not enrolled it never appears, so the solo experience is exactly as before.
|
|
134
|
+
self._convo = "gini" # active conversation id ("gini" or a channel id)
|
|
135
|
+
self._channels: list[dict] = [] # human channels from the Teaching Center
|
|
136
|
+
self._human_msgs: list[dict] = [] # last poll of human messages
|
|
137
|
+
self._convo_seen: dict[str, float] = {} # channel -> ts of last message the student saw
|
|
138
|
+
self._convo_btns: dict[str, QPushButton] = {}
|
|
139
|
+
self._convo_bar = QWidget()
|
|
140
|
+
self._convo_row = QHBoxLayout(self._convo_bar)
|
|
141
|
+
self._convo_row.setContentsMargins(0, 0, 0, 2)
|
|
142
|
+
self._convo_row.setSpacing(4)
|
|
143
|
+
# the ribbon can hold many channels (a teacher sees one per student). A raw button row would
|
|
144
|
+
# force the whole Ask GINI dock as wide as all its pills — so it lives in a horizontal scroll
|
|
145
|
+
# strip: extra channels scroll, they never widen the panel.
|
|
146
|
+
self._convo_scroll = QScrollArea()
|
|
147
|
+
self._convo_scroll.setWidget(self._convo_bar)
|
|
148
|
+
self._convo_scroll.setWidgetResizable(True)
|
|
149
|
+
self._convo_scroll.setFrameShape(QScrollArea.NoFrame)
|
|
150
|
+
self._convo_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
|
151
|
+
self._convo_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
|
152
|
+
self._convo_scroll.setFixedHeight(40)
|
|
153
|
+
self._convo_scroll.setMinimumWidth(120) # small floor → it never demands to be wide
|
|
154
|
+
self._convo_scroll.setVisible(False)
|
|
155
|
+
lay.addWidget(self._convo_scroll)
|
|
156
|
+
self._convo_timer = QTimer(self)
|
|
157
|
+
self._convo_timer.setInterval(6000)
|
|
158
|
+
self._convo_timer.timeout.connect(self._poll_convos)
|
|
159
|
+
self.mission_ui_op.connect(self._apply_convo_op) # worker → UI thread (reuse the queue)
|
|
160
|
+
ctx.bus.enrolment_changed.connect(self._on_enrolment_convos)
|
|
161
|
+
|
|
162
|
+
self._stack = QStackedWidget()
|
|
163
|
+
self._stack.addWidget(self._build_cloud()) # index 0: the cloud
|
|
164
|
+
self._stack.addWidget(self.log) # index 1: the conversation
|
|
165
|
+
lay.addWidget(self._stack, 1)
|
|
166
|
+
|
|
167
|
+
# a canvas change during a Mission drives the game-master loop (live structural eval)
|
|
168
|
+
ctx.bus.topology_changed.connect(self._on_topology_for_mission)
|
|
169
|
+
|
|
170
|
+
# Mode = one exclusive, segmented switch (exactly one active): Chat / Explain / Wizard.
|
|
171
|
+
chips = QHBoxLayout(); chips.setSpacing(4)
|
|
172
|
+
self._chat_btn = QPushButton("Chat")
|
|
173
|
+
self._chat_btn.setObjectName("ModeBtn")
|
|
174
|
+
self._chat_btn.setCheckable(True); self._chat_btn.setChecked(True)
|
|
175
|
+
self._chat_btn.setToolTip("Chat: ask GINI to build, inspect, or explain")
|
|
176
|
+
self._chat_btn.toggled.connect(self._toggle_chat)
|
|
177
|
+
|
|
178
|
+
self._explain_btn = QPushButton("Explain")
|
|
179
|
+
self._explain_btn.setObjectName("ModeBtn")
|
|
180
|
+
self._explain_btn.setCheckable(True)
|
|
181
|
+
self._explain_btn.setToolTip("Explain: click any device on the canvas to explain it")
|
|
182
|
+
self._explain_btn.toggled.connect(self._toggle_explain)
|
|
183
|
+
|
|
184
|
+
self._wizard_btn = QPushButton("Wizard")
|
|
185
|
+
self._wizard_btn.setObjectName("WizardBtn")
|
|
186
|
+
self._wizard_btn.setCheckable(True)
|
|
187
|
+
self._wizard_btn.setToolTip("Wizard: describe a goal and GINI guides you to build it")
|
|
188
|
+
self._wizard_btn.toggled.connect(self._toggle_wizard)
|
|
189
|
+
self._wizard_btn.setEnabled(False) # needs a model; set_loop enables it
|
|
190
|
+
|
|
191
|
+
self._coach_btn = QPushButton("Coach")
|
|
192
|
+
self._coach_btn.setObjectName("WizardBtn")
|
|
193
|
+
self._coach_btn.setCheckable(True)
|
|
194
|
+
self._coach_btn.setToolTip("Coach: GINI reviews your canvas and tells you what to fix")
|
|
195
|
+
self._coach_btn.toggled.connect(self._toggle_coach)
|
|
196
|
+
# `clicked` fires on EVERY click (even when Coach is already the active mode, which
|
|
197
|
+
# emits no `toggled`), so clicking Coach again re-runs the review after a fix.
|
|
198
|
+
self._coach_btn.clicked.connect(self._on_coach_clicked)
|
|
199
|
+
self._coach_btn.setEnabled(False) # needs a model; set_loop enables it
|
|
200
|
+
|
|
201
|
+
self._missions_btn = QPushButton("Missions")
|
|
202
|
+
self._missions_btn.setObjectName("WizardBtn")
|
|
203
|
+
self._missions_btn.setCheckable(True)
|
|
204
|
+
self._missions_btn.setToolTip("Missions: play an assigned lab as a timed, assessed game")
|
|
205
|
+
self._missions_btn.toggled.connect(self._toggle_missions)
|
|
206
|
+
self._missions_btn.setEnabled(False) # needs a model; set_loop enables it
|
|
207
|
+
|
|
208
|
+
self._mode_group = QButtonGroup(self) # radio behaviour: exactly one mode active
|
|
209
|
+
self._mode_group.setExclusive(True)
|
|
210
|
+
for b in (self._chat_btn, self._explain_btn, self._wizard_btn, self._coach_btn,
|
|
211
|
+
self._missions_btn):
|
|
212
|
+
self._mode_group.addButton(b)
|
|
213
|
+
chips.addWidget(b)
|
|
214
|
+
chips.addStretch(1)
|
|
215
|
+
|
|
216
|
+
# Tutor is a *modifier*, not a mode — a small "animate on canvas" toggle, set apart.
|
|
217
|
+
self._tutor_box = QPushButton("⚡ Animate")
|
|
218
|
+
self._tutor_box.setObjectName("ModeBtn")
|
|
219
|
+
self._tutor_box.setCheckable(True); self._tutor_box.setChecked(True)
|
|
220
|
+
self._tutor_box.setToolTip("Animate explanations on the canvas (spotlight + callouts)")
|
|
221
|
+
self._tutor_box.toggled.connect(lambda v: setattr(self, "_tutor", v))
|
|
222
|
+
chips.addWidget(self._tutor_box)
|
|
223
|
+
# (the model presence indicator now lives in the toolbar — see ModeIndicator)
|
|
224
|
+
# wrap the mode row so the whole thing can be hidden when a human conversation is selected.
|
|
225
|
+
# It also lives in a horizontal scroll strip: the six mode buttons are the widest fixed thing
|
|
226
|
+
# in the panel, so a raw row set the dock's MINIMUM width. In the strip they scroll when the
|
|
227
|
+
# dock is dragged to its narrowest, so the panel can be slimmer.
|
|
228
|
+
self._mode_bar = QWidget()
|
|
229
|
+
self._mode_bar.setLayout(chips)
|
|
230
|
+
self._mode_scroll = QScrollArea()
|
|
231
|
+
self._mode_scroll.setWidget(self._mode_bar)
|
|
232
|
+
self._mode_scroll.setWidgetResizable(True)
|
|
233
|
+
self._mode_scroll.setFrameShape(QScrollArea.NoFrame)
|
|
234
|
+
self._mode_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
|
235
|
+
self._mode_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
|
236
|
+
self._mode_scroll.setFixedHeight(self._mode_bar.sizeHint().height() + 4)
|
|
237
|
+
lay.addWidget(self._mode_scroll)
|
|
238
|
+
|
|
239
|
+
# "thinking" spinner — shown while an LLM answer is in flight
|
|
240
|
+
self._spinner = QLabel("")
|
|
241
|
+
self._spinner.setObjectName("Muted")
|
|
242
|
+
self._spinner.setVisible(False)
|
|
243
|
+
lay.addWidget(self._spinner)
|
|
244
|
+
self._spin_timer = QTimer(self)
|
|
245
|
+
self._spin_timer.setInterval(300)
|
|
246
|
+
self._spin_dots = 0
|
|
247
|
+
self._spin_timer.timeout.connect(self._spin_tick)
|
|
248
|
+
|
|
249
|
+
# follow-up suggestion chips — context-aware, populated after each explanation
|
|
250
|
+
self._follow_box = QWidget()
|
|
251
|
+
self._follow_lay = QHBoxLayout(self._follow_box)
|
|
252
|
+
self._follow_lay.setContentsMargins(0, 0, 0, 0)
|
|
253
|
+
self._follow_lay.setSpacing(6)
|
|
254
|
+
self._follow_box.setVisible(False)
|
|
255
|
+
lay.addWidget(self._follow_box)
|
|
256
|
+
|
|
257
|
+
# Missions picker — a VERTICAL, scrollable list. Mission labels are long, so a horizontal
|
|
258
|
+
# chip row would run thousands of px wide and drag the whole window off-screen. The prompt
|
|
259
|
+
# is a header ABOVE the list (not a chat post — that spammed the log on every re-entry).
|
|
260
|
+
self._picker_header = QLabel("Pick a mission to play:")
|
|
261
|
+
self._picker_header.setStyleSheet("font-weight:600;")
|
|
262
|
+
self._picker_header.setVisible(False)
|
|
263
|
+
lay.addWidget(self._picker_header)
|
|
264
|
+
self._picker = QWidget()
|
|
265
|
+
self._picker_lay = QVBoxLayout(self._picker)
|
|
266
|
+
self._picker_lay.setContentsMargins(0, 0, 0, 0)
|
|
267
|
+
self._picker_lay.setSpacing(4)
|
|
268
|
+
self._picker_scroll = QScrollArea()
|
|
269
|
+
self._picker_scroll.setWidgetResizable(True)
|
|
270
|
+
self._picker_scroll.setWidget(self._picker)
|
|
271
|
+
self._picker_scroll.setFrameShape(QScrollArea.NoFrame)
|
|
272
|
+
self._picker_scroll.setMaximumHeight(240)
|
|
273
|
+
self._picker_scroll.setVisible(False)
|
|
274
|
+
lay.addWidget(self._picker_scroll)
|
|
275
|
+
|
|
276
|
+
# Wizard objective banner (shown once a goal is set) — the canvas does the guiding
|
|
277
|
+
self._wz_panel = self._make_wizard_panel()
|
|
278
|
+
lay.addWidget(self._wz_panel)
|
|
279
|
+
|
|
280
|
+
row = QHBoxLayout()
|
|
281
|
+
self.input = QLineEdit()
|
|
282
|
+
self.input.setPlaceholderText("Ask GINI to build, inspect, or explain…")
|
|
283
|
+
self.input.returnPressed.connect(self._send)
|
|
284
|
+
send = QPushButton()
|
|
285
|
+
send.setObjectName("Accent")
|
|
286
|
+
send.setIcon(icons.icon("send", "#ffffff", 16))
|
|
287
|
+
send.clicked.connect(self._send)
|
|
288
|
+
row.addWidget(self.input, 1)
|
|
289
|
+
row.addWidget(send)
|
|
290
|
+
lay.addLayout(row)
|
|
291
|
+
|
|
292
|
+
self._post("GINI", "Hi! I can build, explain, and teach on the canvas. Try: "
|
|
293
|
+
"“add a router”, “connect R1 and S1”, “explain this topology”, "
|
|
294
|
+
"“show path M1 to M2”, or “how does M1 reach M2”. "
|
|
295
|
+
"Tutor mode highlights and animates as I explain.")
|
|
296
|
+
self._show_cloud() # start on the topic cloud; first message swaps to the log
|
|
297
|
+
|
|
298
|
+
# LLM seam -------------------------------------------------------------- #
|
|
299
|
+
def set_llm(self, fn: Callable[[str], str]) -> None:
|
|
300
|
+
self._llm = fn
|
|
301
|
+
|
|
302
|
+
def set_loop(self, loop) -> None:
|
|
303
|
+
"""Attach an AgentLoop (Ollama-backed) for open-ended questions. The Wizard needs a
|
|
304
|
+
model, so its button is enabled/disabled with the loop."""
|
|
305
|
+
self._loop = loop
|
|
306
|
+
self._embedder_obj = None # rebuild the L2 embedder against the new backend
|
|
307
|
+
if loop is not None:
|
|
308
|
+
loop.brief = self._brief # carry the project's framing onto the new loop
|
|
309
|
+
self._refresh_mode_availability()
|
|
310
|
+
|
|
311
|
+
def _embedder(self):
|
|
312
|
+
"""The L2 semantic-recall embedder (cached), built against the active model backend.
|
|
313
|
+
Returns a NullEmbeddings (disabled) when there's no backend or no shipped index — so
|
|
314
|
+
retrieval quietly falls back to lexical + LLM-expansion."""
|
|
315
|
+
if getattr(self, "_embedder_obj", None) is not None:
|
|
316
|
+
return self._embedder_obj
|
|
317
|
+
from ..agent import embed
|
|
318
|
+
backend = getattr(self._loop, "backend", None) if self._loop is not None else None
|
|
319
|
+
self._embedder_obj = (embed.OllamaEmbeddings(backend)
|
|
320
|
+
if backend is not None and hasattr(backend, "embed")
|
|
321
|
+
else embed.NullEmbeddings())
|
|
322
|
+
return self._embedder_obj
|
|
323
|
+
|
|
324
|
+
def _has_xv6(self) -> bool:
|
|
325
|
+
return any(getattr(d, "type_key", "") == "xv6"
|
|
326
|
+
for d in self.ctx.topology.devices.values())
|
|
327
|
+
|
|
328
|
+
def _refresh_mode_availability(self) -> None:
|
|
329
|
+
"""Enable/disable the model-gated modes. Wizard is additionally disabled when an xv6
|
|
330
|
+
Machine is present — it's a standalone OS lab, not a topology to build toward a goal
|
|
331
|
+
(signed off: 'Wizard disabled when xv6 runs'). Coach stays on and becomes the OS tutor."""
|
|
332
|
+
has_model = self._loop is not None
|
|
333
|
+
xv6 = self._has_xv6()
|
|
334
|
+
self._coach_btn.setEnabled(has_model)
|
|
335
|
+
self._wizard_btn.setEnabled(has_model and not xv6)
|
|
336
|
+
self._missions_btn.setEnabled(has_model)
|
|
337
|
+
self._missions_btn.setToolTip(
|
|
338
|
+
"Missions: play an assigned lab as a timed, assessed game" if has_model
|
|
339
|
+
else "Missions need a local model — enable one in Settings → LLM")
|
|
340
|
+
if not has_model:
|
|
341
|
+
self._wizard_btn.setToolTip("Wizard needs a local model — enable one in Settings → LLM")
|
|
342
|
+
self._coach_btn.setToolTip("Coach needs a local model — enable one in Settings → LLM")
|
|
343
|
+
else:
|
|
344
|
+
self._coach_btn.setToolTip(
|
|
345
|
+
"Coach: measured, state-grounded help for your xv6 experiment" if xv6
|
|
346
|
+
else "Coach: GINI reviews your canvas and tells you what to fix")
|
|
347
|
+
self._wizard_btn.setToolTip(
|
|
348
|
+
"Wizard doesn't apply to an xv6 Machine — it's a standalone OS lab, not a "
|
|
349
|
+
"topology to build" if xv6
|
|
350
|
+
else "Wizard: describe a goal and GINI guides you to build it")
|
|
351
|
+
# if a now-disabled mode was active, fall back to Chat (a mode must stay active)
|
|
352
|
+
if ((not self._wizard_btn.isEnabled() and self._wizard_btn.isChecked())
|
|
353
|
+
or (not self._coach_btn.isEnabled() and self._coach_btn.isChecked())
|
|
354
|
+
or (not self._missions_btn.isEnabled() and self._missions_btn.isChecked())):
|
|
355
|
+
self._chat_btn.setChecked(True)
|
|
356
|
+
|
|
357
|
+
# -- per-project AI state (saved with the project, swapped on project switch) --- #
|
|
358
|
+
def set_brief(self, text: str) -> None:
|
|
359
|
+
"""The teacher's framing for the current project — prepended to the model's context."""
|
|
360
|
+
self._brief = text or ""
|
|
361
|
+
if self._loop is not None:
|
|
362
|
+
self._loop.brief = self._brief
|
|
363
|
+
|
|
364
|
+
def brief(self) -> str:
|
|
365
|
+
return self._brief
|
|
366
|
+
|
|
367
|
+
def note_experiment(self, name: str) -> None:
|
|
368
|
+
"""Mark an experiment switch in the conversation.
|
|
369
|
+
|
|
370
|
+
The transcript is PROJECT-level, so it survives moving between experiments — that's the
|
|
371
|
+
point (the tutor should remember the whole arc). But without a marker the model would go on
|
|
372
|
+
reasoning about a canvas that has since been replaced. This drops a visible beat into the
|
|
373
|
+
transcript *and* the model's history, so 'the board' means the new board from here on."""
|
|
374
|
+
self._post("GINI", f"— switched to experiment “{name}” (the canvas is now this one) —")
|
|
375
|
+
if self._loop is not None:
|
|
376
|
+
from ..agent.llm.backend import Message
|
|
377
|
+
self._loop.history.append(
|
|
378
|
+
Message(role="user", content=f"[The user switched to experiment '{name}'. "
|
|
379
|
+
f"The canvas now shows that topology; earlier "
|
|
380
|
+
f"canvas details no longer apply.]"))
|
|
381
|
+
|
|
382
|
+
def ai_state(self) -> dict:
|
|
383
|
+
"""Serialisable snapshot: the visible transcript + the model's message history."""
|
|
384
|
+
history = []
|
|
385
|
+
if self._loop is not None:
|
|
386
|
+
for m in self._loop.history[1:]: # skip the system prompt (regenerated)
|
|
387
|
+
history.append({"role": m.role, "content": m.content, "name": m.name})
|
|
388
|
+
return {"messages": [list(t) for t in self._messages], "history": history}
|
|
389
|
+
|
|
390
|
+
def load_ai_state(self, state: dict | None) -> None:
|
|
391
|
+
"""Restore a project's conversation into the panel and the model's memory."""
|
|
392
|
+
from ..agent.session import SessionKnowledge
|
|
393
|
+
from ..agent.llm.backend import Message
|
|
394
|
+
state = state or {}
|
|
395
|
+
self._messages = [tuple(m) for m in state.get("messages", [])]
|
|
396
|
+
self._session = SessionKnowledge() # accumulator re-fills as chat continues
|
|
397
|
+
self._last_ref = None
|
|
398
|
+
self._rerender()
|
|
399
|
+
if self._loop is not None:
|
|
400
|
+
base = self._loop.history[0]
|
|
401
|
+
self._loop.history = [base] + [
|
|
402
|
+
Message(h.get("role", "user"), h.get("content", ""), name=h.get("name"))
|
|
403
|
+
for h in state.get("history", [])]
|
|
404
|
+
|
|
405
|
+
def clear_conversation(self) -> None:
|
|
406
|
+
"""Blank slate for a brand-new project."""
|
|
407
|
+
from ..agent.session import SessionKnowledge
|
|
408
|
+
self._messages = []
|
|
409
|
+
self._session = SessionKnowledge()
|
|
410
|
+
self._last_ref = None
|
|
411
|
+
self._brief = ""
|
|
412
|
+
self.log.clear()
|
|
413
|
+
if self._loop is not None:
|
|
414
|
+
self._loop.history = self._loop.history[:1]
|
|
415
|
+
self._loop.brief = ""
|
|
416
|
+
self._show_cloud() # blank slate -> back to the topic cloud
|
|
417
|
+
|
|
418
|
+
# chat ------------------------------------------------------------------ #
|
|
419
|
+
def _md_to_html(self, md: str) -> str:
|
|
420
|
+
"""Render the model's Markdown (headers, tables, lists, bold, code) to HTML so the
|
|
421
|
+
chat reads like a real assistant, not a raw dump. Qt's Markdown parser emits no
|
|
422
|
+
explicit colours, so the themed palette shows through."""
|
|
423
|
+
from PySide6.QtGui import QTextDocument
|
|
424
|
+
doc = QTextDocument()
|
|
425
|
+
try:
|
|
426
|
+
doc.setMarkdown(md or "", QTextDocument.MarkdownFeature.MarkdownDialectGitHub)
|
|
427
|
+
except Exception:
|
|
428
|
+
import html as _h
|
|
429
|
+
return _h.escape(md or "").replace("\n", "<br>")
|
|
430
|
+
html = doc.toHtml()
|
|
431
|
+
m = re.search(r"<body[^>]*>(.*)</body>", html, re.S)
|
|
432
|
+
return m.group(1) if m else html
|
|
433
|
+
|
|
434
|
+
def _role_color(self, role: str, ai: bool) -> str:
|
|
435
|
+
"""Colour per speaker, so one shared transcript reads clearly. ProfAI (and any …AI) is
|
|
436
|
+
deliberately the muted colour and carries an 'AI' tag — a student must always be able to tell
|
|
437
|
+
an AI standing in for a human from the human themselves. That distinction is a safety line,
|
|
438
|
+
not decoration, so it never blends into the person it speaks for."""
|
|
439
|
+
t = self.theme.theme
|
|
440
|
+
if ai:
|
|
441
|
+
return t.muted
|
|
442
|
+
if role == "GINI":
|
|
443
|
+
return t.accent
|
|
444
|
+
if role in ("You", "you"):
|
|
445
|
+
return t.muted
|
|
446
|
+
if role == "Prof":
|
|
447
|
+
return getattr(t, "success", t.accent) # the real instructor: solid, authoritative
|
|
448
|
+
return getattr(t, "accent2", t.accent) # a groupmate
|
|
449
|
+
|
|
450
|
+
def _msg_html(self, role: str, text: str, error: bool = False,
|
|
451
|
+
markdown: bool = False, ai: bool = False) -> str:
|
|
452
|
+
t = self.theme.theme
|
|
453
|
+
label = self._role_color(role, ai)
|
|
454
|
+
body = getattr(t, "danger", "#ff5555") if error else t.text # errors in red
|
|
455
|
+
tag = ' <span style="font-size:10px;opacity:.7;">· AI</span>' if ai else ""
|
|
456
|
+
lbl = f'<b style="color:{label};">{role}:{tag}</b>' if not ai else \
|
|
457
|
+
f'<b style="color:{label};">{role}</b><span style="color:{label};font-size:10px;">' \
|
|
458
|
+
f' · AI</span><b style="color:{label};">:</b>'
|
|
459
|
+
if markdown and not error:
|
|
460
|
+
return (f'<div style="margin:6px 0;">{lbl}'
|
|
461
|
+
f'<div style="color:{body};">{self._md_to_html(text)}</div></div>')
|
|
462
|
+
return (f'<p style="margin:6px 0;">{lbl} '
|
|
463
|
+
f'<span style="color:{body};">{text}</span></p>')
|
|
464
|
+
|
|
465
|
+
def _post(self, role: str, text: str, error: bool = False,
|
|
466
|
+
markdown: bool = False) -> None:
|
|
467
|
+
self._messages.append((role, text, error, markdown))
|
|
468
|
+
# only paint into the log if the GINI conversation is the one on screen; otherwise it's
|
|
469
|
+
# stored and shown when the student switches back (an async reply must not bleed into a
|
|
470
|
+
# human thread they've navigated to).
|
|
471
|
+
if getattr(self, "_convo", "gini") == "gini":
|
|
472
|
+
self.log.append(self._msg_html(role, text, error, markdown))
|
|
473
|
+
if hasattr(self, "_stack"):
|
|
474
|
+
self._stack.setCurrentWidget(self.log)
|
|
475
|
+
self.ctx.bus.assistant_message.emit(role, text)
|
|
476
|
+
if role == "GINI":
|
|
477
|
+
self._raise_self() # surface the Ask GINI tab so replies aren't missed
|
|
478
|
+
|
|
479
|
+
def _rerender(self) -> None:
|
|
480
|
+
"""Re-paint the whole conversation in the current theme's colours (message
|
|
481
|
+
colours are baked into the HTML at post time, so a theme switch must redraw)."""
|
|
482
|
+
if getattr(self, "_convo", "gini") != "gini":
|
|
483
|
+
self._render_convo() # a human thread is showing — repaint that
|
|
484
|
+
return
|
|
485
|
+
self.log.clear()
|
|
486
|
+
for role, text, error, markdown in self._messages:
|
|
487
|
+
self.log.append(self._msg_html(role, text, error, markdown))
|
|
488
|
+
if hasattr(self, "_cloud_flow"):
|
|
489
|
+
self._populate_cloud() # recolour the cloud on theme switch
|
|
490
|
+
|
|
491
|
+
# -- unified conversations: GINI + people in one surface ---------------- #
|
|
492
|
+
_CONVO_NOTE = {
|
|
493
|
+
"teacher": "Only you and your instructor (and ProfAI, when they're away) can read this.",
|
|
494
|
+
"group": "Your whole group — and your instructor and ProfAI — can read this channel.",
|
|
495
|
+
"dm": "Private. Your instructor and ProfAI don't see this. Stored on the course server.",
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
def _on_enrolment_convos(self, student: str, online: bool, due: int) -> None:
|
|
499
|
+
"""Signed in → start polling for messages and show the ribbon. Signed out → hide it and
|
|
500
|
+
fall back to the GINI-only surface (exactly the pre-enrolment experience)."""
|
|
501
|
+
if student:
|
|
502
|
+
self._poll_convos()
|
|
503
|
+
if not self._convo_timer.isActive():
|
|
504
|
+
self._convo_timer.start()
|
|
505
|
+
else:
|
|
506
|
+
self._convo_timer.stop()
|
|
507
|
+
self._channels = []
|
|
508
|
+
self._human_msgs = []
|
|
509
|
+
self._select_convo("gini")
|
|
510
|
+
self._rebuild_convo_ribbon()
|
|
511
|
+
|
|
512
|
+
def _poll_convos(self) -> None:
|
|
513
|
+
tc = getattr(self.ctx, "teaching_center", None)
|
|
514
|
+
if tc is None or not tc.signed_in():
|
|
515
|
+
return
|
|
516
|
+
|
|
517
|
+
def work():
|
|
518
|
+
try:
|
|
519
|
+
chans = tc.channels()
|
|
520
|
+
msgs = tc.messages()
|
|
521
|
+
except Exception: # noqa: BLE001 — a failed poll is a non-event
|
|
522
|
+
return
|
|
523
|
+
self.mission_ui_op.emit(("convos", chans, msgs))
|
|
524
|
+
threading.Thread(target=work, daemon=True).start()
|
|
525
|
+
|
|
526
|
+
def _apply_convo_op(self, op) -> None:
|
|
527
|
+
if not isinstance(op, tuple) or not op or op[0] != "convos":
|
|
528
|
+
return # not ours (the mission queue is shared)
|
|
529
|
+
_, chans, msgs = op
|
|
530
|
+
self._channels = chans or []
|
|
531
|
+
self._human_msgs = msgs or []
|
|
532
|
+
self._rebuild_convo_ribbon()
|
|
533
|
+
if self._convo != "gini":
|
|
534
|
+
self._render_convo()
|
|
535
|
+
|
|
536
|
+
def _rebuild_convo_ribbon(self) -> None:
|
|
537
|
+
while self._convo_row.count():
|
|
538
|
+
it = self._convo_row.takeAt(0)
|
|
539
|
+
w = it.widget()
|
|
540
|
+
if w is not None:
|
|
541
|
+
w.setParent(None); w.deleteLater()
|
|
542
|
+
self._convo_btns = {}
|
|
543
|
+
# no course / no channels → no ribbon at all (solo experience unchanged)
|
|
544
|
+
if not self._channels:
|
|
545
|
+
self._convo_scroll.setVisible(False)
|
|
546
|
+
return
|
|
547
|
+
self._convo_scroll.setVisible(True)
|
|
548
|
+
self._add_convo_btn("gini", "GINI")
|
|
549
|
+
for c in self._channels:
|
|
550
|
+
self._add_convo_btn(c["id"], c.get("title", c["id"]), kind=c.get("kind", ""))
|
|
551
|
+
self._convo_row.addStretch(1)
|
|
552
|
+
|
|
553
|
+
def _add_convo_btn(self, cid: str, title: str, kind: str = "") -> None:
|
|
554
|
+
b = QPushButton(title)
|
|
555
|
+
b.setObjectName("ModeBtn")
|
|
556
|
+
b.setCheckable(True)
|
|
557
|
+
b.setChecked(cid == self._convo)
|
|
558
|
+
b.setCursor(Qt.PointingHandCursor)
|
|
559
|
+
# an unread dot when another conversation has something newer than you've seen
|
|
560
|
+
if cid != "gini" and cid != self._convo:
|
|
561
|
+
newest = max((m["ts"] for m in self._human_msgs if m.get("channel") == cid),
|
|
562
|
+
default=0)
|
|
563
|
+
if newest > self._convo_seen.get(cid, 0):
|
|
564
|
+
b.setText(title + " •")
|
|
565
|
+
b.clicked.connect(lambda _=False, c=cid: self._select_convo(c))
|
|
566
|
+
self._convo_btns[cid] = b
|
|
567
|
+
self._convo_row.addWidget(b)
|
|
568
|
+
|
|
569
|
+
def _select_convo(self, cid: str) -> None:
|
|
570
|
+
self._convo = cid
|
|
571
|
+
gini = cid == "gini"
|
|
572
|
+
# the GINI controls belong to the GINI conversation; a human thread shows a plain message box
|
|
573
|
+
for w in (getattr(self, "_mode_scroll", None),):
|
|
574
|
+
if w is not None:
|
|
575
|
+
w.setVisible(gini)
|
|
576
|
+
self._set_gini_controls_visible(gini)
|
|
577
|
+
if gini:
|
|
578
|
+
self.input.setPlaceholderText("Ask GINI to build, inspect, or explain…")
|
|
579
|
+
self._rerender()
|
|
580
|
+
else:
|
|
581
|
+
ch = next((c for c in self._channels if c["id"] == cid), {})
|
|
582
|
+
who = ch.get("title", "them")
|
|
583
|
+
self.input.setPlaceholderText(f"Message {who}…")
|
|
584
|
+
self._convo_seen[cid] = time.time() # mark read
|
|
585
|
+
self._render_convo()
|
|
586
|
+
# reflect selection on the ribbon
|
|
587
|
+
for c, btn in self._convo_btns.items():
|
|
588
|
+
btn.setChecked(c == cid)
|
|
589
|
+
self._rebuild_convo_ribbon()
|
|
590
|
+
|
|
591
|
+
def _set_gini_controls_visible(self, on: bool) -> None:
|
|
592
|
+
"""Show/hide GINI's own controls (mode chips, wizard/coach panels, followups) so a human
|
|
593
|
+
thread isn't cluttered with build tools."""
|
|
594
|
+
for name in ("_follow_box", "_wz_panel", "_picker_header", "_picker_scroll"):
|
|
595
|
+
w = getattr(self, name, None)
|
|
596
|
+
if w is not None and not on:
|
|
597
|
+
w.setVisible(False)
|
|
598
|
+
for b in (self._chat_btn, self._explain_btn, self._wizard_btn, self._coach_btn,
|
|
599
|
+
self._missions_btn, self._tutor_box):
|
|
600
|
+
b.setVisible(on)
|
|
601
|
+
|
|
602
|
+
def _render_convo(self) -> None:
|
|
603
|
+
"""Paint the active HUMAN thread into the shared transcript."""
|
|
604
|
+
if self._convo == "gini":
|
|
605
|
+
return
|
|
606
|
+
ch = next((c for c in self._channels if c["id"] == self._convo), {})
|
|
607
|
+
self.log.clear()
|
|
608
|
+
note = self._CONVO_NOTE.get(ch.get("kind", ""), "")
|
|
609
|
+
if note:
|
|
610
|
+
self.log.append(f'<p style="color:{self.theme.theme.muted};font-size:11px;'
|
|
611
|
+
f'margin:2px 0 8px;">{note}</p>')
|
|
612
|
+
for m in self._human_msgs:
|
|
613
|
+
if m.get("channel") != self._convo:
|
|
614
|
+
continue
|
|
615
|
+
ai = m.get("kind") == "ai"
|
|
616
|
+
self.log.append(self._msg_html(m.get("from", "?"), m.get("body", ""), ai=ai))
|
|
617
|
+
self._stack.setCurrentWidget(self.log)
|
|
618
|
+
self.log.verticalScrollBar().setValue(self.log.verticalScrollBar().maximum())
|
|
619
|
+
|
|
620
|
+
def open_conversation(self, channel_id: str) -> None:
|
|
621
|
+
"""Focus a specific human thread (used by the User pill's Messages / group items)."""
|
|
622
|
+
self._raise_self()
|
|
623
|
+
if any(c["id"] == channel_id for c in self._channels):
|
|
624
|
+
self._select_convo(channel_id)
|
|
625
|
+
else:
|
|
626
|
+
self._select_convo("gini") # not loaded yet → land on GINI, poll will add it
|
|
627
|
+
|
|
628
|
+
# -- topic cloud (empty-state) ----------------------------------------- #
|
|
629
|
+
def _build_cloud(self) -> QWidget:
|
|
630
|
+
from .flow_layout import FlowLayout
|
|
631
|
+
w = QWidget()
|
|
632
|
+
outer = QVBoxLayout(w); outer.setContentsMargins(12, 12, 12, 12); outer.setSpacing(8)
|
|
633
|
+
cap = QLabel("Not sure what to ask? Tap a topic to explore or build — or just type "
|
|
634
|
+
"anything.")
|
|
635
|
+
cap.setObjectName("Muted"); cap.setWordWrap(True)
|
|
636
|
+
outer.addWidget(cap)
|
|
637
|
+
host = QWidget()
|
|
638
|
+
self._cloud_flow = FlowLayout(host, spacing=6)
|
|
639
|
+
sc = QScrollArea(); sc.setWidgetResizable(True); sc.setWidget(host)
|
|
640
|
+
sc.setFrameShape(QScrollArea.NoFrame)
|
|
641
|
+
outer.addWidget(sc, 1)
|
|
642
|
+
self._populate_cloud()
|
|
643
|
+
return w
|
|
644
|
+
|
|
645
|
+
def _populate_cloud(self) -> None:
|
|
646
|
+
import random
|
|
647
|
+
from ..domain.topic_cloud import topic_cloud
|
|
648
|
+
t = self.theme.theme
|
|
649
|
+
flow = self._cloud_flow
|
|
650
|
+
while flow.count(): # clear (rebuild on theme change)
|
|
651
|
+
it = flow.takeAt(0)
|
|
652
|
+
if it.widget():
|
|
653
|
+
it.widget().deleteLater()
|
|
654
|
+
items = topic_cloud()
|
|
655
|
+
random.shuffle(items) # a little freshness each visit
|
|
656
|
+
items = items[:36]
|
|
657
|
+
for it in items:
|
|
658
|
+
label = ("▶ " + it.label) if it.kind == "recipe" else it.label
|
|
659
|
+
btn = QPushButton(label)
|
|
660
|
+
px = {3: 15, 2: 13, 1: 11}.get(it.weight, 12)
|
|
661
|
+
col = t.accent_for(it.accent) if hasattr(t, "accent_for") else t.accent
|
|
662
|
+
btn.setCursor(Qt.PointingHandCursor)
|
|
663
|
+
btn.setStyleSheet(
|
|
664
|
+
f"QPushButton{{border:1px solid {col}; color:{col}; border-radius:13px; "
|
|
665
|
+
f"padding:4px 11px; font-size:{_sp(px)}px; background:transparent;}}"
|
|
666
|
+
f"QPushButton:hover{{background:{col}22;}}")
|
|
667
|
+
btn.clicked.connect(lambda _=False, q=it.query: self._cloud_pick(q))
|
|
668
|
+
flow.addWidget(btn)
|
|
669
|
+
|
|
670
|
+
def _cloud_pick(self, query: str) -> None:
|
|
671
|
+
self.input.setText(query) # show what was asked, then send it
|
|
672
|
+
self._send()
|
|
673
|
+
|
|
674
|
+
def _in_chat_mode(self) -> bool:
|
|
675
|
+
return not (self.missions_mode or self.wizard_mode or self.coach_mode or self.explain_mode)
|
|
676
|
+
|
|
677
|
+
def _refresh_stack(self) -> None:
|
|
678
|
+
"""The topic-cloud empty-state belongs to CHAT only; every other mode (Missions, Wizard,
|
|
679
|
+
Coach, Explain) has its own panel/picker, so the cloud would just waste the panel. Show the
|
|
680
|
+
cloud only for a Chat the student hasn't engaged yet (no message from them — the welcome line
|
|
681
|
+
is from GINI); otherwise show the conversation area."""
|
|
682
|
+
if not hasattr(self, "_stack"):
|
|
683
|
+
return
|
|
684
|
+
engaged = any(role == "You" for role, *_ in self._messages)
|
|
685
|
+
if self._in_chat_mode() and not engaged:
|
|
686
|
+
self._stack.setCurrentIndex(0) # index 0 is the cloud
|
|
687
|
+
else:
|
|
688
|
+
self._stack.setCurrentWidget(self.log)
|
|
689
|
+
|
|
690
|
+
def _show_cloud(self) -> None:
|
|
691
|
+
self._refresh_stack()
|
|
692
|
+
|
|
693
|
+
def _raise_self(self) -> None:
|
|
694
|
+
from PySide6.QtWidgets import QDockWidget
|
|
695
|
+
w = self.parent()
|
|
696
|
+
while w is not None and not isinstance(w, QDockWidget):
|
|
697
|
+
w = w.parent()
|
|
698
|
+
if w is not None:
|
|
699
|
+
w.raise_()
|
|
700
|
+
|
|
701
|
+
def showEvent(self, e) -> None: # panel is chat-ready: cursor waits in the input
|
|
702
|
+
super().showEvent(e)
|
|
703
|
+
self.input.setFocus()
|
|
704
|
+
|
|
705
|
+
def _send(self) -> None:
|
|
706
|
+
text = self.input.text().strip()
|
|
707
|
+
if not text:
|
|
708
|
+
return
|
|
709
|
+
self.input.clear()
|
|
710
|
+
# a human conversation is selected → the message goes to a person, not the AI router
|
|
711
|
+
if getattr(self, "_convo", "gini") != "gini":
|
|
712
|
+
self._send_human(text)
|
|
713
|
+
return
|
|
714
|
+
self._post("You", text)
|
|
715
|
+
# Wizard's objective is set via its own goal box; the chat box here is for
|
|
716
|
+
# refining / asking (goal-aware via the injected context) — same brain as Q&A.
|
|
717
|
+
try:
|
|
718
|
+
reply = self._handle(text)
|
|
719
|
+
except Exception as e: # surface errors to the student rather than crashing
|
|
720
|
+
self._post("GINI", f"Sorry, I couldn't do that: {e}", error=True) # red
|
|
721
|
+
return
|
|
722
|
+
if reply is not None: # None => an LLM answer is coming asynchronously
|
|
723
|
+
self._post("GINI", reply)
|
|
724
|
+
self._refresh_followups() # chips appear iff this was an explain (_last_ref set)
|
|
725
|
+
|
|
726
|
+
def _send_human(self, text: str) -> None:
|
|
727
|
+
"""Send to the selected person/group via the Teaching Center, then re-poll so the thread
|
|
728
|
+
(including any ProfAI reply the server generated) shows up."""
|
|
729
|
+
tc = getattr(self.ctx, "teaching_center", None)
|
|
730
|
+
ch = next((c for c in self._channels if c["id"] == self._convo), {})
|
|
731
|
+
if tc is None or not ch:
|
|
732
|
+
return
|
|
733
|
+
to = ("teacher" if ch["kind"] == "teacher" else
|
|
734
|
+
"group" if ch["kind"] == "group" else ch.get("peer", ""))
|
|
735
|
+
# optimistic echo (the next poll reconciles from the server copy)
|
|
736
|
+
self._human_msgs.append({"channel": self._convo, "from": "You", "kind": "human",
|
|
737
|
+
"body": text, "ts": time.time()})
|
|
738
|
+
self._render_convo()
|
|
739
|
+
|
|
740
|
+
def work():
|
|
741
|
+
res = tc.send_message(to, text)
|
|
742
|
+
if not res.get("ok"):
|
|
743
|
+
self.ctx.log(f"Message not sent: {res.get('error', 'unknown error')}", "error")
|
|
744
|
+
try:
|
|
745
|
+
msgs = tc.messages()
|
|
746
|
+
except Exception: # noqa: BLE001
|
|
747
|
+
msgs = None
|
|
748
|
+
if msgs is not None:
|
|
749
|
+
self.mission_ui_op.emit(("convos", self._channels, msgs))
|
|
750
|
+
threading.Thread(target=work, daemon=True).start()
|
|
751
|
+
|
|
752
|
+
# deterministic intent handling (LLM-free) ------------------------------ #
|
|
753
|
+
def _handle(self, text: str) -> str:
|
|
754
|
+
low = text.lower().strip()
|
|
755
|
+
self._last_ref = None # cleared by default; explain paths below set it
|
|
756
|
+
|
|
757
|
+
# During an active Mission, the student's messages go to the game master (which reasons
|
|
758
|
+
# about their intent), not the general Q&A router.
|
|
759
|
+
if self._mission_ctrl is not None and self._mission_ctrl.active:
|
|
760
|
+
if low in ("end mission", "quit mission", "stop mission"):
|
|
761
|
+
self.end_mission()
|
|
762
|
+
return "Mission ended."
|
|
763
|
+
if low in ("run", "check", "run check", "run/check", "/run"):
|
|
764
|
+
self._dispatch_mission("run_check") # behavioral probes on the live runtime
|
|
765
|
+
return None
|
|
766
|
+
if low in ("hint", "/hint", "help", "i'm stuck", "im stuck", "stuck"):
|
|
767
|
+
self._dispatch_mission("ask", "I'm stuck — give me a hint, but don't solve it for me.")
|
|
768
|
+
return None
|
|
769
|
+
self._dispatch_mission("ask", text) # game master reasons off the UI thread
|
|
770
|
+
return None
|
|
771
|
+
|
|
772
|
+
# In Missions mode with no mission playing yet, the chat box IS the "describe a mission"
|
|
773
|
+
# box: the student's words are composed into a playable, gradable mission (never fabricated).
|
|
774
|
+
if self.missions_mode and (self._mission_ctrl is None or not self._mission_ctrl.active):
|
|
775
|
+
mm = re.match(r"(?:/mission|start mission)\s+([\w-]+)", low)
|
|
776
|
+
if mm:
|
|
777
|
+
return self._start_preview_mission(mm.group(1))
|
|
778
|
+
return self._describe_mission(text)
|
|
779
|
+
|
|
780
|
+
# Preview launcher (temporary; the real UI surface is a design decision): "/mission <id>"
|
|
781
|
+
# or "start mission <id>" launches a seed archetype from the Game Catalog.
|
|
782
|
+
m = re.match(r"(?:/mission|start mission)\s+([\w-]+)", low)
|
|
783
|
+
if m:
|
|
784
|
+
return self._start_preview_mission(m.group(1))
|
|
785
|
+
|
|
786
|
+
if low in ("explain", "explain this", "explain this topology", "what is happening"):
|
|
787
|
+
if self._explain_btn.isChecked():
|
|
788
|
+
self._run_overview() # already in explain mode: re-explain
|
|
789
|
+
else:
|
|
790
|
+
self._explain_btn.setChecked(True) # -> _toggle_explain runs the overview
|
|
791
|
+
return None
|
|
792
|
+
if low in ("summary", "summarize", "stats"):
|
|
793
|
+
s = self.api.summary()
|
|
794
|
+
cats = ", ".join(f"{v} {k.lower()}" for k, v in s["by_category"].items()) or "nothing yet"
|
|
795
|
+
return f"{s['devices']} devices, {s['links']} links — {cats}."
|
|
796
|
+
if low in ("status", "context", "what's on the canvas", "whats on the canvas",
|
|
797
|
+
"what is on the canvas", "what do i have", "show topology"):
|
|
798
|
+
return self.api.context_digest()
|
|
799
|
+
if low in ("list", "what can you add", "device types"):
|
|
800
|
+
labels = ", ".join(sorted({d.label for d in all_devices()}))
|
|
801
|
+
return f"I can add: {labels}."
|
|
802
|
+
|
|
803
|
+
if low in ("recipes", "blueprints", "wizard"):
|
|
804
|
+
rs = self.api.list_recipes()
|
|
805
|
+
lines = "\n".join(f"• <b>{r['name']}</b> — {r['summary']} "
|
|
806
|
+
f"<i>(type “recipe {r['id']}” to lay it out)</i>" for r in rs)
|
|
807
|
+
return "Working blueprints I can lay out for you:<br>" + lines
|
|
808
|
+
|
|
809
|
+
m = re.match(r"recipe ([\w-]+)", low)
|
|
810
|
+
if m:
|
|
811
|
+
try:
|
|
812
|
+
res = self.api.apply_recipe(m.group(1))
|
|
813
|
+
except KeyError:
|
|
814
|
+
return (f"I don't have a recipe called “{m.group(1)}”. "
|
|
815
|
+
"Type “recipes” to see the list.")
|
|
816
|
+
self.ctx.bus.topology_changed.emit()
|
|
817
|
+
return (f"Laid out the <b>{res['name']}</b> blueprint — {len(res['added'])} "
|
|
818
|
+
f"elements, {res['links']} links. Press Run to start it.")
|
|
819
|
+
|
|
820
|
+
m = re.match(r"explain (.+)", low)
|
|
821
|
+
if m:
|
|
822
|
+
target = m.group(1).strip()
|
|
823
|
+
# "explain <thing>" is a DEVICE explanation only when <thing> is actually a placed
|
|
824
|
+
# device; otherwise it's a concept/topic question ("explain SDN") and must reach
|
|
825
|
+
# the retrieval pipeline — not be mistaken for a missing device.
|
|
826
|
+
if self._device_id(target.upper()) is not None:
|
|
827
|
+
return self._show_device(target.upper())
|
|
828
|
+
if self._loop is not None:
|
|
829
|
+
self._ask_gini(text) # concept/topic -> retrieval pipeline (async)
|
|
830
|
+
return None
|
|
831
|
+
reply = self._offline_concept(target) # offline: deterministic concept note
|
|
832
|
+
if reply is not None:
|
|
833
|
+
return reply
|
|
834
|
+
# else: fall through to the offline capability hint below
|
|
835
|
+
|
|
836
|
+
m = re.match(r"(?:add|create|place)(?: an?| a)? (.+)", low)
|
|
837
|
+
if m:
|
|
838
|
+
return self._add(m.group(1).strip())
|
|
839
|
+
|
|
840
|
+
m = re.match(r"connect (.+?) (?:and|to|with) (.+)", low)
|
|
841
|
+
if m:
|
|
842
|
+
r = self.api.connect(m.group(1).strip().upper(), m.group(2).strip().upper())
|
|
843
|
+
return f"Connected {r['source']} ↔ {r['target']}."
|
|
844
|
+
|
|
845
|
+
m = (re.match(r"(?:show |trace |animate )?(?:the )?path (?:from )?(\w+) (?:to|->|→) (\w+)", low)
|
|
846
|
+
or re.match(r"how (?:does|do|can|would) (\w+) (?:reach|get to|ping|talk to|connect to) (\w+)", low))
|
|
847
|
+
if m:
|
|
848
|
+
return self._trace_and_show(m.group(1).upper(), m.group(2).upper())
|
|
849
|
+
|
|
850
|
+
if self._loop is not None:
|
|
851
|
+
self._ask_gini(text) # free-form -> understand + retrieve + reason
|
|
852
|
+
return None
|
|
853
|
+
if self._llm is not None:
|
|
854
|
+
return self._llm(text)
|
|
855
|
+
# offline with no model: still show we can see the canvas, then guide.
|
|
856
|
+
hint = ("I can: add a <device>, connect A and B, explain [device], summarize, "
|
|
857
|
+
"status, or list. Connect a local Ollama model (set GINI_LLM_URL) for "
|
|
858
|
+
"open-ended questions.")
|
|
859
|
+
if self.ctx.topology.devices:
|
|
860
|
+
return f"{self.api.context_digest()}\n\n{hint}"
|
|
861
|
+
return hint
|
|
862
|
+
|
|
863
|
+
# tutor-mode helpers ---------------------------------------------------- #
|
|
864
|
+
def _chip(self, cmd: str) -> None:
|
|
865
|
+
if cmd == "__path__":
|
|
866
|
+
hosts = [d.name for d in self.ctx.topology.devices.values()
|
|
867
|
+
if d.type_key in ("host", "instance", "container")]
|
|
868
|
+
if len(hosts) < 2:
|
|
869
|
+
self._post("GINI", "Add two machines and a link, then I'll trace the path.")
|
|
870
|
+
return
|
|
871
|
+
self._post("You", f"show path {hosts[0]} to {hosts[-1]}")
|
|
872
|
+
self._post("GINI", self._trace_and_show(hosts[0], hosts[-1]))
|
|
873
|
+
return
|
|
874
|
+
self.input.setText(cmd)
|
|
875
|
+
self._send()
|
|
876
|
+
|
|
877
|
+
# --- follow-up suggestion chips ---------------------------------------- #
|
|
878
|
+
def _clear_followups(self) -> None:
|
|
879
|
+
while self._follow_lay.count():
|
|
880
|
+
item = self._follow_lay.takeAt(0)
|
|
881
|
+
w = item.widget()
|
|
882
|
+
if w is not None:
|
|
883
|
+
w.deleteLater()
|
|
884
|
+
self._follow_box.setVisible(False)
|
|
885
|
+
|
|
886
|
+
def _refresh_followups(self) -> None:
|
|
887
|
+
"""Show 2-3 context-aware next questions based on what we just explained."""
|
|
888
|
+
self._render_followups(self._followups_for(self._last_ref))
|
|
889
|
+
|
|
890
|
+
def _render_followups(self, chips: list[str]) -> None:
|
|
891
|
+
self._clear_followups()
|
|
892
|
+
if not chips:
|
|
893
|
+
return
|
|
894
|
+
for label in chips:
|
|
895
|
+
b = QPushButton(label)
|
|
896
|
+
b.setObjectName("Chip")
|
|
897
|
+
b.setCursor(Qt.PointingHandCursor)
|
|
898
|
+
b.clicked.connect(lambda _=False, c=label: self._run_followup(c))
|
|
899
|
+
self._follow_lay.addWidget(b)
|
|
900
|
+
self._follow_lay.addStretch(1)
|
|
901
|
+
self._follow_box.setVisible(True)
|
|
902
|
+
|
|
903
|
+
def _show_context_chips(self) -> None:
|
|
904
|
+
"""In Chat mode, keep contextual quick-actions visible (Show a path when there are
|
|
905
|
+
≥2 hosts, What needs fixing? when there are warnings) — this is where the demoted
|
|
906
|
+
'Show a path' / 'Status' buttons now live, surfaced only when relevant."""
|
|
907
|
+
if (self.wizard_mode or self.coach_mode or self.missions_mode or self._busy
|
|
908
|
+
or self._last_ref is not None):
|
|
909
|
+
return
|
|
910
|
+
self._render_followups(self._followups_for(("overview", "")))
|
|
911
|
+
|
|
912
|
+
def _run_followup(self, text: str) -> None:
|
|
913
|
+
self._clear_followups()
|
|
914
|
+
if text == "Show a path":
|
|
915
|
+
self._chip("__path__")
|
|
916
|
+
return
|
|
917
|
+
if text == "Re-check": # Coach: re-scan after fixing
|
|
918
|
+
self._run_coach()
|
|
919
|
+
return
|
|
920
|
+
if text.startswith("Fix "): # Coach: explain + how to fix a flagged element
|
|
921
|
+
self.explain_warning(text[4:].strip())
|
|
922
|
+
return
|
|
923
|
+
self.input.setText(text)
|
|
924
|
+
self._send()
|
|
925
|
+
|
|
926
|
+
def _followups_for(self, ref: tuple[str, str] | None) -> list[str]:
|
|
927
|
+
if not ref:
|
|
928
|
+
return []
|
|
929
|
+
kind, val = ref
|
|
930
|
+
t = self.ctx.topology
|
|
931
|
+
if kind == "device":
|
|
932
|
+
d = next((x for x in t.devices.values() if x.name == val), None)
|
|
933
|
+
role = d.type_key if d else ""
|
|
934
|
+
if role in ("router", "firewall"):
|
|
935
|
+
return [f"How does {val} forward a packet?",
|
|
936
|
+
f"What's connected to {val}?", "Compare a router and a switch"]
|
|
937
|
+
if role in ("switch", "hub", "ovs"):
|
|
938
|
+
return [f"How does {val} move frames?",
|
|
939
|
+
"What's the difference between a switch and a hub?",
|
|
940
|
+
f"What's connected to {val}?"]
|
|
941
|
+
if role in ("host", "instance", "container"):
|
|
942
|
+
return [f"What is {val}'s gateway?",
|
|
943
|
+
f"How does {val} reach another machine?", "Show a path"]
|
|
944
|
+
return [f"Why is {val} here?", f"What's connected to {val}?"]
|
|
945
|
+
if kind == "warning":
|
|
946
|
+
return [f"How do I fix {val}?", f"Explain {val}", "Explain my topology"]
|
|
947
|
+
if kind == "type":
|
|
948
|
+
return ["When should I NOT use it?",
|
|
949
|
+
"Compare it to the alternatives", "Explain my topology"]
|
|
950
|
+
if kind == "overview":
|
|
951
|
+
chips: list[str] = []
|
|
952
|
+
names = [d.name for d in t.devices.values()]
|
|
953
|
+
if names:
|
|
954
|
+
chips.append(f"Explain {names[0]}")
|
|
955
|
+
hosts = sum(1 for d in t.devices.values()
|
|
956
|
+
if d.type_key in ("host", "instance", "container"))
|
|
957
|
+
if hosts >= 2:
|
|
958
|
+
chips.append("Show a path")
|
|
959
|
+
if self.ctx.warnings:
|
|
960
|
+
chips.append("What needs fixing?")
|
|
961
|
+
return chips[:3]
|
|
962
|
+
return []
|
|
963
|
+
|
|
964
|
+
def explain_warning(self, name: str) -> None:
|
|
965
|
+
"""Student clicked a device's amber lint badge — explain why it's flagged and
|
|
966
|
+
how to fix it, tying the validation back into the tutor."""
|
|
967
|
+
issues = self.ctx.warnings.get(name) or []
|
|
968
|
+
problem = "; ".join(issues) if issues else "a possible configuration issue"
|
|
969
|
+
self._post("You", f"Why is {name} flagged?")
|
|
970
|
+
self._last_ref = ("warning", name)
|
|
971
|
+
did = self._device_id(name)
|
|
972
|
+
if self._tutor and did:
|
|
973
|
+
self.ctx.bus.present_spotlight.emit([did])
|
|
974
|
+
self.ctx.bus.present_callout.emit(did, self._callout_line(problem))
|
|
975
|
+
if self._loop is not None:
|
|
976
|
+
facts = self.api.explain_device(name)
|
|
977
|
+
self._ask_async(
|
|
978
|
+
f"The student clicked a warning on {name}. The advisory lint says: "
|
|
979
|
+
f"\"{problem}\". Explain in 2-3 sentences why this is flagged and how to "
|
|
980
|
+
f"fix it, for a student. Facts: {facts}", name)
|
|
981
|
+
else:
|
|
982
|
+
self._post("GINI", f"{name}: {problem}. "
|
|
983
|
+
"Fix it by completing the missing link or gateway above.")
|
|
984
|
+
self._refresh_followups()
|
|
985
|
+
|
|
986
|
+
def _toggle_missions(self, on: bool) -> None:
|
|
987
|
+
"""Missions mode: pick an assigned lab and play it as a timed, assessed game. Entering
|
|
988
|
+
shows a picker; leaving ends any active mission. Model-gated (the button is disabled
|
|
989
|
+
without a model, so `on` implies a model is attached)."""
|
|
990
|
+
self.missions_mode = on
|
|
991
|
+
self._emit_status()
|
|
992
|
+
if on:
|
|
993
|
+
self.input.setPlaceholderText("Playing a Mission — type to talk to the game master…")
|
|
994
|
+
self._show_mission_picker()
|
|
995
|
+
else:
|
|
996
|
+
self.end_mission()
|
|
997
|
+
self._hide_mission_picker()
|
|
998
|
+
|
|
999
|
+
def enter_missions(self) -> bool:
|
|
1000
|
+
"""Jump straight into Missions (used by the toolbar's User pill — "show me my homework").
|
|
1001
|
+
Returns False if there's no model, since the mode is model-gated; the caller can say so."""
|
|
1002
|
+
if not self._missions_btn.isEnabled():
|
|
1003
|
+
self.ctx.log("Missions needs a local model — connect one in Settings → LLM.", "info")
|
|
1004
|
+
return False
|
|
1005
|
+
self._missions_btn.setChecked(True) # toggled → _toggle_missions → the picker
|
|
1006
|
+
return True
|
|
1007
|
+
|
|
1008
|
+
def _assigned_missions(self) -> list:
|
|
1009
|
+
"""Released, not-past-due missions from a connected Teaching Center (empty when no Center is
|
|
1010
|
+
wired — the code path is future-ready; today that means the practice state)."""
|
|
1011
|
+
tc = getattr(self.ctx, "teaching_center", None)
|
|
1012
|
+
if tc is None:
|
|
1013
|
+
return []
|
|
1014
|
+
try:
|
|
1015
|
+
return tc.available_lessons() or []
|
|
1016
|
+
except Exception:
|
|
1017
|
+
return []
|
|
1018
|
+
|
|
1019
|
+
def _picker_button(self, label: str, cb) -> None:
|
|
1020
|
+
b = QPushButton(label)
|
|
1021
|
+
b.setObjectName("Chip")
|
|
1022
|
+
b.setCursor(Qt.PointingHandCursor)
|
|
1023
|
+
b.setStyleSheet("text-align:left; padding:5px 10px;")
|
|
1024
|
+
b.clicked.connect(lambda _=False: cb())
|
|
1025
|
+
self._picker_lay.addWidget(b)
|
|
1026
|
+
|
|
1027
|
+
def _show_mission_picker(self) -> None:
|
|
1028
|
+
"""List the missions the student can play as a VERTICAL list of full-width buttons. When a
|
|
1029
|
+
Teaching Center has ASSIGNED missions, show ONLY those (mandatory); otherwise offer the full
|
|
1030
|
+
Game Catalog to practise, plus the describe box (the chat input) for a made-to-order one."""
|
|
1031
|
+
from ..domain import catalog
|
|
1032
|
+
while self._picker_lay.count(): # rebuild the list
|
|
1033
|
+
it = self._picker_lay.takeAt(0)
|
|
1034
|
+
if it.widget() is not None:
|
|
1035
|
+
it.widget().setParent(None)
|
|
1036
|
+
assigned = self._assigned_missions()
|
|
1037
|
+
if assigned:
|
|
1038
|
+
self._picker_header.setText("Assigned Missions (Mandatory)")
|
|
1039
|
+
for m in assigned:
|
|
1040
|
+
self._picker_button(m.get("title") or m.get("id"),
|
|
1041
|
+
lambda lid=m.get("id"): self._start_assigned_mission(lid))
|
|
1042
|
+
self.input.setPlaceholderText("Playing a Mission — pick an assigned mission above…")
|
|
1043
|
+
else:
|
|
1044
|
+
self._picker_header.setText(
|
|
1045
|
+
"No assigned missions — pick any to practice, or type what you want to build:")
|
|
1046
|
+
for a in catalog.all_archetypes():
|
|
1047
|
+
self._picker_button(a.summary, lambda aid=a.id: self._start_preview_mission(aid))
|
|
1048
|
+
self.input.setPlaceholderText("Pick a mission above, or describe one to build…")
|
|
1049
|
+
self._picker_header.setVisible(True)
|
|
1050
|
+
self._picker_scroll.setVisible(True)
|
|
1051
|
+
|
|
1052
|
+
def _start_assigned_mission(self, lesson_id: str) -> None:
|
|
1053
|
+
"""Fetch and play a Teaching-Center-assigned lesson by id."""
|
|
1054
|
+
tc = getattr(self.ctx, "teaching_center", None)
|
|
1055
|
+
les = None
|
|
1056
|
+
if tc is not None:
|
|
1057
|
+
try:
|
|
1058
|
+
les = tc.fetch_lesson(lesson_id)
|
|
1059
|
+
except Exception:
|
|
1060
|
+
les = None
|
|
1061
|
+
if les is None:
|
|
1062
|
+
self._post("GINI", "That mission couldn't be loaded right now.")
|
|
1063
|
+
return
|
|
1064
|
+
self.start_mission(les)
|
|
1065
|
+
|
|
1066
|
+
def _hide_mission_picker(self) -> None:
|
|
1067
|
+
self._picker_header.setVisible(False)
|
|
1068
|
+
self._picker_scroll.setVisible(False)
|
|
1069
|
+
|
|
1070
|
+
def _toggle_chat(self, on: bool) -> None:
|
|
1071
|
+
"""Chat = the default mode (no special click behaviour). The Explain/Wizard 'off'
|
|
1072
|
+
branches already clear the canvas stage + mission when switching away from them."""
|
|
1073
|
+
if not on:
|
|
1074
|
+
return
|
|
1075
|
+
self.input.setPlaceholderText("Ask GINI to build, inspect, or explain…")
|
|
1076
|
+
self._emit_status()
|
|
1077
|
+
self._show_context_chips() # surface contextual chips (Show a path, …)
|
|
1078
|
+
|
|
1079
|
+
def _toggle_coach(self, on: bool) -> None:
|
|
1080
|
+
"""Coach reviews the CURRENT canvas for problems and tells the student what to fix —
|
|
1081
|
+
the corrective complement to the Wizard. Detection is deterministic (the advisory
|
|
1082
|
+
lint); the model authors the coaching. Model-gated like Wizard."""
|
|
1083
|
+
self.coach_mode = on
|
|
1084
|
+
self.input.setPlaceholderText(
|
|
1085
|
+
"Coach mode — I'm reviewing your canvas. Ask a follow-up…" if on
|
|
1086
|
+
else "Ask GINI to build, inspect, or explain…")
|
|
1087
|
+
self._emit_status()
|
|
1088
|
+
if on:
|
|
1089
|
+
self._coach_toggled = True # entering (click or setChecked) -> review runs
|
|
1090
|
+
self._run_coach()
|
|
1091
|
+
else:
|
|
1092
|
+
self.ctx.bus.present_clear.emit()
|
|
1093
|
+
|
|
1094
|
+
def _on_coach_clicked(self) -> None:
|
|
1095
|
+
# `clicked` fires on EVERY click. When it merely re-selects the already-active Coach
|
|
1096
|
+
# button (no `toggled` is emitted), re-run the review — that's the "re-check after a
|
|
1097
|
+
# fix" gesture. On the entering click, `toggled` already ran it (guarded below).
|
|
1098
|
+
if not self.coach_mode:
|
|
1099
|
+
return
|
|
1100
|
+
if getattr(self, "_coach_toggled", False):
|
|
1101
|
+
self._coach_toggled = False
|
|
1102
|
+
return
|
|
1103
|
+
self._run_coach()
|
|
1104
|
+
|
|
1105
|
+
def _run_coach(self) -> None:
|
|
1106
|
+
ms = self._active_xv6_state()
|
|
1107
|
+
if ms is not None: # OS Coach: measured, state-grounded help
|
|
1108
|
+
self._run_os_coach(ms)
|
|
1109
|
+
return
|
|
1110
|
+
from ..agent.wizard import coach_prompt
|
|
1111
|
+
from ..services.compiler import validate
|
|
1112
|
+
self._clear_followups()
|
|
1113
|
+
try:
|
|
1114
|
+
issues = [i for i in validate(self.ctx.topology) if i.get("level") == "warn"]
|
|
1115
|
+
except Exception:
|
|
1116
|
+
issues = []
|
|
1117
|
+
if not issues:
|
|
1118
|
+
self.ctx.bus.present_clear.emit()
|
|
1119
|
+
self._post("GINI", "✓ I reviewed your canvas — no problems found. It looks "
|
|
1120
|
+
"complete and valid. Press <b>Run</b> when you're ready.")
|
|
1121
|
+
return
|
|
1122
|
+
flagged = [d for d in (self._device_id(i["device"]) for i in issues if i.get("device")) if d]
|
|
1123
|
+
if self._tutor and flagged:
|
|
1124
|
+
self.ctx.bus.present_highlight.emit(flagged) # draw the eye to the trouble spots
|
|
1125
|
+
self._last_ref = None
|
|
1126
|
+
if self._loop is not None: # model authors the coaching
|
|
1127
|
+
self._ask_async(coach_prompt(issues, self.api.context_digest()), "")
|
|
1128
|
+
else: # safety net (Coach is model-gated)
|
|
1129
|
+
self._post("GINI", "Things to look at — " + "; ".join(
|
|
1130
|
+
f"{i['device']}: {i['message']}" for i in issues[:4]))
|
|
1131
|
+
# one tappable fix per flagged element + a re-scan, surfaced as chips
|
|
1132
|
+
seen, chips = set(), ["Re-check"]
|
|
1133
|
+
for i in issues:
|
|
1134
|
+
d = i.get("device")
|
|
1135
|
+
if d and d not in seen:
|
|
1136
|
+
seen.add(d)
|
|
1137
|
+
chips.append(f"Fix {d}")
|
|
1138
|
+
self._render_followups(chips[:6])
|
|
1139
|
+
|
|
1140
|
+
def _on_machine_events(self, device_id: str) -> None:
|
|
1141
|
+
"""A running xv6 produced new teachable moments. If Coach mode is on and this is the
|
|
1142
|
+
machine in focus, coach proactively — bounded by the help budget and a short cooldown so
|
|
1143
|
+
it nudges rather than nags."""
|
|
1144
|
+
import time
|
|
1145
|
+
if not self.coach_mode or self._busy:
|
|
1146
|
+
return
|
|
1147
|
+
ms = self._active_xv6_state()
|
|
1148
|
+
if ms is None or getattr(ms, "device_id", None) != device_id:
|
|
1149
|
+
return
|
|
1150
|
+
if not ms.pending_events() or not ms.ledger.can_help():
|
|
1151
|
+
return
|
|
1152
|
+
now = time.monotonic()
|
|
1153
|
+
if now - getattr(self, "_coach_last_fire", 0.0) < 8.0: # cooldown
|
|
1154
|
+
return
|
|
1155
|
+
self._coach_last_fire = now
|
|
1156
|
+
self._run_os_coach(ms)
|
|
1157
|
+
|
|
1158
|
+
def _run_os_coach(self, ms) -> None:
|
|
1159
|
+
"""Coach an xv6 experiment: drain the detected teachable moments, then give ONE Socratic,
|
|
1160
|
+
budgeted, logged nudge grounded in the live kernel state (the 'measured help' moat)."""
|
|
1161
|
+
from ..agent.wizard import os_coach_prompt
|
|
1162
|
+
self._clear_followups()
|
|
1163
|
+
events = ms.drain_events()
|
|
1164
|
+
ledger = ms.ledger
|
|
1165
|
+
if not ledger.can_help():
|
|
1166
|
+
self._post("GINI", "You've used all your Coach hints for this run — try reasoning "
|
|
1167
|
+
"it through from the panels (who's running, who's starving, how "
|
|
1168
|
+
"the switches fall), or check with your instructor. Your Coach "
|
|
1169
|
+
"use is logged.")
|
|
1170
|
+
self._render_followups(["Re-check", "Step switch"])
|
|
1171
|
+
return
|
|
1172
|
+
card = ms.card(level=1)
|
|
1173
|
+
# Reasoning 2.0 (twin-as-context for the coach): the deterministic concern set picks the
|
|
1174
|
+
# most salient issue for the nudge to target; it is also the upgraded no-model fallback.
|
|
1175
|
+
concerns, focus = [], ""
|
|
1176
|
+
if getattr(self.ctx.settings, "twin_enabled", False):
|
|
1177
|
+
try:
|
|
1178
|
+
from ..agent.twin.os_coach import coach_concerns, fallback_text, focus_line
|
|
1179
|
+
concerns = coach_concerns(events, ms)
|
|
1180
|
+
focus = focus_line(concerns)
|
|
1181
|
+
except Exception:
|
|
1182
|
+
concerns, focus = [], ""
|
|
1183
|
+
if self._loop is not None: # the model authors the Socratic nudge
|
|
1184
|
+
ledger.record(events)
|
|
1185
|
+
self._ask_async(os_coach_prompt(events, card, ledger.remaining(), focus=focus), "")
|
|
1186
|
+
else: # deterministic fallback (Coach is model-gated)
|
|
1187
|
+
if concerns:
|
|
1188
|
+
ledger.record(events)
|
|
1189
|
+
from ..agent.twin.os_coach import fallback_text
|
|
1190
|
+
self._post("GINI", fallback_text(concerns))
|
|
1191
|
+
elif events:
|
|
1192
|
+
ledger.record(events)
|
|
1193
|
+
self._post("GINI", "Notice — " + "; ".join(e.detail for e in events[:2]))
|
|
1194
|
+
else:
|
|
1195
|
+
self._post("GINI", "The run looks steady. Try slowing the time-slice or "
|
|
1196
|
+
"spawning another CPU-bound process, and watch the switches.")
|
|
1197
|
+
self._render_followups(["Re-check", "Step switch"])
|
|
1198
|
+
|
|
1199
|
+
def _toggle_explain(self, on: bool) -> None:
|
|
1200
|
+
"""Explain mode: click a DEVICE to explain it; clicking empty canvas reverts to Chat
|
|
1201
|
+
(so it doesn't stay stuck on). Chat is the default mode."""
|
|
1202
|
+
self.explain_mode = on
|
|
1203
|
+
self.input.setPlaceholderText(
|
|
1204
|
+
"Explain mode — click any device on the canvas…" if on
|
|
1205
|
+
else "Ask GINI to build, inspect, or explain…")
|
|
1206
|
+
self._emit_status()
|
|
1207
|
+
if on:
|
|
1208
|
+
self._run_overview()
|
|
1209
|
+
|
|
1210
|
+
def _on_canvas_background(self) -> None:
|
|
1211
|
+
# clicking empty canvas exits Explain back to Chat (device clicks still explain).
|
|
1212
|
+
# Wizard/Coach are deliberate multi-step flows, so they stay put.
|
|
1213
|
+
if self.explain_mode:
|
|
1214
|
+
self._chat_btn.setChecked(True) # -> _toggle_chat / _toggle_explain(False)
|
|
1215
|
+
else:
|
|
1216
|
+
self.ctx.bus.present_clear.emit() # exit: clear the stage
|
|
1217
|
+
|
|
1218
|
+
def _toggle_wizard(self, on: bool) -> None:
|
|
1219
|
+
"""Wizard mode = X-ray with a goal. Describe an objective ("a multi-LAN IP network")
|
|
1220
|
+
and GINI guides you toward it: it highlights on-goal elements, long-press shows only
|
|
1221
|
+
the connections that serve the goal, and off-goal drops get flagged. You build it."""
|
|
1222
|
+
self.wizard_mode = on
|
|
1223
|
+
self._wz_panel.setVisible(on)
|
|
1224
|
+
self._wz_banner_box.setVisible(on and self.ctx.mission is not None)
|
|
1225
|
+
self.input.setPlaceholderText(
|
|
1226
|
+
"Refine the goal or ask GINI…" if on
|
|
1227
|
+
else "Ask GINI to build, inspect, or explain…")
|
|
1228
|
+
self._emit_status()
|
|
1229
|
+
if on:
|
|
1230
|
+
self._clear_followups()
|
|
1231
|
+
self._post("GINI", "Wizard mode on. Type your objective above and press <b>Set</b> "
|
|
1232
|
+
"— e.g. “a multi-LAN IP network”, “a cloud service on "
|
|
1233
|
+
"Kubernetes”. I'll reason about what it needs and guide you to "
|
|
1234
|
+
"build it: on-goal elements are highlighted, and long-pressing "
|
|
1235
|
+
"shows only the connections that fit the goal.")
|
|
1236
|
+
elif self.ctx.mission is not None:
|
|
1237
|
+
self.ctx.set_mission(None) # leaving guided mode clears the objective
|
|
1238
|
+
|
|
1239
|
+
def _run_overview(self) -> None:
|
|
1240
|
+
self._last_ref = ("overview", "")
|
|
1241
|
+
self._spotlight_hub()
|
|
1242
|
+
hint = "Click any device on the canvas to explain it. Toggle Explain off to exit."
|
|
1243
|
+
if self._loop is not None:
|
|
1244
|
+
self._ask_async("Give a 2-3 sentence overview of the current topology for a "
|
|
1245
|
+
"student, and note the most important device.", "")
|
|
1246
|
+
self._post("GINI", hint)
|
|
1247
|
+
else:
|
|
1248
|
+
narration = self.api.explain_topology()
|
|
1249
|
+
self._explain_stage(narration)
|
|
1250
|
+
self._post("GINI", narration + " · " + hint)
|
|
1251
|
+
self._refresh_followups()
|
|
1252
|
+
|
|
1253
|
+
def _spotlight_hub(self) -> None:
|
|
1254
|
+
t = self.ctx.topology
|
|
1255
|
+
if self._tutor and t.devices:
|
|
1256
|
+
hub = max(t.devices.values(), key=lambda d: t.degree(d.id))
|
|
1257
|
+
self.ctx.bus.present_spotlight.emit([hub.id])
|
|
1258
|
+
|
|
1259
|
+
def _device_id(self, name: str) -> str | None:
|
|
1260
|
+
return next((d.id for d in self.ctx.topology.devices.values()
|
|
1261
|
+
if d.name == name), None)
|
|
1262
|
+
|
|
1263
|
+
def _show_device(self, name: str) -> str | None:
|
|
1264
|
+
"""Explain one device and put it on the canvas. With an LLM connected, the
|
|
1265
|
+
explanation is authored by the model (async, in the shared conversation, so it
|
|
1266
|
+
remembers what was discussed before); offline it uses the deterministic facts.
|
|
1267
|
+
Spotlight moves immediately so the click feels instant. Returns None when the
|
|
1268
|
+
answer is coming asynchronously (it posts itself)."""
|
|
1269
|
+
did = self._device_id(name)
|
|
1270
|
+
if did is None:
|
|
1271
|
+
return f"I don't see a device called {name}."
|
|
1272
|
+
self._last_ref = ("device", name)
|
|
1273
|
+
if self._tutor:
|
|
1274
|
+
self.ctx.bus.present_spotlight.emit([did]) # instant
|
|
1275
|
+
if self._loop is not None:
|
|
1276
|
+
facts = self.api.explain_device(name) # ground the model in real facts
|
|
1277
|
+
self._ask_async(f"The student is now looking at {name}. Explain it for them in "
|
|
1278
|
+
f"2-3 sentences, connecting it to what we discussed. Facts: {facts}",
|
|
1279
|
+
name)
|
|
1280
|
+
return None
|
|
1281
|
+
text = self.api.explain_device(name) # deterministic fallback
|
|
1282
|
+
if self._tutor:
|
|
1283
|
+
self.ctx.bus.present_callout.emit(did, self._callout_line(text))
|
|
1284
|
+
return text
|
|
1285
|
+
|
|
1286
|
+
# --- Missions: run an authored Lesson as a game -------------------------- #
|
|
1287
|
+
def start_mission(self, lesson) -> bool:
|
|
1288
|
+
"""Launch a Mission for `lesson` (a domain.lesson.Lesson). Requires a model (Missions
|
|
1289
|
+
are LLM-gated). Shows the objective tracker and hands control to the game master."""
|
|
1290
|
+
from ..agent.agent_gamemaster import AgentGameMaster
|
|
1291
|
+
from ..agent.mission_controller import MissionController
|
|
1292
|
+
from ..domain import profile as _profile
|
|
1293
|
+
if self._loop is None:
|
|
1294
|
+
self._post("GINI", "Missions need a local model — enable one in Settings → LLM.")
|
|
1295
|
+
return False
|
|
1296
|
+
if self._mission_profile is None:
|
|
1297
|
+
self._mission_profile = _profile.Profile(getattr(self.ctx.settings, "student_id", "local"))
|
|
1298
|
+
# post + panel updates are marshaled to the UI thread (the reactions run on a worker); the
|
|
1299
|
+
# world is a snapshot so the worker never reads the live canvas mid-mutation.
|
|
1300
|
+
self._mission_ctrl = MissionController(
|
|
1301
|
+
get_world=lambda: self._mission_world,
|
|
1302
|
+
llm=self._quick_llm,
|
|
1303
|
+
post=lambda role, tx: self.mission_ui_op.emit(("say", tx)),
|
|
1304
|
+
make_runner=self._mission_runner,
|
|
1305
|
+
panel=_MissionPanelProxy(self._mission_panel, self.mission_ui_op.emit),
|
|
1306
|
+
profile=self._mission_profile,
|
|
1307
|
+
submit=self._submit_to_center, # report the result to the course server
|
|
1308
|
+
# reasoning runs through the multi-agent stack; the Reasoning Twin audits coverage
|
|
1309
|
+
# when enabled in Settings (Reasoning 2.0 phase A, off by default)
|
|
1310
|
+
gm_factory=lambda lesson, **kw: AgentGameMaster(
|
|
1311
|
+
lesson, twin_enabled=getattr(self.ctx.settings, "twin_enabled", False), **kw))
|
|
1312
|
+
self._mission_busy = self._mission_dirty = False
|
|
1313
|
+
self._hide_mission_picker() # drop the picker once we're playing
|
|
1314
|
+
self._select_convo("gini") # a mission plays in the GINI surface, never a human thread
|
|
1315
|
+
self._convo_scroll.setVisible(False) # Missions takes over the panel (it's nice as-is)
|
|
1316
|
+
self._mission_panel.setVisible(True)
|
|
1317
|
+
if not self._apply_stage(lesson): # M3: pre-build the board, if the lesson stages one
|
|
1318
|
+
self.end_mission() # student kept their canvas — don't start on the wrong board
|
|
1319
|
+
return False
|
|
1320
|
+
self._mission_world = self._snapshot_world()
|
|
1321
|
+
self._archive_chat() # focus the panel on the mission (restored on exit)
|
|
1322
|
+
ok = self._mission_ctrl.start(lesson)
|
|
1323
|
+
if not ok:
|
|
1324
|
+
self.end_mission()
|
|
1325
|
+
else:
|
|
1326
|
+
self._update_mission_flags() # flag any off-task elements already on the canvas
|
|
1327
|
+
return ok
|
|
1328
|
+
|
|
1329
|
+
def _apply_stage(self, lesson) -> bool:
|
|
1330
|
+
"""M3: build the lesson's pre-set board onto the canvas (scaffolded / fault-injection labs).
|
|
1331
|
+
|
|
1332
|
+
A staged board is a *designed* board — the mission is graded against exactly it — so we
|
|
1333
|
+
clear the canvas first rather than stacking the stage on top of whatever was lying around
|
|
1334
|
+
(which used to let stale elements satisfy objectives, or collide with the injected fault).
|
|
1335
|
+
Clearing is destructive, so if the student has work on the canvas we ask. Returns False if
|
|
1336
|
+
they'd rather keep their board, in which case the mission does not start."""
|
|
1337
|
+
from ..domain import staging
|
|
1338
|
+
if not staging.is_staged(lesson):
|
|
1339
|
+
return True
|
|
1340
|
+
if staging.wants_reset(lesson) and self.ctx.topology.devices:
|
|
1341
|
+
if not self._confirm_clear_board(lesson):
|
|
1342
|
+
return False
|
|
1343
|
+
self.ctx.clear_topology()
|
|
1344
|
+
try:
|
|
1345
|
+
placed = staging.apply(lesson.stage,
|
|
1346
|
+
add_device=lambda tk, x, y: self.ctx.add_device(tk, x, y),
|
|
1347
|
+
add_link=lambda s, t: self.ctx.add_link(s, t),
|
|
1348
|
+
topology=self.ctx.topology)
|
|
1349
|
+
# bring the pre-built board INTO VIEW — otherwise it can land off-screen and the student
|
|
1350
|
+
# thinks the mission placed nothing (they shouldn't have to go hunting for it).
|
|
1351
|
+
ids = [inst.id for inst in placed.values() if getattr(inst, "id", None)]
|
|
1352
|
+
if ids:
|
|
1353
|
+
self.ctx.bus.focus_requested.emit(ids)
|
|
1354
|
+
except Exception:
|
|
1355
|
+
pass # a bad stage never blocks the mission from starting
|
|
1356
|
+
return True
|
|
1357
|
+
|
|
1358
|
+
def _confirm_clear_board(self, lesson) -> bool:
|
|
1359
|
+
"""Ask before wiping the student's canvas. Never silently destroy their work."""
|
|
1360
|
+
from PySide6.QtWidgets import QMessageBox
|
|
1361
|
+
box = QMessageBox(self)
|
|
1362
|
+
box.setIcon(QMessageBox.Icon.Question)
|
|
1363
|
+
box.setWindowTitle("Start the mission?")
|
|
1364
|
+
box.setText(f"“{lesson.title}” sets up its own board.")
|
|
1365
|
+
box.setInformativeText(
|
|
1366
|
+
"Your current canvas will be cleared so the mission starts from the exact "
|
|
1367
|
+
"setup it was designed around.\n\nSave your work first if you need it.")
|
|
1368
|
+
clear = box.addButton("Clear && Start", QMessageBox.ButtonRole.DestructiveRole)
|
|
1369
|
+
box.addButton("Cancel", QMessageBox.ButtonRole.RejectRole)
|
|
1370
|
+
box.exec()
|
|
1371
|
+
return box.clickedButton() is clear
|
|
1372
|
+
|
|
1373
|
+
def end_mission(self) -> None:
|
|
1374
|
+
self._mission_ctrl = None
|
|
1375
|
+
self._last_mission_say = None # reset the anti-spam tracker between missions
|
|
1376
|
+
self._mission_busy = self._mission_dirty = False
|
|
1377
|
+
self._mission_panel.setVisible(False)
|
|
1378
|
+
self._hide_mission_picker()
|
|
1379
|
+
self._clear_mission_flags()
|
|
1380
|
+
self._restore_chat() # bring back the pre-mission conversation
|
|
1381
|
+
self._rebuild_convo_ribbon() # the conversation ribbon comes back after a mission
|
|
1382
|
+
|
|
1383
|
+
# -- focused-chat archive (a mission is a bounded episode) --------------- #
|
|
1384
|
+
def _archive_chat(self) -> None:
|
|
1385
|
+
"""Stash the current transcript and clear the panel for a clean game-master session."""
|
|
1386
|
+
if self._chat_archive is None: # don't clobber an existing archive
|
|
1387
|
+
self._chat_archive = list(self._messages)
|
|
1388
|
+
self._messages = []
|
|
1389
|
+
self.log.clear()
|
|
1390
|
+
if hasattr(self, "_stack"):
|
|
1391
|
+
self._stack.setCurrentWidget(self.log)
|
|
1392
|
+
|
|
1393
|
+
def _restore_chat(self) -> None:
|
|
1394
|
+
"""Put the pre-mission conversation back exactly as it was."""
|
|
1395
|
+
if self._chat_archive is None:
|
|
1396
|
+
return
|
|
1397
|
+
self._messages = list(self._chat_archive)
|
|
1398
|
+
self._chat_archive = None
|
|
1399
|
+
self._rerender()
|
|
1400
|
+
if not self._messages:
|
|
1401
|
+
self._show_cloud() # empty history → back to the topic cloud
|
|
1402
|
+
|
|
1403
|
+
# -- async game-master plumbing (keeps model calls off the UI thread) ----- #
|
|
1404
|
+
def _snapshot_world(self):
|
|
1405
|
+
"""A frozen copy of the canvas as an objectives World — safe to read from a worker."""
|
|
1406
|
+
from ..domain import grader as _grader
|
|
1407
|
+
return _grader.world_from_snapshot(self.ctx.topology.to_dict())
|
|
1408
|
+
|
|
1409
|
+
def _apply_mission_ui(self, op) -> None:
|
|
1410
|
+
"""Deliver a worker's chat/panel update on the UI thread."""
|
|
1411
|
+
kind = op[0]
|
|
1412
|
+
if kind == "say":
|
|
1413
|
+
text = (op[1] or "").strip()
|
|
1414
|
+
if not text or text == getattr(self, "_last_mission_say", None):
|
|
1415
|
+
return # M7: drop empty + consecutive-duplicate game-master lines
|
|
1416
|
+
self._last_mission_say = text
|
|
1417
|
+
self._post("GINI", text, markdown=True)
|
|
1418
|
+
elif kind == "tracker":
|
|
1419
|
+
self._mission_panel.render_current()
|
|
1420
|
+
elif kind == "step":
|
|
1421
|
+
self._mission_panel.set_step(op[1], op[2], op[3])
|
|
1422
|
+
elif kind == "step_clear":
|
|
1423
|
+
self._mission_panel.clear_step()
|
|
1424
|
+
elif kind == "compose_start":
|
|
1425
|
+
les = op[1]
|
|
1426
|
+
note = op[2] if len(op) > 2 else ""
|
|
1427
|
+
if les is None:
|
|
1428
|
+
self._post("GINI", note or "I couldn't shape that into a mission — try describing a "
|
|
1429
|
+
"networking or cloud goal, e.g. 'a firewall protecting a server'.")
|
|
1430
|
+
else:
|
|
1431
|
+
self.start_mission(les)
|
|
1432
|
+
if note: # e.g. "Built it without metrics, dashboard…"
|
|
1433
|
+
self._post("GINI", note)
|
|
1434
|
+
elif kind == "busy":
|
|
1435
|
+
self._set_llm_busy(bool(op[1]))
|
|
1436
|
+
elif kind == "done":
|
|
1437
|
+
self._mission_busy = False
|
|
1438
|
+
if self._mission_dirty: # a change arrived mid-reaction → run once more
|
|
1439
|
+
self._mission_dirty = False
|
|
1440
|
+
self._dispatch_mission("on_canvas_changed")
|
|
1441
|
+
|
|
1442
|
+
def _set_llm_busy(self, on: bool) -> None:
|
|
1443
|
+
"""Reflect a worker-thread LLM call in the shared 'thinking' indicator. Counted, so several
|
|
1444
|
+
overlapping engagements (a game-master reaction + a flag note) show one spinner and clear it
|
|
1445
|
+
only when the last finishes. This is what makes EVERY LLM pathway visible, not just chat."""
|
|
1446
|
+
n = max(0, getattr(self, "_llm_active", 0) + (1 if on else -1))
|
|
1447
|
+
self._llm_active = n
|
|
1448
|
+
if n > 0 and not self._busy:
|
|
1449
|
+
self._start_spinner("GINI is thinking")
|
|
1450
|
+
elif n == 0 and self._busy:
|
|
1451
|
+
self._stop_spinner()
|
|
1452
|
+
|
|
1453
|
+
def _dispatch_mission(self, method: str, *args) -> None:
|
|
1454
|
+
"""Run a controller reaction on a worker thread (one at a time; coalesce extra changes)."""
|
|
1455
|
+
ctrl = self._mission_ctrl
|
|
1456
|
+
if ctrl is None or ctrl.mission is None:
|
|
1457
|
+
return
|
|
1458
|
+
# Run/Check and questions still work AFTER a mission completes (run the live checks, ask a
|
|
1459
|
+
# follow-up) — a mission can go GOLD on its structural objectives while the behavioral 'Live'
|
|
1460
|
+
# checks are still un-run. Only the automatic canvas reaction is gated on 'active'.
|
|
1461
|
+
if not ctrl.active and method not in ("run_check", "ask"):
|
|
1462
|
+
return
|
|
1463
|
+
if self._mission_busy:
|
|
1464
|
+
self._mission_dirty = True
|
|
1465
|
+
return
|
|
1466
|
+
self._mission_busy = True
|
|
1467
|
+
self._mission_world = self._snapshot_world() # snapshot on the UI thread
|
|
1468
|
+
import threading
|
|
1469
|
+
|
|
1470
|
+
def work():
|
|
1471
|
+
self.mission_ui_op.emit(("busy", True)) # the game master is reasoning (LLM)
|
|
1472
|
+
try:
|
|
1473
|
+
getattr(self._mission_ctrl, method)(*args)
|
|
1474
|
+
except Exception:
|
|
1475
|
+
pass
|
|
1476
|
+
finally:
|
|
1477
|
+
self.mission_ui_op.emit(("busy", False))
|
|
1478
|
+
self.mission_ui_op.emit(("done",))
|
|
1479
|
+
threading.Thread(target=work, daemon=True).start()
|
|
1480
|
+
|
|
1481
|
+
def _start_preview_mission(self, archetype_id: str) -> str | None:
|
|
1482
|
+
"""Launch a seed Game-Catalog archetype for a quick preview (temporary command)."""
|
|
1483
|
+
from ..domain import catalog, lesson as _lesson
|
|
1484
|
+
arch = catalog.get(archetype_id)
|
|
1485
|
+
if arch is None:
|
|
1486
|
+
ids = ", ".join(a.id for a in catalog.all_archetypes())
|
|
1487
|
+
return f"No such mission. Try one of: {ids}."
|
|
1488
|
+
les = _lesson.from_archetype(archetype_id, catalog.demo_params(archetype_id),
|
|
1489
|
+
id=f"preview-{archetype_id}", title=arch.summary,
|
|
1490
|
+
brief=arch.summary, time_limit="20m")
|
|
1491
|
+
if not self.start_mission(les):
|
|
1492
|
+
# the only non-error way this happens: a staged mission needs the canvas and the
|
|
1493
|
+
# student chose to keep their work. Say so, rather than failing silently.
|
|
1494
|
+
return "Kept your board — the mission wasn't started. Save or clear the canvas, then pick it again."
|
|
1495
|
+
return None
|
|
1496
|
+
|
|
1497
|
+
def _describe_mission(self, text: str) -> str | None:
|
|
1498
|
+
"""The student typed what they'd like to build — compose a playable mission from it (on a
|
|
1499
|
+
worker thread so the UI never freezes) and launch it. The compose SELECTS/COMBINES verified
|
|
1500
|
+
catalog archetypes; it never fabricates objectives, so the result is always gradable."""
|
|
1501
|
+
self._post("GINI", "Shaping a mission from that…")
|
|
1502
|
+
import threading
|
|
1503
|
+
|
|
1504
|
+
def work():
|
|
1505
|
+
self.mission_ui_op.emit(("busy", True)) # composing a mission engages the LLM
|
|
1506
|
+
les, note = None, ""
|
|
1507
|
+
try:
|
|
1508
|
+
from ..agent import lesson_resolver
|
|
1509
|
+
prop = lesson_resolver.compose(text, self._quick_llm, lesson_id="described")
|
|
1510
|
+
if prop is not None:
|
|
1511
|
+
les = prop.lesson
|
|
1512
|
+
if prop.infeasible: # DOs and DON'Ts conflict → explain, don't build
|
|
1513
|
+
note = prop.infeasible
|
|
1514
|
+
elif prop.suppressed:
|
|
1515
|
+
note = f"(Built it without {prop.suppressed}, as you asked.)"
|
|
1516
|
+
except Exception:
|
|
1517
|
+
les = None
|
|
1518
|
+
self.mission_ui_op.emit(("busy", False))
|
|
1519
|
+
self.mission_ui_op.emit(("compose_start", les, note))
|
|
1520
|
+
threading.Thread(target=work, daemon=True).start()
|
|
1521
|
+
return None
|
|
1522
|
+
|
|
1523
|
+
def _submit_to_center(self, lesson_id: str, mission) -> None:
|
|
1524
|
+
"""A mission finished — report it to the Teaching Center and sync the profile. Runs on the
|
|
1525
|
+
mission worker thread (never the UI thread). Offline is fine: the client queues the
|
|
1526
|
+
submission and flushes it on the next successful connect."""
|
|
1527
|
+
tc = getattr(self.ctx, "teaching_center", None)
|
|
1528
|
+
if tc is None:
|
|
1529
|
+
return # not enrolled — local practice only
|
|
1530
|
+
from ..domain import grader as _grader
|
|
1531
|
+
sent = tc.submit(lesson_id, mission, snapshot=_grader.snapshot_of(self.ctx.topology))
|
|
1532
|
+
if self._mission_profile is not None:
|
|
1533
|
+
tc.checkin_profile(self._mission_profile)
|
|
1534
|
+
self.mission_ui_op.emit(("say", (
|
|
1535
|
+
f"Result sent to your instructor — {mission.score().band.upper()}." if sent else
|
|
1536
|
+
"You're offline; your result is queued and will sync when the course server is reachable.")))
|
|
1537
|
+
# finishing a mission changes what's DUE — tell the toolbar's User pill (we're already on a
|
|
1538
|
+
# worker thread, and the pill listens on a queued signal, so this is safe from here)
|
|
1539
|
+
try:
|
|
1540
|
+
done = {lid for lid, rec in (self._mission_profile.lessons or {}).items()
|
|
1541
|
+
if rec.completed} if self._mission_profile is not None else set()
|
|
1542
|
+
due = sum(1 for m in tc.available_lessons() if m.get("id") not in done)
|
|
1543
|
+
self.ctx.bus.enrolment_changed.emit(
|
|
1544
|
+
getattr(self.ctx.settings, "tc_student", ""), sent, due)
|
|
1545
|
+
except Exception: # noqa: BLE001 — a stale badge is not fatal
|
|
1546
|
+
pass
|
|
1547
|
+
|
|
1548
|
+
def _mission_runner(self):
|
|
1549
|
+
"""A behavioral probe runner from the live runtime, if one is reachable — else None so
|
|
1550
|
+
behavioral objectives stay pending (structural still evaluates live). Wired defensively;
|
|
1551
|
+
real probing needs the orchestrator + a running stack (Mac-side)."""
|
|
1552
|
+
orch = getattr(self.ctx, "orchestrator", None)
|
|
1553
|
+
if orch is None:
|
|
1554
|
+
return None
|
|
1555
|
+
try:
|
|
1556
|
+
from ..domain.probes import TypeRunner
|
|
1557
|
+
from ..services.probe_runner import DockerProbeRunner
|
|
1558
|
+
# TypeRunner resolves type-based probe tokens (web_app, database…) to the student's
|
|
1559
|
+
# actual device names against the live topology — name-agnostic behavioral objectives.
|
|
1560
|
+
return TypeRunner(DockerProbeRunner(orch), lambda: self.ctx.topology)
|
|
1561
|
+
except Exception:
|
|
1562
|
+
return None
|
|
1563
|
+
|
|
1564
|
+
def _on_topology_for_mission(self) -> None:
|
|
1565
|
+
if self._mission_ctrl is None or not self._mission_ctrl.active:
|
|
1566
|
+
return
|
|
1567
|
+
self._update_mission_flags() # SYNCHRONOUS: instant red badges (no model)
|
|
1568
|
+
# coalesce rapid drops/links, then run the game master's reaction on a worker thread so the
|
|
1569
|
+
# canvas repaints immediately instead of blocking on a model call
|
|
1570
|
+
self._mission_debounce.start()
|
|
1571
|
+
|
|
1572
|
+
def _update_mission_flags(self) -> None:
|
|
1573
|
+
"""Recompute move-legality flags (off-task elements, illegal links) and paint the red
|
|
1574
|
+
badges instantly. Deterministic + fast — the game master's spoken reasoning is separate."""
|
|
1575
|
+
if self._mission_ctrl is None or self._mission_ctrl.mission is None:
|
|
1576
|
+
return
|
|
1577
|
+
from ..domain import legality
|
|
1578
|
+
try:
|
|
1579
|
+
f = legality.flags(self._mission_ctrl.mission.lesson, self.ctx.topology)
|
|
1580
|
+
flags = dict(f.get("devices", {}))
|
|
1581
|
+
except Exception:
|
|
1582
|
+
flags = {}
|
|
1583
|
+
prev = getattr(self, "_mission_flag_ids", set())
|
|
1584
|
+
self.ctx.mission_flags = flags
|
|
1585
|
+
self.ctx.bus.mission_flags_changed.emit()
|
|
1586
|
+
# if something NEW just got flagged, have the game master call it out (async, once)
|
|
1587
|
+
new_ids = set(flags) - prev
|
|
1588
|
+
self._mission_flag_ids = set(flags)
|
|
1589
|
+
if new_ids:
|
|
1590
|
+
self._speak_flag_note([flags[i] for i in new_ids])
|
|
1591
|
+
|
|
1592
|
+
def _speak_flag_note(self, reasons) -> None:
|
|
1593
|
+
"""The game master's spoken reasoning for a fresh flag — on a worker thread so it never
|
|
1594
|
+
blocks the canvas; deduped so it doesn't nag."""
|
|
1595
|
+
if self._loop is None or self._mission_ctrl is None or self._mission_ctrl.gm is None:
|
|
1596
|
+
return
|
|
1597
|
+
import threading
|
|
1598
|
+
gm = self._mission_ctrl.gm
|
|
1599
|
+
|
|
1600
|
+
def work():
|
|
1601
|
+
self.mission_ui_op.emit(("busy", True)) # the game master is reasoning (LLM)
|
|
1602
|
+
try:
|
|
1603
|
+
line = gm.flag_note(reasons)
|
|
1604
|
+
except Exception:
|
|
1605
|
+
line = "; ".join(reasons)
|
|
1606
|
+
self.mission_ui_op.emit(("busy", False))
|
|
1607
|
+
self.mission_ui_op.emit(("say", line))
|
|
1608
|
+
threading.Thread(target=work, daemon=True).start()
|
|
1609
|
+
|
|
1610
|
+
def _clear_mission_flags(self) -> None:
|
|
1611
|
+
self._mission_flag_ids = set()
|
|
1612
|
+
if getattr(self.ctx, "mission_flags", None):
|
|
1613
|
+
self.ctx.mission_flags = {}
|
|
1614
|
+
self.ctx.bus.mission_flags_changed.emit()
|
|
1615
|
+
|
|
1616
|
+
def _offline_concept(self, target: str) -> str | None:
|
|
1617
|
+
"""With no model attached, answer 'explain <topic>' from the matching concept note
|
|
1618
|
+
(deterministic). Returns the note text, or None if nothing matches (caller falls
|
|
1619
|
+
through to the generic capability hint)."""
|
|
1620
|
+
try:
|
|
1621
|
+
from ..agent import recall
|
|
1622
|
+
except Exception:
|
|
1623
|
+
return None
|
|
1624
|
+
c, strength = recall.best_concept("explain " + target)
|
|
1625
|
+
if c is None or strength == "empty":
|
|
1626
|
+
return None
|
|
1627
|
+
self._last_ref = ("concept", c.key)
|
|
1628
|
+
return f"**{c.title}**\n\n{c.body}"
|
|
1629
|
+
|
|
1630
|
+
@staticmethod
|
|
1631
|
+
def _callout_line(text: str) -> str:
|
|
1632
|
+
"""One glanceable line for an on-canvas callout (first sentence). The full
|
|
1633
|
+
explanation lives in the right pane — the callout just labels the element."""
|
|
1634
|
+
import re
|
|
1635
|
+
first = re.split(r"(?<=[.!?])\s", text.strip(), maxsplit=1)[0]
|
|
1636
|
+
return first if len(first) <= 130 else first[:127] + "…"
|
|
1637
|
+
|
|
1638
|
+
def explain_selected(self, device_name: str) -> None:
|
|
1639
|
+
"""Called when the user selects a device while in explain mode."""
|
|
1640
|
+
if self.explain_mode:
|
|
1641
|
+
reply = self._show_device(device_name)
|
|
1642
|
+
if reply is not None: # deterministic; async posts itself
|
|
1643
|
+
self._post("GINI", reply)
|
|
1644
|
+
self._refresh_followups()
|
|
1645
|
+
|
|
1646
|
+
def explain_element_type(self, type_key: str) -> None:
|
|
1647
|
+
"""Explain a palette element TYPE (Router/Switch/Hub/…): what it is + when to use
|
|
1648
|
+
it, grounded in the element guide and authored by the LLM when connected."""
|
|
1649
|
+
from ..domain.devices import REGISTRY
|
|
1650
|
+
dt = REGISTRY.get(type_key)
|
|
1651
|
+
label = dt.label if dt else type_key
|
|
1652
|
+
self._post("You", f"What is a {label}, and when do I use it?")
|
|
1653
|
+
self._last_ref = ("type", type_key)
|
|
1654
|
+
facts = self.api.explain_element_type(type_key)
|
|
1655
|
+
if self._loop is not None:
|
|
1656
|
+
self._ask_async(
|
|
1657
|
+
f"The student is asking about the '{label}' element from the palette. "
|
|
1658
|
+
f"Explain what it is and WHEN to use it (vs. similar elements), for a "
|
|
1659
|
+
f"student, in 2-4 sentences. Reference: {facts}", "")
|
|
1660
|
+
else:
|
|
1661
|
+
self._post("GINI", facts) # palette element — answer in the right pane
|
|
1662
|
+
self._refresh_followups()
|
|
1663
|
+
|
|
1664
|
+
# --- spinner + status -------------------------------------------------- #
|
|
1665
|
+
def _emit_status(self) -> None:
|
|
1666
|
+
mode = ("Missions mode" if self.missions_mode
|
|
1667
|
+
else "Wizard mode" if self.wizard_mode
|
|
1668
|
+
else "Coach mode" if self.coach_mode
|
|
1669
|
+
else "Explain mode" if self.explain_mode else "Chat mode")
|
|
1670
|
+
self._refresh_stack() # keep the topic-cloud empty-state to Chat mode only
|
|
1671
|
+
self.status_changed.emit(mode, self._busy)
|
|
1672
|
+
|
|
1673
|
+
# --- Wizard mode: an objective that guides X-ray (no auto-build) ---------- #
|
|
1674
|
+
def _make_wizard_panel(self) -> QWidget:
|
|
1675
|
+
panel = QWidget()
|
|
1676
|
+
pl = QVBoxLayout(panel); pl.setContentsMargins(0, 0, 0, 0); pl.setSpacing(6)
|
|
1677
|
+
cap = QLabel("Describe what you want to build"); cap.setObjectName("Muted")
|
|
1678
|
+
pl.addWidget(cap)
|
|
1679
|
+
inrow = QHBoxLayout(); inrow.setSpacing(6)
|
|
1680
|
+
self._wz_goal = QLineEdit()
|
|
1681
|
+
self._wz_goal.setPlaceholderText("e.g. a multi-LAN IP network")
|
|
1682
|
+
self._wz_goal.returnPressed.connect(self._set_goal_from_input)
|
|
1683
|
+
setbtn = QPushButton("Set"); setbtn.setObjectName("Accent")
|
|
1684
|
+
setbtn.setCursor(Qt.PointingHandCursor)
|
|
1685
|
+
setbtn.clicked.connect(self._set_goal_from_input)
|
|
1686
|
+
inrow.addWidget(self._wz_goal, 1); inrow.addWidget(setbtn)
|
|
1687
|
+
pl.addLayout(inrow)
|
|
1688
|
+
# the "🎯 Building: …" banner appears once a goal is set
|
|
1689
|
+
self._wz_banner_box = QWidget(); self._wz_banner_box.setObjectName("GoalBanner")
|
|
1690
|
+
brow = QHBoxLayout(self._wz_banner_box); brow.setContentsMargins(12, 8, 12, 8)
|
|
1691
|
+
self._wz_banner = QLabel(""); self._wz_banner.setWordWrap(True)
|
|
1692
|
+
brow.addWidget(self._wz_banner, 1)
|
|
1693
|
+
self._wz_clear = QPushButton("Clear"); self._wz_clear.setObjectName("Chip")
|
|
1694
|
+
self._wz_clear.setCursor(Qt.PointingHandCursor)
|
|
1695
|
+
self._wz_clear.clicked.connect(self._clear_mission)
|
|
1696
|
+
brow.addWidget(self._wz_clear)
|
|
1697
|
+
self._wz_banner_box.setVisible(False)
|
|
1698
|
+
pl.addWidget(self._wz_banner_box)
|
|
1699
|
+
panel.setVisible(False)
|
|
1700
|
+
return panel
|
|
1701
|
+
|
|
1702
|
+
def _set_goal_from_input(self) -> None:
|
|
1703
|
+
goal = self._wz_goal.text().strip()
|
|
1704
|
+
if not goal:
|
|
1705
|
+
return
|
|
1706
|
+
self._wz_goal.clear()
|
|
1707
|
+
self._post("You", goal)
|
|
1708
|
+
self._set_mission(goal)
|
|
1709
|
+
|
|
1710
|
+
def _set_mission(self, goal: str) -> None:
|
|
1711
|
+
"""Set the objective and let the model drive: it picks a starter element (placed
|
|
1712
|
+
for the student), and from then on it filters each element's neighbours to the
|
|
1713
|
+
goal. Requires a connected model (the Wizard button is disabled otherwise)."""
|
|
1714
|
+
from ..domain import missions
|
|
1715
|
+
goal = (goal or "").strip()
|
|
1716
|
+
if not goal:
|
|
1717
|
+
return
|
|
1718
|
+
if self._loop is None:
|
|
1719
|
+
self._post("GINI", "The Wizard needs a local model. Enable one in Settings → LLM.")
|
|
1720
|
+
return
|
|
1721
|
+
self._ghost_cache = {}
|
|
1722
|
+
# One source of truth: the on-goal set is what the MODEL endorses (the starter +
|
|
1723
|
+
# each approved neighbour), grown as we build — never a separate keyword guess.
|
|
1724
|
+
mission = missions.Mission(goal, frozenset(), None)
|
|
1725
|
+
self.ctx.set_mission(mission)
|
|
1726
|
+
self._show_mission(mission)
|
|
1727
|
+
self._post("GINI", f"Thinking about how to start “{goal}”…")
|
|
1728
|
+
self._pick_starter_async(goal)
|
|
1729
|
+
|
|
1730
|
+
def _add_on_goal(self, types) -> None:
|
|
1731
|
+
"""Grow the mission's on-goal set with element types the model has endorsed, so the
|
|
1732
|
+
off-goal flag never contradicts what the Wizard itself placed/suggested."""
|
|
1733
|
+
from ..domain.missions import Mission
|
|
1734
|
+
m = self.ctx.mission
|
|
1735
|
+
if m is None:
|
|
1736
|
+
return
|
|
1737
|
+
new = frozenset(set(m.types) | set(types))
|
|
1738
|
+
if new != m.types:
|
|
1739
|
+
self.ctx.set_mission(Mission(m.goal, new, m.first))
|
|
1740
|
+
|
|
1741
|
+
def _learn_on_goal(self, _device_id: str, items) -> None:
|
|
1742
|
+
self._add_on_goal({t for t, _r in items})
|
|
1743
|
+
|
|
1744
|
+
# -- LLM helpers (quiet, stateless — don't pollute the chat history) ------- #
|
|
1745
|
+
def _llm_complete(self, prompt: str,
|
|
1746
|
+
system: str = "You are GINI, a precise gBuilder assistant. Be brief.") -> str:
|
|
1747
|
+
from ..agent.llm.backend import Message
|
|
1748
|
+
out = []
|
|
1749
|
+
for c in self._loop.backend.chat([Message("system", system), Message("user", prompt)]):
|
|
1750
|
+
if c.text:
|
|
1751
|
+
out.append(c.text)
|
|
1752
|
+
return "".join(out)
|
|
1753
|
+
|
|
1754
|
+
def _canvas_summary(self) -> str:
|
|
1755
|
+
devs = list(self.ctx.topology.devices.values())
|
|
1756
|
+
if not devs:
|
|
1757
|
+
return "nothing yet"
|
|
1758
|
+
return ", ".join(f"{d.name} ({d.type.label})" for d in devs[:12])
|
|
1759
|
+
|
|
1760
|
+
def _pick_starter_async(self, goal: str) -> None:
|
|
1761
|
+
import threading
|
|
1762
|
+
from ..agent import wizard as wz
|
|
1763
|
+
catalog, names = wz.element_catalog(), wz.element_names()
|
|
1764
|
+
|
|
1765
|
+
def work():
|
|
1766
|
+
# Ask, validate, and RE-ASK (up to 3) — never guess. The retry prompt is terse and
|
|
1767
|
+
# demands one exact element name. If still no valid pick, we ask the user.
|
|
1768
|
+
prompts = [wz.starter_prompt(goal, catalog),
|
|
1769
|
+
wz.starter_retry_prompt(goal, names),
|
|
1770
|
+
wz.starter_retry_prompt(goal, names)]
|
|
1771
|
+
key, reason, last = "", "", ""
|
|
1772
|
+
for p in prompts:
|
|
1773
|
+
try:
|
|
1774
|
+
text = self._llm_complete(p)
|
|
1775
|
+
except Exception: # noqa: BLE001
|
|
1776
|
+
text = ""
|
|
1777
|
+
last = text
|
|
1778
|
+
k, r = wz.parse_starter(text)
|
|
1779
|
+
if k:
|
|
1780
|
+
key, reason = k, r
|
|
1781
|
+
break
|
|
1782
|
+
snippet = " ".join((last or "(empty)").split())[:300]
|
|
1783
|
+
self.ctx.log(f"Wizard starter — model said: “{snippet}” → parsed: {key or '(none)'}",
|
|
1784
|
+
"info")
|
|
1785
|
+
self.starter_ready.emit(key, reason)
|
|
1786
|
+
threading.Thread(target=work, daemon=True).start()
|
|
1787
|
+
|
|
1788
|
+
def _place_starter(self, type_key: str, reason: str) -> None:
|
|
1789
|
+
from ..domain.devices import REGISTRY
|
|
1790
|
+
if self.ctx.mission is None:
|
|
1791
|
+
return
|
|
1792
|
+
if not type_key or type_key not in REGISTRY: # no valid pick after retries — ask, don't guess
|
|
1793
|
+
self._post("GINI", f"I couldn't settle on a clear first element for "
|
|
1794
|
+
f"“{self.ctx.mission.goal}”. Could you make the goal more specific "
|
|
1795
|
+
"(e.g. name the kind of network or service), or tell me which "
|
|
1796
|
+
"element to start with? I'd rather ask than guess.")
|
|
1797
|
+
return
|
|
1798
|
+
self._add_on_goal({type_key}) # the starter is on-goal by definition
|
|
1799
|
+
devs = list(self.ctx.topology.devices.values())
|
|
1800
|
+
x = (max(d.x for d in devs) + 320.0) if devs else 220.0
|
|
1801
|
+
y = (sum(d.y for d in devs) / len(devs)) if devs else 200.0
|
|
1802
|
+
d = self.api.add_device(type_key, x=x, y=y)
|
|
1803
|
+
self.ctx.select(d["id"])
|
|
1804
|
+
label = REGISTRY[type_key].label
|
|
1805
|
+
self._post("GINI", f"Start with a <b>{label}</b> — {reason or 'the foundation for this goal'}. "
|
|
1806
|
+
"Tap a glowing suggestion to add the next piece.")
|
|
1807
|
+
self.ctx.bus.wizard_ghosts_requested.emit(d["id"]) # auto-show its goal ghosts
|
|
1808
|
+
|
|
1809
|
+
def _resolve_ghosts_async(self, device_id: str) -> None:
|
|
1810
|
+
"""Filter an element's grammar-valid neighbours to the goal (one batched LLM call,
|
|
1811
|
+
cached per goal+type). Emits wizard_ghosts_ready for the canvas to draw."""
|
|
1812
|
+
from ..domain import connection_rules as cr
|
|
1813
|
+
from ..domain.devices import REGISTRY
|
|
1814
|
+
m = self.ctx.mission
|
|
1815
|
+
d = self.ctx.topology.devices.get(device_id)
|
|
1816
|
+
if m is None or d is None:
|
|
1817
|
+
return
|
|
1818
|
+
partners = cr.partners_for(d.type_key)
|
|
1819
|
+
candidates = [(p.type_key, REGISTRY[p.type_key].label) for p in partners]
|
|
1820
|
+
if not candidates:
|
|
1821
|
+
self.ctx.bus.wizard_ghosts_ready.emit(device_id, [])
|
|
1822
|
+
return
|
|
1823
|
+
grammar_items = [(p.type_key, p.why) for p in partners]
|
|
1824
|
+
key = (m.goal, d.type_key)
|
|
1825
|
+
if key in self._ghost_cache:
|
|
1826
|
+
self.ctx.bus.wizard_ghosts_ready.emit(device_id, self._ghost_cache[key])
|
|
1827
|
+
return
|
|
1828
|
+
if self._loop is None: # safety: no model -> grammar ring
|
|
1829
|
+
self.ctx.bus.wizard_ghosts_ready.emit(device_id, grammar_items)
|
|
1830
|
+
return
|
|
1831
|
+
import threading
|
|
1832
|
+
from ..agent import wizard as wz
|
|
1833
|
+
goal, cur = m.goal, REGISTRY[d.type_key].label
|
|
1834
|
+
summary = self._canvas_summary()
|
|
1835
|
+
|
|
1836
|
+
def work():
|
|
1837
|
+
try:
|
|
1838
|
+
text = self._llm_complete(wz.filter_prompt(goal, cur, candidates, summary))
|
|
1839
|
+
items = wz.parse_filter(text, candidates) or grammar_items
|
|
1840
|
+
except Exception: # noqa: BLE001
|
|
1841
|
+
items = grammar_items
|
|
1842
|
+
self._ghost_cache[key] = items
|
|
1843
|
+
self.ctx.bus.wizard_ghosts_ready.emit(device_id, items)
|
|
1844
|
+
threading.Thread(target=work, daemon=True).start()
|
|
1845
|
+
|
|
1846
|
+
def _show_mission(self, mission, refined: bool = False, speak: bool = True) -> None:
|
|
1847
|
+
t = self.theme.theme
|
|
1848
|
+
self._wz_banner.setText(
|
|
1849
|
+
f'<span style="color:{t.accent};font-weight:700">🎯 Building:</span> '
|
|
1850
|
+
f'<span style="color:{t.text}">{mission.goal}</span>')
|
|
1851
|
+
self._wz_banner_box.setVisible(True)
|
|
1852
|
+
self._wz_panel.setVisible(self.wizard_mode)
|
|
1853
|
+
|
|
1854
|
+
def _clear_mission(self) -> None:
|
|
1855
|
+
self.ctx.set_mission(None)
|
|
1856
|
+
self._ghost_cache = {}
|
|
1857
|
+
self._wz_banner_box.setVisible(False)
|
|
1858
|
+
self._post("GINI", "Goal cleared — X-ray is back to showing every valid connection.")
|
|
1859
|
+
|
|
1860
|
+
def _start_spinner(self, what: str = "GINI is thinking") -> None:
|
|
1861
|
+
self._busy = True
|
|
1862
|
+
self._spin_base = what
|
|
1863
|
+
self._spin_dots = 0
|
|
1864
|
+
self._spinner.setText(what + "…")
|
|
1865
|
+
self._spinner.setVisible(True)
|
|
1866
|
+
self._spin_timer.start()
|
|
1867
|
+
self._emit_status()
|
|
1868
|
+
|
|
1869
|
+
def _stop_spinner(self) -> None:
|
|
1870
|
+
self._busy = False
|
|
1871
|
+
self._spin_timer.stop()
|
|
1872
|
+
self._spinner.setVisible(False)
|
|
1873
|
+
self._spinner.setText("")
|
|
1874
|
+
self._emit_status()
|
|
1875
|
+
|
|
1876
|
+
def _spin_tick(self) -> None:
|
|
1877
|
+
self._spin_dots = (self._spin_dots + 1) % 4
|
|
1878
|
+
self._spinner.setText(self._spin_base + "." * self._spin_dots)
|
|
1879
|
+
|
|
1880
|
+
# --- Ask GINI pipeline: understand -> retrieve -> route -> reason ------- #
|
|
1881
|
+
def _mode_name(self) -> str:
|
|
1882
|
+
return ("coach" if self.coach_mode else "wizard" if self.wizard_mode
|
|
1883
|
+
else "explain" if self.explain_mode else "chat")
|
|
1884
|
+
|
|
1885
|
+
def _quick_llm(self, prompt: str, schema: dict | None = None) -> str:
|
|
1886
|
+
"""A one-shot completion on the same model — for understanding-refine, the session
|
|
1887
|
+
summariser, and the mission personas. Runs on the worker thread (never blocks the UI).
|
|
1888
|
+
`schema` (optional) requests decoder-constrained JSON via the backend's structured
|
|
1889
|
+
outputs; backends without support just ignore it (callers keep tolerant parsing)."""
|
|
1890
|
+
try:
|
|
1891
|
+
from ..agent.llm.backend import Message
|
|
1892
|
+
out = []
|
|
1893
|
+
try:
|
|
1894
|
+
chunks = self._loop.backend.chat([Message("user", prompt)], tools=None,
|
|
1895
|
+
schema=schema)
|
|
1896
|
+
except TypeError: # a backend without the schema kwarg
|
|
1897
|
+
chunks = self._loop.backend.chat([Message("user", prompt)], tools=None)
|
|
1898
|
+
for ch in chunks:
|
|
1899
|
+
if ch.text:
|
|
1900
|
+
out.append(ch.text)
|
|
1901
|
+
return "".join(out)
|
|
1902
|
+
except Exception:
|
|
1903
|
+
return ""
|
|
1904
|
+
|
|
1905
|
+
def _ask_gini(self, text: str) -> None:
|
|
1906
|
+
"""Front door for free-form questions: interpret, retrieve GINI knowledge, and
|
|
1907
|
+
route (build a recipe, reason with grounded context, or clarify)."""
|
|
1908
|
+
from ..agent import ask, kb
|
|
1909
|
+
from ..agent import understand as U
|
|
1910
|
+
names = [d.name for d in self.ctx.topology.devices.values()]
|
|
1911
|
+
# deterministic parse on the UI thread (fast, non-blocking); the model-refine and
|
|
1912
|
+
# summariser run later on the worker thread.
|
|
1913
|
+
intent = U.understand(text, canvas_names=names, mode=self._mode_name())
|
|
1914
|
+
retrieval = kb.retrieve(intent, topology=self.ctx.topology)
|
|
1915
|
+
plan = ask.plan(intent, retrieval)
|
|
1916
|
+
if plan.action == "clarify":
|
|
1917
|
+
self._post("GINI", plan.clarify)
|
|
1918
|
+
return
|
|
1919
|
+
if plan.action == "build_recipe":
|
|
1920
|
+
self._build_recipe(plan.recipe_id)
|
|
1921
|
+
return
|
|
1922
|
+
offer = plan.recipe_id if plan.offer_build else ""
|
|
1923
|
+
self._ask_async(text, "", grounded=(intent, retrieval, offer))
|
|
1924
|
+
|
|
1925
|
+
def _build_recipe(self, recipe_id: str) -> None:
|
|
1926
|
+
"""Auto-build a vetted example on the canvas and narrate it (deterministic — the
|
|
1927
|
+
elements are authored, so this can't produce a broken topology)."""
|
|
1928
|
+
from ..domain import recipes
|
|
1929
|
+
rec = recipes.get_recipe(recipe_id)
|
|
1930
|
+
if rec is None:
|
|
1931
|
+
self._ask_async(recipe_id, "")
|
|
1932
|
+
return
|
|
1933
|
+
try:
|
|
1934
|
+
res = self.api.apply_recipe(recipe_id)
|
|
1935
|
+
except Exception as e:
|
|
1936
|
+
self._post("GINI", f"I couldn't build that: {e}", error=True)
|
|
1937
|
+
return
|
|
1938
|
+
self.ctx.bus.topology_changed.emit()
|
|
1939
|
+
lines = [f"Built a **{rec.name}** on the canvas — {len(res['added'])} elements, "
|
|
1940
|
+
f"{res['links']} links. Press **Run** to start it.", "", rec.summary]
|
|
1941
|
+
whys = [f"- **{el.type_key}** — {el.why}" for el in rec.elements if el.why]
|
|
1942
|
+
if whys:
|
|
1943
|
+
lines += ["", "**What each piece does:**"] + whys
|
|
1944
|
+
self._post("GINI", "\n".join(lines), markdown=True)
|
|
1945
|
+
|
|
1946
|
+
def _active_xv6_state(self):
|
|
1947
|
+
"""The MachineState the student is focused on, if any: the selected xv6 Machine, else
|
|
1948
|
+
the sole xv6 Machine when there's exactly one. Returns None otherwise."""
|
|
1949
|
+
states = getattr(self.ctx, "machine_states", {}) or {}
|
|
1950
|
+
if not states:
|
|
1951
|
+
return None
|
|
1952
|
+
devs = self.ctx.topology.devices
|
|
1953
|
+
sel = self.ctx.selected_id
|
|
1954
|
+
if sel in states and getattr(devs.get(sel), "type_key", "") == "xv6":
|
|
1955
|
+
return states[sel]
|
|
1956
|
+
xv6 = [ms for did, ms in states.items()
|
|
1957
|
+
if getattr(devs.get(did), "type_key", "") == "xv6"]
|
|
1958
|
+
return xv6[0] if len(xv6) == 1 else None
|
|
1959
|
+
|
|
1960
|
+
def _active_machine_card(self, prompt: str) -> str:
|
|
1961
|
+
"""The live xv6 state card for the grounded context (empty when no xv6 focus). Depth
|
|
1962
|
+
scales with the question so the small-LLM budget stays lean."""
|
|
1963
|
+
try:
|
|
1964
|
+
from ..agent import ask
|
|
1965
|
+
ms = self._active_xv6_state()
|
|
1966
|
+
if ms is None:
|
|
1967
|
+
return ""
|
|
1968
|
+
return ms.card(level=ask.machine_card_level(prompt))
|
|
1969
|
+
except Exception:
|
|
1970
|
+
return ""
|
|
1971
|
+
|
|
1972
|
+
# --- async LLM plumbing: one shared conversation, off the UI thread ----- #
|
|
1973
|
+
def _ask_async(self, prompt: str, device: str, grounded=None) -> None:
|
|
1974
|
+
import threading
|
|
1975
|
+
# one place for "waiting" feedback: the spinner in the pane (no canvas popup).
|
|
1976
|
+
about = f" about {device}" if device and not device.startswith("__") else ""
|
|
1977
|
+
self._start_spinner("GINI is thinking" + about)
|
|
1978
|
+
self._streaming = False
|
|
1979
|
+
self._stream_buf = ""
|
|
1980
|
+
self._clear_followups() # hide stale suggestions while answering
|
|
1981
|
+
|
|
1982
|
+
def work():
|
|
1983
|
+
from ..agent import ask, kb
|
|
1984
|
+
from ..agent.loop import visible_text
|
|
1985
|
+
if grounded is not None:
|
|
1986
|
+
intent, retrieval, offer_rid = grounded
|
|
1987
|
+
# Re-run retrieval WITH the model + embedder now that we're on the worker
|
|
1988
|
+
# thread — this is where the L1 (LLM query-expansion) and L2 (semantic) fallbacks
|
|
1989
|
+
# fire, since they do I/O. The UI-thread pass (for routing) was lexical-only.
|
|
1990
|
+
retrieval = kb.retrieve(intent, topology=self.ctx.topology,
|
|
1991
|
+
llm=self._quick_llm, embedder=self._embedder())
|
|
1992
|
+
# accumulate this turn's knowledge (small-LLM summary if over budget), then
|
|
1993
|
+
# assemble the full grounded context the reasoning model sees, with a grounding
|
|
1994
|
+
# stance derived from how strongly the KB matched (closed vs. open-but-fenced).
|
|
1995
|
+
self._session.add(retrieval.cards, llm=self._quick_llm)
|
|
1996
|
+
mcard = self._active_machine_card(prompt)
|
|
1997
|
+
stance = ask.grounding_stance(retrieval, intent)
|
|
1998
|
+
ctx = ask.grounded_context(kb.always_on_context(), self._session.as_context(),
|
|
1999
|
+
retrieval, self.api.context_digest(), intent,
|
|
2000
|
+
machine_card=mcard, stance=stance)
|
|
2001
|
+
if offer_rid:
|
|
2002
|
+
ctx += (f"\n\nIf it would help, end by offering to build the "
|
|
2003
|
+
f"'{offer_rid}' example (the student can say 'show me').")
|
|
2004
|
+
self._loop.extra_context = ctx
|
|
2005
|
+
raw_parts: list[str] = []
|
|
2006
|
+
try:
|
|
2007
|
+
text = self._loop.send(prompt, on_text=raw_parts.append)
|
|
2008
|
+
except TypeError:
|
|
2009
|
+
text = self._loop.send(prompt) # older loop signature (no streaming)
|
|
2010
|
+
except Exception as e:
|
|
2011
|
+
raw_parts, text = [], f"(LLM error: {e})"
|
|
2012
|
+
finally:
|
|
2013
|
+
self._loop.extra_context = "" # never leak grounding into the next turn
|
|
2014
|
+
raw = "".join(raw_parts) or text or ""
|
|
2015
|
+
self.answer_ready.emit(device or "", visible_text(raw) or "Done.")
|
|
2016
|
+
threading.Thread(target=work, daemon=True).start()
|
|
2017
|
+
|
|
2018
|
+
def _on_chunk(self, delta: str) -> None:
|
|
2019
|
+
"""A streamed token arrived — begin (or continue) typing it into the pane."""
|
|
2020
|
+
if not delta:
|
|
2021
|
+
return
|
|
2022
|
+
if not self._streaming:
|
|
2023
|
+
self._stop_spinner() # the answer is appearing; drop the spinner
|
|
2024
|
+
self._begin_stream()
|
|
2025
|
+
self._streaming = True
|
|
2026
|
+
self._stream_buf += delta
|
|
2027
|
+
self._stream_insert(delta)
|
|
2028
|
+
|
|
2029
|
+
def _begin_stream(self) -> None:
|
|
2030
|
+
t = self.theme.theme
|
|
2031
|
+
cur = self.log.textCursor()
|
|
2032
|
+
cur.movePosition(QTextCursor.End)
|
|
2033
|
+
if not self.log.document().isEmpty():
|
|
2034
|
+
cur.insertBlock()
|
|
2035
|
+
lbl = QTextCharFormat(); lbl.setForeground(QColor(t.accent)); lbl.setFontWeight(QFont.Bold)
|
|
2036
|
+
cur.insertText("GINI: ", lbl)
|
|
2037
|
+
self._body_fmt = QTextCharFormat(); self._body_fmt.setForeground(QColor(t.text))
|
|
2038
|
+
self.log.setTextCursor(cur)
|
|
2039
|
+
self.log.ensureCursorVisible()
|
|
2040
|
+
|
|
2041
|
+
def _stream_insert(self, delta: str) -> None:
|
|
2042
|
+
cur = self.log.textCursor()
|
|
2043
|
+
cur.movePosition(QTextCursor.End)
|
|
2044
|
+
cur.insertText(delta, self._body_fmt)
|
|
2045
|
+
self.log.setTextCursor(cur)
|
|
2046
|
+
self.log.ensureCursorVisible()
|
|
2047
|
+
|
|
2048
|
+
def _on_answer(self, device: str, text: str) -> None:
|
|
2049
|
+
self._stop_spinner()
|
|
2050
|
+
if self._streaming: # text was already typed live — just persist
|
|
2051
|
+
final = self._stream_buf or text
|
|
2052
|
+
self._messages.append(("GINI", final, False, False))
|
|
2053
|
+
self.ctx.bus.assistant_message.emit("GINI", final)
|
|
2054
|
+
self._streaming = False
|
|
2055
|
+
self._raise_self()
|
|
2056
|
+
text = final
|
|
2057
|
+
elif text and text.strip(): # buffered reply -> render as Markdown
|
|
2058
|
+
self._post("GINI", text, markdown=True)
|
|
2059
|
+
if self._tutor and device:
|
|
2060
|
+
did = self._device_id(device)
|
|
2061
|
+
if did: # element-specific: short anchored callout
|
|
2062
|
+
self.ctx.bus.present_callout.emit(did, self._callout_line(text))
|
|
2063
|
+
if not self.wizard_mode and not self.coach_mode: # Wizard/Coach manage their own chips
|
|
2064
|
+
self._refresh_followups() # offer context-aware next questions
|
|
2065
|
+
|
|
2066
|
+
def exit_explain_mode(self) -> None:
|
|
2067
|
+
if self.explain_mode:
|
|
2068
|
+
self.explain_mode = False
|
|
2069
|
+
self.ctx.bus.present_clear.emit()
|
|
2070
|
+
|
|
2071
|
+
def _trace_and_show(self, a: str, b: str) -> str:
|
|
2072
|
+
try:
|
|
2073
|
+
path = self.api.trace_path(a, b)
|
|
2074
|
+
except Exception:
|
|
2075
|
+
return f"I couldn't find {a} or {b} on the canvas."
|
|
2076
|
+
if not path:
|
|
2077
|
+
return f"There's no path between {a} and {b} on the canvas."
|
|
2078
|
+
by_name = {d.name: d.id for d in self.ctx.topology.devices.values()}
|
|
2079
|
+
ids = [by_name[n] for n in path if n in by_name]
|
|
2080
|
+
if self._tutor:
|
|
2081
|
+
bus = self.ctx.bus
|
|
2082
|
+
bus.present_clear.emit()
|
|
2083
|
+
bus.present_highlight.emit(ids)
|
|
2084
|
+
bus.present_packet.emit(ids) # animate a packet along the path (no text bubble)
|
|
2085
|
+
hops = len([n for n in path if n != a and n != b])
|
|
2086
|
+
return f"Path {a} → {b} ({hops} hop{'s' if hops != 1 else ''}): " + " → ".join(path)
|
|
2087
|
+
|
|
2088
|
+
def _explain_stage(self, narration: str) -> None:
|
|
2089
|
+
"""Spotlight the hub node, anchor a callout, and narrate — the AI on stage."""
|
|
2090
|
+
if not self._tutor:
|
|
2091
|
+
return
|
|
2092
|
+
t = self.ctx.topology
|
|
2093
|
+
if not t.devices:
|
|
2094
|
+
return
|
|
2095
|
+
hub = max(t.devices.values(), key=lambda d: t.degree(d.id))
|
|
2096
|
+
bus = self.ctx.bus
|
|
2097
|
+
bus.present_spotlight.emit([hub.id])
|
|
2098
|
+
bus.present_callout.emit(hub.id, f"{hub.name} — most connected node")
|
|
2099
|
+
# the full overview goes to the right pane (no big canvas bubble)
|
|
2100
|
+
|
|
2101
|
+
def _add(self, phrase: str) -> str:
|
|
2102
|
+
phrase = phrase.rstrip(".")
|
|
2103
|
+
match = None
|
|
2104
|
+
for d in all_devices():
|
|
2105
|
+
if d.label.lower() in phrase or d.key in phrase:
|
|
2106
|
+
match = d
|
|
2107
|
+
break
|
|
2108
|
+
if match is None:
|
|
2109
|
+
return f"I don't recognize a device called “{phrase}”. Try “list”."
|
|
2110
|
+
created = self.api.add_device(match.key)
|
|
2111
|
+
return f"Added {created['name']} ({match.label})."
|