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/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""GINI gBuilder — next-generation topology builder for networks + cloud."""
|
|
2
|
+
|
|
3
|
+
# Version is derived from git tags at build time by setuptools-scm, which writes _version.py into
|
|
4
|
+
# the package. Running from a source checkout without a build falls back to a dev marker.
|
|
5
|
+
try:
|
|
6
|
+
from ._version import version as __version__
|
|
7
|
+
except Exception: # noqa: BLE001 — no built _version.py (raw source tree)
|
|
8
|
+
try:
|
|
9
|
+
from importlib.metadata import version as _v
|
|
10
|
+
__version__ = _v("gini-toolkit")
|
|
11
|
+
except Exception: # noqa: BLE001
|
|
12
|
+
__version__ = "0.0.0+unknown"
|
gini/__main__.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Launch gBuilder.
|
|
2
|
+
|
|
3
|
+
python -m gini # run the app
|
|
4
|
+
python -m gini --demo # run with a sample hybrid topology
|
|
5
|
+
QT_QPA_PLATFORM=offscreen python -m gini --selftest # headless smoke test
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build_demo(api) -> None:
|
|
13
|
+
"""A small hybrid networks + cloud topology for first-run / selftest."""
|
|
14
|
+
r1 = api.add_device("router", x=-260, y=-160)
|
|
15
|
+
s1 = api.add_device("switch", x=-260, y=-20)
|
|
16
|
+
h1 = api.add_device("host", x=-380, y=120)
|
|
17
|
+
api.add_device("host", x=-160, y=120)
|
|
18
|
+
api.connect(r1["name"], s1["name"])
|
|
19
|
+
api.connect(s1["name"], h1["name"])
|
|
20
|
+
|
|
21
|
+
vpc = api.add_device("vpc", x=160, y=-180)
|
|
22
|
+
lb = api.add_device("load_balancer", x=160, y=-40)
|
|
23
|
+
inst = api.add_device("instance", x=40, y=110)
|
|
24
|
+
api.add_device("instance", x=280, y=110)
|
|
25
|
+
db = api.add_device("database", x=160, y=240)
|
|
26
|
+
api.connect(vpc["name"], lb["name"])
|
|
27
|
+
api.connect(lb["name"], inst["name"])
|
|
28
|
+
api.connect(inst["name"], db["name"])
|
|
29
|
+
api.connect(r1["name"], vpc["name"], "hybrid uplink")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _apply_branding(app) -> None:
|
|
33
|
+
"""Name + icon so the taskbar/dock shows the GINI mascot (not the Python launcher)."""
|
|
34
|
+
from .ui.branding import app_icon, icon_path
|
|
35
|
+
app.setApplicationName("gBuilder")
|
|
36
|
+
app.setApplicationDisplayName("gBuilder")
|
|
37
|
+
app.setOrganizationName("GINI")
|
|
38
|
+
app.setWindowIcon(app_icon())
|
|
39
|
+
if sys.platform == "win32": # make Windows group + show OUR icon, not python.exe's
|
|
40
|
+
try:
|
|
41
|
+
import ctypes
|
|
42
|
+
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("GINI.gBuilder")
|
|
43
|
+
except Exception:
|
|
44
|
+
pass
|
|
45
|
+
elif sys.platform == "darwin": # set the Dock icon when run from source (needs pyobjc)
|
|
46
|
+
try:
|
|
47
|
+
from AppKit import NSApplication, NSImage
|
|
48
|
+
img = NSImage.alloc().initByReferencingFile_(icon_path())
|
|
49
|
+
NSApplication.sharedApplication().setApplicationIconImage_(img)
|
|
50
|
+
except Exception:
|
|
51
|
+
pass # no pyobjc → bundle a .app for a permanent Dock icon
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _setup_preflight() -> None:
|
|
55
|
+
"""Soft check: Demo mode always works; live Run needs the container runtime + images from
|
|
56
|
+
`gini-setup`. We only nudge here — never block — so the app is explorable immediately."""
|
|
57
|
+
try:
|
|
58
|
+
from . import __version__
|
|
59
|
+
from .setup import marker
|
|
60
|
+
if not marker.is_setup_done():
|
|
61
|
+
print("[gini] Runtime not set up yet — Demo mode works now; run `gini-setup` to enable "
|
|
62
|
+
"live Run (installs Docker/Colima + pulls images).")
|
|
63
|
+
elif marker.needs_update(__version__):
|
|
64
|
+
print(f"[gini] App is {__version__} but images were set up for {marker.setup_version()} "
|
|
65
|
+
"— run `gini-setup --update` to refresh them.")
|
|
66
|
+
except Exception:
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def main() -> int:
|
|
71
|
+
args = set(sys.argv[1:])
|
|
72
|
+
from PySide6.QtWidgets import QApplication
|
|
73
|
+
|
|
74
|
+
app = QApplication.instance() or QApplication(sys.argv)
|
|
75
|
+
_apply_branding(app)
|
|
76
|
+
from .ui import MainWindow
|
|
77
|
+
|
|
78
|
+
win = MainWindow(app)
|
|
79
|
+
|
|
80
|
+
if "--demo" in args or "--selftest" in args:
|
|
81
|
+
build_demo(win.api)
|
|
82
|
+
|
|
83
|
+
if "--selftest" in args:
|
|
84
|
+
s = win.api.summary()
|
|
85
|
+
assert s["devices"] >= 9, s
|
|
86
|
+
assert s["links"] >= 6, s
|
|
87
|
+
explanation = win.api.explain_topology()
|
|
88
|
+
assert "elements" in explanation
|
|
89
|
+
# exercise theme swap + node creation paths
|
|
90
|
+
win.theme.set_theme("Light")
|
|
91
|
+
win.theme.set_theme("GINI Brand")
|
|
92
|
+
print("SELFTEST OK:", s)
|
|
93
|
+
print("EXPLAIN:", explanation)
|
|
94
|
+
return 0
|
|
95
|
+
|
|
96
|
+
if not ({"--demo", "--selftest"} & args):
|
|
97
|
+
win.restore_last_project() # reopen last session's project
|
|
98
|
+
|
|
99
|
+
_setup_preflight() # non-blocking: Demo always works
|
|
100
|
+
win.show()
|
|
101
|
+
from PySide6.QtCore import QTimer
|
|
102
|
+
QTimer.singleShot(450, win.maybe_start_tour) # feature tour, once the window is painted
|
|
103
|
+
return app.exec()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
if __name__ == "__main__":
|
|
107
|
+
raise SystemExit(main())
|
gini/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '6.0.1.dev0'
|
|
22
|
+
__version_tuple__ = version_tuple = (6, 0, 1, 'dev0')
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
gini/agent/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Agent package.
|
|
2
|
+
|
|
3
|
+
`GiniAPI` is exposed lazily: importing it pulls in `app.context` -> PySide6, and headless consumers
|
|
4
|
+
(the Teaching Center server, CI, scripts) need the pure pieces of this package — `authoring`,
|
|
5
|
+
`teaching_center`, `lesson_resolver` — WITHOUT a GUI dependency. `from gini.agent import GiniAPI`
|
|
6
|
+
still works exactly as before; it just doesn't happen at package-import time.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def __getattr__(name): # PEP 562 lazy attribute
|
|
11
|
+
if name == "GiniAPI":
|
|
12
|
+
from .api import GiniAPI
|
|
13
|
+
return GiniAPI
|
|
14
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
__all__ = ["GiniAPI"]
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""AgentGameMaster — a drop-in for `GameMaster` whose reasoning runs through the multi-agent stack
|
|
2
|
+
(blackboard + personas), so the live mission loop is *exercised* by the new architecture without
|
|
3
|
+
touching MissionController's proven mechanics (panel, guided steps, scoring, completion, flags).
|
|
4
|
+
|
|
5
|
+
It exposes the exact method surface the controller drives — `brief_line`, `present_step`,
|
|
6
|
+
`react_reply`, `decide`, `flag_note` — but:
|
|
7
|
+
• grounds every line in the blackboard's live objective verdicts (fed from the mission's own
|
|
8
|
+
results, so no double evaluation);
|
|
9
|
+
• routes a student question (decide with an utterance) through the full MissionAgent turn
|
|
10
|
+
(classify → understand → reason → critic), and a quiet canvas-reaction through the Reasoning
|
|
11
|
+
persona alone (fast, no extra model calls per drop);
|
|
12
|
+
• returns a `contracts.Move` whose `kind == "quiet"` is what the controller treats as silence.
|
|
13
|
+
|
|
14
|
+
Missions stay LLM-gated: with `llm=None` every method degrades to the authored text (inert), matching
|
|
15
|
+
`GameMaster`.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from .blackboard import Blackboard
|
|
20
|
+
from .contracts import Move, Notification
|
|
21
|
+
from .meaning import MissionAgent
|
|
22
|
+
from .personas import PersonaRunner
|
|
23
|
+
from .reasoning import REASONING
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AgentGameMaster:
|
|
27
|
+
def __init__(self, lesson, llm=None, *, persona: str | None = None,
|
|
28
|
+
twin_enabled: bool = False) -> None:
|
|
29
|
+
self.lesson = lesson
|
|
30
|
+
self.llm = llm
|
|
31
|
+
self.persona = persona or getattr(lesson, "persona", "coach")
|
|
32
|
+
self.runner = PersonaRunner(llm)
|
|
33
|
+
self.bb = Blackboard()
|
|
34
|
+
self.bb.load_lesson(lesson)
|
|
35
|
+
# Reasoning 2.0: the Twin audits covered turns. Model-gated — with no LLM the persona
|
|
36
|
+
# answers are authored text and a coverage dialogue would only add noise.
|
|
37
|
+
twin = None
|
|
38
|
+
if twin_enabled and llm is not None:
|
|
39
|
+
from .twin.dialectic import Twin
|
|
40
|
+
twin = Twin()
|
|
41
|
+
self.agent = MissionAgent(self.runner, self.bb, lesson, twin=twin)
|
|
42
|
+
self.reasoning = self.agent.reasoning
|
|
43
|
+
self._prev_met: int | None = None
|
|
44
|
+
self._get_world = None # bound by the controller (for the explainer)
|
|
45
|
+
|
|
46
|
+
def bind_world(self, getter) -> None:
|
|
47
|
+
"""The controller hands us a way to read the live world, so the Reasoning persona can ground
|
|
48
|
+
its 'why is this red' diagnosis in the actual board (the predicate explainer)."""
|
|
49
|
+
self._get_world = getter
|
|
50
|
+
|
|
51
|
+
def _sync_world(self) -> None:
|
|
52
|
+
if self._get_world is not None:
|
|
53
|
+
try:
|
|
54
|
+
self.bb._world = self._get_world()
|
|
55
|
+
except Exception:
|
|
56
|
+
self.bb._world = None
|
|
57
|
+
|
|
58
|
+
# -- narration (Reasoning persona, grounded in mission intent) ----------- #
|
|
59
|
+
def brief_line(self) -> str:
|
|
60
|
+
if self.llm is None:
|
|
61
|
+
return self.lesson.brief
|
|
62
|
+
return self.runner.call(
|
|
63
|
+
REASONING, context=self.reasoning._intent(),
|
|
64
|
+
task=f"Brief the student on this mission in 1-2 short lines, in character, ending by "
|
|
65
|
+
f"starting the clock. The task: {self.lesson.brief}") or self.lesson.brief
|
|
66
|
+
|
|
67
|
+
def present_step(self, step, index: int, total: int, *, acked: str = "") -> str:
|
|
68
|
+
if self.llm is None:
|
|
69
|
+
return step.say
|
|
70
|
+
lead = (f"The student just finished the previous beat ({acked}); acknowledge it in a few "
|
|
71
|
+
"words, then ") if acked else ""
|
|
72
|
+
return self.runner.call(
|
|
73
|
+
REASONING, context=self.reasoning._context(),
|
|
74
|
+
task=f"This is a guided lab, beat {index}/{total}. {lead}give the student THIS one "
|
|
75
|
+
f"instruction in 1-2 short lines — just this step: {step.say}") or step.say
|
|
76
|
+
|
|
77
|
+
def react_reply(self, step, utterance: str, results) -> str:
|
|
78
|
+
self.bb.ingest_results(results)
|
|
79
|
+
if self.llm is None:
|
|
80
|
+
return "Good — let's continue."
|
|
81
|
+
return self.runner.call(
|
|
82
|
+
REASONING, context=self.reasoning._context(),
|
|
83
|
+
task=f"The student was asked: {step.say!r} and replied: {utterance!r}. Respond in 1-2 "
|
|
84
|
+
"short lines — affirm or gently correct, grounded in the goal; then we move on."
|
|
85
|
+
) or "Good — let's continue."
|
|
86
|
+
|
|
87
|
+
def run_note(self, failed) -> str:
|
|
88
|
+
"""React to a Run whose live checks FAILED — grounded so the model can't claim a false win."""
|
|
89
|
+
joined = "; ".join(failed)
|
|
90
|
+
if self.llm is None:
|
|
91
|
+
return f"The live check didn't pass yet: {joined}. Check the real connection."
|
|
92
|
+
return self.runner.call(
|
|
93
|
+
REASONING, context=self.reasoning._context(),
|
|
94
|
+
task=f"The student ran the system and a LIVE check FAILED: {joined}. In ONE short line, "
|
|
95
|
+
"tell them the live verification did not pass yet and to check the actual "
|
|
96
|
+
"connection — do NOT claim it succeeded.") or f"The live check didn't pass: {joined}."
|
|
97
|
+
|
|
98
|
+
def flag_note(self, reasons) -> str:
|
|
99
|
+
joined = "; ".join(reasons)
|
|
100
|
+
if self.llm is None:
|
|
101
|
+
return "Heads up — " + joined
|
|
102
|
+
return self.runner.call(
|
|
103
|
+
REASONING, context=self.reasoning._context(),
|
|
104
|
+
task=f"The student just placed something off-task: {joined}. In ONE short in-character "
|
|
105
|
+
"line, flag it and tell them to fix it — do NOT remove it for them."
|
|
106
|
+
) or ("Heads up — " + joined)
|
|
107
|
+
|
|
108
|
+
# -- the reasoning turn (what the controller calls on change / ask) ------ #
|
|
109
|
+
def decide(self, mission, results, *, utterance: str = "", world_digest: str = "") -> Move:
|
|
110
|
+
if self.llm is None:
|
|
111
|
+
return Move("quiet")
|
|
112
|
+
self.bb.ingest_results(results) # sync truth from the mission's own eval
|
|
113
|
+
self._sync_world() # give the explainer the live board
|
|
114
|
+
|
|
115
|
+
if utterance: # a question → full trio (grounded + audited)
|
|
116
|
+
return self.agent.turn(utterance=utterance)
|
|
117
|
+
|
|
118
|
+
met = sum(1 for r in results if r.met)
|
|
119
|
+
first = self._prev_met is None
|
|
120
|
+
prev = self._prev_met
|
|
121
|
+
self._prev_met = met
|
|
122
|
+
|
|
123
|
+
if mission.complete:
|
|
124
|
+
trigger = Notification("mission_complete", salience=1.0)
|
|
125
|
+
elif getattr(mission, "state", "") == "done":
|
|
126
|
+
trigger = Notification("objective_unmet", salience=0.4) # ran out — encourage
|
|
127
|
+
elif self.lesson.help == "none":
|
|
128
|
+
return Move("quiet") # proctored: silent except completion
|
|
129
|
+
elif not first and met > prev:
|
|
130
|
+
trigger = Notification("objective_met", salience=0.6)
|
|
131
|
+
elif not first and met < prev:
|
|
132
|
+
trigger = Notification("objective_unmet", salience=0.4)
|
|
133
|
+
else:
|
|
134
|
+
return Move("quiet") # no salient change → stay quiet
|
|
135
|
+
|
|
136
|
+
return self.reasoning.react(trigger) # quiet reaction path: Reasoning only (fast)
|
|
137
|
+
|
|
138
|
+
# parity with GameMaster's convenience method
|
|
139
|
+
def observe(self, mission, results) -> Move:
|
|
140
|
+
return self.decide(mission, results)
|
gini/agent/api.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""GiniAPI — the programmatic surface AI agents drive.
|
|
2
|
+
|
|
3
|
+
This is the single, UI-independent entry point for *building*, *inspecting*, and
|
|
4
|
+
*explaining* topologies. The in-app assistant calls it directly; the MCP server
|
|
5
|
+
(`mcp_server.py`) wraps these same methods as tools for external agents like Claude.
|
|
6
|
+
|
|
7
|
+
The `explain_*` methods are deterministic (rule-based) so they work with no LLM and
|
|
8
|
+
are unit-testable; an LLM assistant can layer richer narration on top of them.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from ..app import AppContext
|
|
13
|
+
from ..domain import Category, all_devices, devices
|
|
14
|
+
from ..domain.topology import DeviceInstance
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class GiniAPI:
|
|
18
|
+
def __init__(self, ctx: AppContext) -> None:
|
|
19
|
+
self.ctx = ctx
|
|
20
|
+
|
|
21
|
+
# -- resolution --------------------------------------------------------- #
|
|
22
|
+
def _resolve(self, ref: str) -> DeviceInstance:
|
|
23
|
+
t = self.ctx.topology
|
|
24
|
+
if ref in t.devices:
|
|
25
|
+
return t.devices[ref]
|
|
26
|
+
d = t.find_by_name(ref)
|
|
27
|
+
if d is None:
|
|
28
|
+
raise KeyError(f"no device named or id'd {ref!r}")
|
|
29
|
+
return d
|
|
30
|
+
|
|
31
|
+
# -- catalog ------------------------------------------------------------ #
|
|
32
|
+
def list_device_types(self) -> list[dict]:
|
|
33
|
+
return [
|
|
34
|
+
{"key": d.key, "label": d.label, "category": d.category.value,
|
|
35
|
+
"cloud": d.cloud, "description": d.description}
|
|
36
|
+
for d in all_devices()
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
# -- build -------------------------------------------------------------- #
|
|
40
|
+
def add_device(self, type_key: str, name: str | None = None,
|
|
41
|
+
x: float = 0.0, y: float = 0.0,
|
|
42
|
+
properties: dict | None = None) -> dict:
|
|
43
|
+
if type_key not in devices.REGISTRY:
|
|
44
|
+
raise KeyError(f"unknown device type {type_key!r}")
|
|
45
|
+
inst = self.ctx.add_device(type_key, x=x, y=y, name=name, properties=properties)
|
|
46
|
+
return self._device_dict(inst)
|
|
47
|
+
|
|
48
|
+
def connect(self, a: str, b: str, label: str = "") -> dict:
|
|
49
|
+
da, db = self._resolve(a), self._resolve(b)
|
|
50
|
+
# ctx.connect auto-attaches Source/Sink riders (dotted edge) and links everything else —
|
|
51
|
+
# same behaviour as the canvas, so agent/recipe wiring matches what a teacher would draw.
|
|
52
|
+
link = self.ctx.connect(da.id, db.id, label)
|
|
53
|
+
return {"id": link.id, "source": da.name, "target": db.name, "label": label}
|
|
54
|
+
|
|
55
|
+
def remove_device(self, ref: str) -> None:
|
|
56
|
+
self.ctx.remove_device(self._resolve(ref).id)
|
|
57
|
+
|
|
58
|
+
def set_property(self, ref: str, key: str, value: str) -> dict:
|
|
59
|
+
d = self._resolve(ref)
|
|
60
|
+
d.properties[key] = value
|
|
61
|
+
if key == "Name":
|
|
62
|
+
self.ctx.topology.rename(d.id, value)
|
|
63
|
+
self.ctx.bus.device_changed.emit(d.id)
|
|
64
|
+
# The advisory lint reads PROPERTIES, not just shape — "No BoardID set",
|
|
65
|
+
# duplicate BoardID, overlapping PhysicalSubnet — but it only re-ran on
|
|
66
|
+
# topology_changed. So a warning raised when an element was dropped (every
|
|
67
|
+
# property still at its default) was never re-evaluated once the student
|
|
68
|
+
# filled the property in: a GINI32 board showed the amber "!" for a missing
|
|
69
|
+
# BoardID while simultaneously reporting itself online, which is impossible,
|
|
70
|
+
# since the relay matches boards BY that id. The badge was simply stale.
|
|
71
|
+
# This is debounced (120 ms) and text fields commit on editingFinished, so
|
|
72
|
+
# re-linting here costs nothing per edit.
|
|
73
|
+
self.ctx.bus.topology_changed.emit()
|
|
74
|
+
return self._device_dict(d)
|
|
75
|
+
|
|
76
|
+
# -- manual addressing -------------------------------------------------- #
|
|
77
|
+
def set_manual_addressing(self, on: bool) -> None:
|
|
78
|
+
"""Toggle manual addressing: stop auto-assigning IPs and honor static_ips
|
|
79
|
+
(auto-filling any interface left blank)."""
|
|
80
|
+
self.ctx.topology.manual_addressing = bool(on)
|
|
81
|
+
self.ctx.bus.topology_changed.emit()
|
|
82
|
+
|
|
83
|
+
def set_interface_ip(self, ref: str, link_id: str, ip: str) -> dict:
|
|
84
|
+
"""Set (or clear, with an empty string) a device's static IP on one interface
|
|
85
|
+
(identified by the link it sits on). Honored only in manual addressing mode."""
|
|
86
|
+
d = self._resolve(ref)
|
|
87
|
+
bare = (ip or "").strip().split("/")[0]
|
|
88
|
+
si = dict(getattr(d, "static_ips", None) or {})
|
|
89
|
+
if bare:
|
|
90
|
+
si[link_id] = bare
|
|
91
|
+
else:
|
|
92
|
+
si.pop(link_id, None)
|
|
93
|
+
d.static_ips = si
|
|
94
|
+
self.ctx.bus.topology_changed.emit()
|
|
95
|
+
return self._device_dict(d)
|
|
96
|
+
|
|
97
|
+
# -- recipes (Wizard blueprints) ---------------------------------------- #
|
|
98
|
+
def list_recipes(self) -> list[dict]:
|
|
99
|
+
from ..domain.recipes import RECIPES
|
|
100
|
+
return [{"id": r.id, "name": r.name, "summary": r.summary,
|
|
101
|
+
"teaches": r.teaches, "intent": list(r.intent)} for r in RECIPES]
|
|
102
|
+
|
|
103
|
+
def suggest_recipes(self, query: str) -> list[dict]:
|
|
104
|
+
"""Rank recipes against a free-text intent (deterministic; the LLM mirrors this
|
|
105
|
+
but can also explain). Returns the matching recipes, best first."""
|
|
106
|
+
from ..domain.recipes import suggest_recipes
|
|
107
|
+
return [{"id": r.id, "name": r.name, "summary": r.summary, "teaches": r.teaches}
|
|
108
|
+
for r in suggest_recipes(query)]
|
|
109
|
+
|
|
110
|
+
def apply_recipe(self, recipe_id: str) -> dict:
|
|
111
|
+
"""Instantiate a curated blueprint onto the canvas — add its elements (laid out
|
|
112
|
+
on a grid), set their properties, and connect them. Deterministic: no LLM in this
|
|
113
|
+
path, so the result is always a valid, known-good topology."""
|
|
114
|
+
from ..domain.recipes import get_recipe
|
|
115
|
+
r = get_recipe(recipe_id)
|
|
116
|
+
if r is None:
|
|
117
|
+
raise KeyError(f"unknown recipe {recipe_id!r}")
|
|
118
|
+
# offset the layout below anything already on the canvas
|
|
119
|
+
base_y = 0.0
|
|
120
|
+
if self.ctx.topology.devices:
|
|
121
|
+
base_y = max(d.y for d in self.ctx.topology.devices.values()) + 170.0
|
|
122
|
+
refs: dict[str, str] = {}
|
|
123
|
+
for el in r.elements:
|
|
124
|
+
x = 60.0 + el.col * 220.0
|
|
125
|
+
y = base_y + 40.0 + el.row * 150.0
|
|
126
|
+
d = self.add_device(el.type_key, x=x, y=y, properties=el.props or None)
|
|
127
|
+
refs[el.ref] = d["id"]
|
|
128
|
+
# box containment (VPC / Subnet / Region): set parent_id so the compiler
|
|
129
|
+
# reads membership. Parents precede children in element order.
|
|
130
|
+
parent = getattr(el, "parent", "")
|
|
131
|
+
if parent and parent in refs:
|
|
132
|
+
dev = self.ctx.topology.devices.get(d["id"])
|
|
133
|
+
if dev is not None:
|
|
134
|
+
dev.parent_id = refs[parent]
|
|
135
|
+
links = 0
|
|
136
|
+
for a, b in r.links:
|
|
137
|
+
self.connect(refs[a], refs[b])
|
|
138
|
+
links += 1
|
|
139
|
+
return {"recipe": r.id, "name": r.name, "added": list(refs.values()),
|
|
140
|
+
"links": links}
|
|
141
|
+
|
|
142
|
+
# -- inspect ------------------------------------------------------------ #
|
|
143
|
+
def get_topology(self) -> dict:
|
|
144
|
+
return self.ctx.topology.to_dict()
|
|
145
|
+
|
|
146
|
+
def inspect(self, ref: str) -> dict:
|
|
147
|
+
d = self._resolve(ref)
|
|
148
|
+
out = self._device_dict(d)
|
|
149
|
+
out["neighbors"] = [n.name for n in self.ctx.topology.neighbors(d.id)]
|
|
150
|
+
out["degree"] = self.ctx.topology.degree(d.id)
|
|
151
|
+
return out
|
|
152
|
+
|
|
153
|
+
def summary(self) -> dict:
|
|
154
|
+
t = self.ctx.topology
|
|
155
|
+
return {
|
|
156
|
+
"name": t.name,
|
|
157
|
+
"devices": len(t.devices),
|
|
158
|
+
"links": len(t.links),
|
|
159
|
+
"by_category": t.counts_by_category(),
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
# -- explain (deterministic narration) ---------------------------------- #
|
|
163
|
+
def explain_topology(self) -> str:
|
|
164
|
+
t = self.ctx.topology
|
|
165
|
+
if not t.devices:
|
|
166
|
+
return "The canvas is empty. Drag a device from the palette to begin."
|
|
167
|
+
counts = t.counts_by_category()
|
|
168
|
+
has_net = any(c in counts for c in (Category.NETWORKING.value, Category.SDN.value))
|
|
169
|
+
has_cloud = any(k in counts for k in (
|
|
170
|
+
Category.CONTAINERS.value, Category.CLOUD_NETWORK.value,
|
|
171
|
+
Category.STORAGE.value, Category.SERVERLESS.value))
|
|
172
|
+
|
|
173
|
+
parts = [f"This topology “{t.name}” has {len(t.devices)} elements "
|
|
174
|
+
f"and {len(t.links)} links."]
|
|
175
|
+
cat_bits = [f"{n} {cat.lower()}" for cat, n in counts.items()]
|
|
176
|
+
parts.append("It spans " + ", ".join(cat_bits) + ".")
|
|
177
|
+
if has_net and has_cloud:
|
|
178
|
+
parts.append("It is a hybrid experiment combining classic network devices "
|
|
179
|
+
"with cloud-computing primitives.")
|
|
180
|
+
elif has_cloud:
|
|
181
|
+
parts.append("This is primarily a cloud-computing scenario.")
|
|
182
|
+
else:
|
|
183
|
+
parts.append("This is primarily a computer-networking scenario.")
|
|
184
|
+
|
|
185
|
+
# connectivity notes
|
|
186
|
+
isolated = [d.name for d in t.devices.values() if t.degree(d.id) == 0]
|
|
187
|
+
if isolated:
|
|
188
|
+
parts.append("Not yet connected: " + ", ".join(isolated) + ".")
|
|
189
|
+
hubs = sorted(t.devices.values(), key=lambda d: t.degree(d.id), reverse=True)
|
|
190
|
+
if hubs and t.degree(hubs[0].id) >= 2:
|
|
191
|
+
parts.append(f"{hubs[0].name} is the most connected node "
|
|
192
|
+
f"({t.degree(hubs[0].id)} links).")
|
|
193
|
+
return " ".join(parts)
|
|
194
|
+
|
|
195
|
+
def context_digest(self) -> str:
|
|
196
|
+
"""A compact, ground-truth snapshot of the current canvas for the AI: every
|
|
197
|
+
device with its type, IP/subnet, and what it connects to. Injected into the
|
|
198
|
+
model each turn so the assistant always knows the topology without a tool call.
|
|
199
|
+
"""
|
|
200
|
+
t = self.ctx.topology
|
|
201
|
+
if not t.devices:
|
|
202
|
+
return "The canvas is currently empty (no devices placed yet)."
|
|
203
|
+
try:
|
|
204
|
+
from ..services.compiler import address_map
|
|
205
|
+
addr = address_map(t)
|
|
206
|
+
except Exception:
|
|
207
|
+
addr = {}
|
|
208
|
+
lines = [f'Topology "{t.name}": {len(t.devices)} devices, {len(t.links)} links.']
|
|
209
|
+
subnets: set[str] = set()
|
|
210
|
+
for d in t.devices.values():
|
|
211
|
+
info = addr.get(d.name, {})
|
|
212
|
+
ifaces = info.get("interfaces", [])
|
|
213
|
+
ip_bits = []
|
|
214
|
+
for itf in ifaces:
|
|
215
|
+
if itf.get("ip"):
|
|
216
|
+
ip_bits.append(itf["ip"])
|
|
217
|
+
if itf.get("subnet"):
|
|
218
|
+
subnets.add(itf["subnet"])
|
|
219
|
+
ip_s = (" — " + ", ".join(ip_bits)) if ip_bits else ""
|
|
220
|
+
nbrs = [n.name for n in t.neighbors(d.id)]
|
|
221
|
+
conn = ("connected to " + ", ".join(nbrs)) if nbrs else "not connected yet"
|
|
222
|
+
gw = next((itf.get("gateway") for itf in ifaces if itf.get("gateway")), None)
|
|
223
|
+
gw_s = f", gateway {gw}" if gw else ""
|
|
224
|
+
lines.append(f"- {d.name} ({d.type.label}){ip_s} — {conn}{gw_s}")
|
|
225
|
+
if subnets:
|
|
226
|
+
lines.append("Subnets: " + ", ".join(sorted(subnets)) + ".")
|
|
227
|
+
return "\n".join(lines)
|
|
228
|
+
|
|
229
|
+
def explain_element_type(self, type_key: str) -> str:
|
|
230
|
+
"""Teaching guide for a palette element TYPE (what it is + when to use it).
|
|
231
|
+
Falls back to the catalog description if the element isn't in the guide."""
|
|
232
|
+
from ..domain.devices import REGISTRY
|
|
233
|
+
from ..domain.element_guide import guide_for
|
|
234
|
+
dt = REGISTRY.get(type_key)
|
|
235
|
+
if dt is None:
|
|
236
|
+
return f"I don't recognize the element '{type_key}'."
|
|
237
|
+
return guide_for(type_key) or f"{dt.label} ({dt.category.value}). {dt.description}"
|
|
238
|
+
|
|
239
|
+
def trace_path(self, src: str, dst: str) -> list[str]:
|
|
240
|
+
"""The hop-by-hop device path a packet takes from src to dst (by name).
|
|
241
|
+
|
|
242
|
+
Shortest path over the topology graph — which is exactly the forwarding path,
|
|
243
|
+
since routing follows it. Returns device names (e.g. [M1, S1, R1, R2, S2, M5])
|
|
244
|
+
or [] if there's no path. Drives the tutor's packet animation.
|
|
245
|
+
"""
|
|
246
|
+
from collections import deque
|
|
247
|
+
s, d = self._resolve(src), self._resolve(dst)
|
|
248
|
+
t = self.ctx.topology
|
|
249
|
+
nbrs: dict[str, list] = {}
|
|
250
|
+
for l in t.links.values():
|
|
251
|
+
nbrs.setdefault(l.source_id, []).append(l.target_id)
|
|
252
|
+
nbrs.setdefault(l.target_id, []).append(l.source_id)
|
|
253
|
+
prev: dict[str, str | None] = {s.id: None}
|
|
254
|
+
q = deque([s.id])
|
|
255
|
+
while q:
|
|
256
|
+
cur = q.popleft()
|
|
257
|
+
if cur == d.id:
|
|
258
|
+
break
|
|
259
|
+
for nb in nbrs.get(cur, []):
|
|
260
|
+
if nb not in prev:
|
|
261
|
+
prev[nb] = cur
|
|
262
|
+
q.append(nb)
|
|
263
|
+
if d.id not in prev:
|
|
264
|
+
return []
|
|
265
|
+
path, cur = [], d.id
|
|
266
|
+
while cur is not None:
|
|
267
|
+
path.append(t.devices[cur].name)
|
|
268
|
+
cur = prev[cur]
|
|
269
|
+
path.reverse()
|
|
270
|
+
return path
|
|
271
|
+
|
|
272
|
+
def explain_device(self, ref: str) -> str:
|
|
273
|
+
d = self._resolve(ref)
|
|
274
|
+
dt = d.type
|
|
275
|
+
nbrs = [n.name for n in self.ctx.topology.neighbors(d.id)]
|
|
276
|
+
msg = f"{d.name} is a {dt.label} ({dt.category.value}). {dt.description}"
|
|
277
|
+
props = {k: v for k, v in d.properties.items() if v and k != "Name"}
|
|
278
|
+
if props:
|
|
279
|
+
msg += " Configured: " + ", ".join(f"{k}={v}" for k, v in props.items()) + "."
|
|
280
|
+
msg += (f" It connects to {', '.join(nbrs)}." if nbrs
|
|
281
|
+
else " It is not connected to anything yet.")
|
|
282
|
+
return msg
|
|
283
|
+
|
|
284
|
+
# -- helpers ------------------------------------------------------------ #
|
|
285
|
+
@staticmethod
|
|
286
|
+
def _device_dict(d: DeviceInstance) -> dict:
|
|
287
|
+
return {
|
|
288
|
+
"id": d.id, "name": d.name, "type": d.type_key,
|
|
289
|
+
"category": d.type.category.value,
|
|
290
|
+
"x": d.x, "y": d.y, "properties": dict(d.properties),
|
|
291
|
+
}
|