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/domain/assembly.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Assembly — turn a set of chosen fragments into ONE complete, gradable mission by graph closure.
|
|
2
|
+
|
|
3
|
+
This is the engine behind "the reasoner pieces templates together into bigger missions." Given one
|
|
4
|
+
or more **core** fragments, assembly:
|
|
5
|
+
|
|
6
|
+
1. **closes the graph** — every `requires` is satisfied by some fragment's `provides`, pulling in a
|
|
7
|
+
provider when a requirement is unmet (so nothing dangles);
|
|
8
|
+
2. **fills layers** — for engaged genres it adds an *exercise* and an *observe* companion so a bare
|
|
9
|
+
skeleton becomes educational (the completeness rule of the design);
|
|
10
|
+
3. **defaults genre + quest level** from the shape of the assembly (both pinnable);
|
|
11
|
+
4. **emits a Lesson** — the union of the fragments' objectives (semantic-dedup on the predicate,
|
|
12
|
+
ids kept unique) + merged intent + a help level from the genre — and rejects anything that
|
|
13
|
+
doesn't `validate()` (never ship an ungradable mission).
|
|
14
|
+
|
|
15
|
+
The LLM never runs here: assembly is deterministic. The model's only job upstream is choosing the
|
|
16
|
+
core(s) and reskinning — GINI owns the gradable structure. See GINI_MISSIONS_COMPOSABLE_DESIGN.md.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from . import capabilities as _caps
|
|
21
|
+
from . import fragments as _frag
|
|
22
|
+
from . import lesson as _lesson
|
|
23
|
+
from .objectives import Objective
|
|
24
|
+
|
|
25
|
+
EXPERIENCE, EXPEDITION, CHALLENGE = "experience", "expedition", "challenge"
|
|
26
|
+
|
|
27
|
+
# how much the game master helps, per genre
|
|
28
|
+
_HELP_BY_GENRE = {EXPERIENCE: "full_tutor_logged", EXPEDITION: "warmer_colder", CHALLENGE: "none"}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _provided(frags) -> set[str]:
|
|
32
|
+
out: set[str] = set()
|
|
33
|
+
for f in frags:
|
|
34
|
+
out.update(f.provides)
|
|
35
|
+
return out
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _requirement_met(required: str, frags) -> bool:
|
|
39
|
+
return any(_caps.any_satisfies(f.provides, required) for f in frags)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def close_graph(frags: list, exclude=None) -> tuple[list, list[str]]:
|
|
43
|
+
"""Add providers until every `requires` is satisfied. Returns (fragments, still-unmet). Prefers
|
|
44
|
+
a provider that is itself a core/enrichment already in the registry; deterministic + terminating
|
|
45
|
+
(each role is resolved at most once). Providers the student excluded are never pulled in."""
|
|
46
|
+
chosen = list(frags)
|
|
47
|
+
chosen_ids = {f.id for f in chosen}
|
|
48
|
+
unmet: list[str] = []
|
|
49
|
+
guard = 0
|
|
50
|
+
queue = list(chosen)
|
|
51
|
+
while queue and guard < 100:
|
|
52
|
+
guard += 1
|
|
53
|
+
f = queue.pop(0)
|
|
54
|
+
for req in f.requires:
|
|
55
|
+
if _requirement_met(req, chosen):
|
|
56
|
+
continue
|
|
57
|
+
provider = next((p for p in _frag.find_providers(req)
|
|
58
|
+
if p.id not in chosen_ids and not _excluded(p, exclude)), None)
|
|
59
|
+
if provider is None:
|
|
60
|
+
if req not in unmet:
|
|
61
|
+
unmet.append(req)
|
|
62
|
+
continue
|
|
63
|
+
chosen.append(provider)
|
|
64
|
+
chosen_ids.add(provider.id)
|
|
65
|
+
queue.append(provider)
|
|
66
|
+
return chosen, unmet
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _excluded(fragment, exclude) -> bool:
|
|
70
|
+
if exclude is None:
|
|
71
|
+
return False
|
|
72
|
+
from . import constraints as _con
|
|
73
|
+
return _con.fragment_excluded(fragment, exclude)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _fill_layer(chosen: list, layer: str, exclude=None) -> list:
|
|
77
|
+
"""If no fragment of `layer` is present, add the first enrichment fragment of that layer whose
|
|
78
|
+
own requirements are already satisfied AND that the student hasn't excluded. A layer the student
|
|
79
|
+
excluded outright is never filled."""
|
|
80
|
+
if exclude is not None and layer in getattr(exclude, "layers", set()):
|
|
81
|
+
return chosen # the student asked to leave this layer out
|
|
82
|
+
if any(f.layer == layer for f in chosen):
|
|
83
|
+
return chosen
|
|
84
|
+
ids = {f.id for f in chosen}
|
|
85
|
+
for cand in _frag.by_layer(layer):
|
|
86
|
+
if cand.id in ids or _excluded(cand, exclude):
|
|
87
|
+
continue
|
|
88
|
+
if all(_requirement_met(r, chosen) for r in cand.requires):
|
|
89
|
+
return chosen + [cand]
|
|
90
|
+
return chosen
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def default_genre(chosen: list, *, faults: int = 0) -> str:
|
|
94
|
+
has_ex = any(f.layer == _frag.EXERCISE for f in chosen)
|
|
95
|
+
has_ob = any(f.layer == _frag.OBSERVE for f in chosen)
|
|
96
|
+
if faults > 0 and has_ex and has_ob:
|
|
97
|
+
return CHALLENGE
|
|
98
|
+
if has_ex:
|
|
99
|
+
return EXPEDITION
|
|
100
|
+
return EXPERIENCE
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def default_level(chosen: list, *, faults: int = 0, guided: bool = False) -> int:
|
|
104
|
+
n_core = sum(1 for f in chosen if f.layer == _frag.CORE)
|
|
105
|
+
has_ex = any(f.layer == _frag.EXERCISE for f in chosen)
|
|
106
|
+
has_ob = any(f.layer == _frag.OBSERVE for f in chosen)
|
|
107
|
+
score = max(0, n_core - 1) + (1 if has_ex else 0) + (1 if has_ob else 0) + faults
|
|
108
|
+
score -= 1 if guided else 0
|
|
109
|
+
return max(0, min(3, score))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _forks_of(primary) -> list:
|
|
113
|
+
"""The primary core's difficulty forks, instantiated into live Objectives, carried onto the
|
|
114
|
+
lesson. Forks come from the CORE fragment only — a composition's difficulty is the core's."""
|
|
115
|
+
out = []
|
|
116
|
+
for fk in getattr(primary, "forks", ()) or ():
|
|
117
|
+
out.append({"id": fk.id, "label": fk.label, "difficulty": fk.difficulty,
|
|
118
|
+
"kind": fk.kind, "objectives": fk.instantiate()})
|
|
119
|
+
return out
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _merge_objectives(chosen: list) -> list[Objective]:
|
|
123
|
+
objs: list[Objective] = []
|
|
124
|
+
seen_checks: set[str] = set()
|
|
125
|
+
seen_ids: set[str] = set()
|
|
126
|
+
for f in chosen:
|
|
127
|
+
for o in f.instantiate():
|
|
128
|
+
if o.check and o.check in seen_checks:
|
|
129
|
+
continue # same requirement already covered by another fragment
|
|
130
|
+
if o.check:
|
|
131
|
+
seen_checks.add(o.check)
|
|
132
|
+
oid = o.id if o.id not in seen_ids else f"{f.id}-{o.id}"
|
|
133
|
+
seen_ids.add(oid)
|
|
134
|
+
objs.append(Objective(id=oid, say=o.say, kind=o.kind, check=o.check, probe=o.probe,
|
|
135
|
+
level=o.level, stars=getattr(o, "stars", 0)))
|
|
136
|
+
from .objectives import by_level
|
|
137
|
+
return by_level(objs) # progressive ladder: place → connect → group → prove live
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def assemble(core_ids, *, genre: str | None = None, level: int | None = None, lesson_id: str,
|
|
141
|
+
title: str = "", brief: str = "", persona: str = "coach", fill: bool | None = None,
|
|
142
|
+
exclude=None, **overrides) -> _lesson.Lesson:
|
|
143
|
+
"""Assemble one Lesson from the given core fragment id(s).
|
|
144
|
+
|
|
145
|
+
genre/level default from the assembly's shape but may be pinned. `fill` forces layer-filling on
|
|
146
|
+
(Expedition/Challenge default) or off (Experience default = core only, guided). `exclude` (a
|
|
147
|
+
constraints.Excludes) suppresses layers/companions the student asked to leave out."""
|
|
148
|
+
seeds = [_frag.get(cid) for cid in core_ids]
|
|
149
|
+
seeds = [f for f in seeds if f is not None] or [_frag.cores()[0]]
|
|
150
|
+
|
|
151
|
+
chosen, _unmet = close_graph(seeds, exclude)
|
|
152
|
+
|
|
153
|
+
# experience = keep it a bare, guided build; engaged genres fill exercise + observe
|
|
154
|
+
engaged = genre in (EXPEDITION, CHALLENGE) if genre else True
|
|
155
|
+
do_fill = engaged if fill is None else fill
|
|
156
|
+
if do_fill:
|
|
157
|
+
chosen = _fill_layer(chosen, _frag.EXERCISE, exclude)
|
|
158
|
+
chosen = _fill_layer(chosen, _frag.OBSERVE, exclude)
|
|
159
|
+
|
|
160
|
+
guided = genre == EXPERIENCE
|
|
161
|
+
genre = genre or default_genre(chosen)
|
|
162
|
+
if level is None:
|
|
163
|
+
level = default_level(chosen, guided=guided)
|
|
164
|
+
help_level = overrides.get("help", _HELP_BY_GENRE.get(genre, "warmer_colder"))
|
|
165
|
+
|
|
166
|
+
primary = chosen[0]
|
|
167
|
+
objs = _merge_objectives(chosen)
|
|
168
|
+
spirit = " ".join(f.spirit for f in chosen if f.spirit)
|
|
169
|
+
misconceptions: list[str] = []
|
|
170
|
+
for f in chosen:
|
|
171
|
+
for m in f.misconceptions:
|
|
172
|
+
if m not in misconceptions:
|
|
173
|
+
misconceptions.append(m)
|
|
174
|
+
steps = [_lesson.Step(say=f.step) for f in chosen if f.step] if guided else []
|
|
175
|
+
|
|
176
|
+
# A teacher's authored intent AUGMENTS the fragments' own: their goal/emphasis leads, but the
|
|
177
|
+
# fragment's spirit (what actually counts as success) is preserved and their misconceptions are
|
|
178
|
+
# added. `notes` is pure teacher nuance the game master reasons over at play time.
|
|
179
|
+
ti = overrides.get("intent") or {}
|
|
180
|
+
t_spirit = (ti.get("spirit") or "").strip()
|
|
181
|
+
intent = _lesson.Intent(
|
|
182
|
+
concept=ti.get("concept") or primary.teaches,
|
|
183
|
+
goal=ti.get("goal") or title or primary.summary,
|
|
184
|
+
spirit=(f"{t_spirit} {spirit}".strip() if t_spirit else spirit),
|
|
185
|
+
misconceptions=list(dict.fromkeys(list(ti.get("misconceptions") or []) + misconceptions)),
|
|
186
|
+
notes=ti.get("notes", ""))
|
|
187
|
+
summary = brief or " ".join(f.summary for f in chosen if f.summary)
|
|
188
|
+
quest_tag = f"[Quest L{level} · {genre}] "
|
|
189
|
+
|
|
190
|
+
cw = overrides.get("complete_when") or getattr(primary, "complete_when", "all")
|
|
191
|
+
lesson = _lesson.Lesson(
|
|
192
|
+
id=lesson_id, title=title or primary.summary, brief=quest_tag + summary, objectives=objs,
|
|
193
|
+
steps=steps, stage=dict(getattr(primary, "stage", {}) or {}), complete_when=cw,
|
|
194
|
+
time_limit_s=_lesson.parse_duration(overrides.get("time_limit", "20m")),
|
|
195
|
+
attempts=int(overrides.get("attempts", 3)), help=help_level, persona=persona,
|
|
196
|
+
intent=intent, archetype=primary.id, genre=genre, level=level,
|
|
197
|
+
fragments=[f.id for f in chosen], forks=_forks_of(primary))
|
|
198
|
+
|
|
199
|
+
if not _lesson.is_valid(lesson): # guardrail: fall back to the primary core alone
|
|
200
|
+
objs = _merge_objectives([primary])
|
|
201
|
+
lesson = _lesson.Lesson(
|
|
202
|
+
id=lesson_id, title=title or primary.summary, brief=quest_tag + (brief or primary.summary),
|
|
203
|
+
objectives=objs, complete_when=primary.complete_when,
|
|
204
|
+
time_limit_s=_lesson.parse_duration(overrides.get("time_limit", "20m")),
|
|
205
|
+
attempts=int(overrides.get("attempts", 3)), help=help_level, persona=persona,
|
|
206
|
+
intent=_lesson.Intent(concept=primary.teaches, goal=title or primary.summary,
|
|
207
|
+
spirit=primary.spirit, misconceptions=list(primary.misconceptions)),
|
|
208
|
+
archetype=primary.id, genre=genre, level=level, fragments=[primary.id])
|
|
209
|
+
return lesson
|
gini/domain/authoring.py
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
"""Fragment authoring — objectives *by demonstration*.
|
|
2
|
+
|
|
3
|
+
A professor shouldn't hand-write `path(web_app, database)`. Instead they build the winning arrangement
|
|
4
|
+
on the real canvas, and this reads it back into candidate objectives they confirm or prune. The
|
|
5
|
+
canvas is the predicate builder (GINI_AUTHORING_DESIGN.md, Seam B). Pure logic over a Topology; the UI
|
|
6
|
+
in `ui/` just drives it.
|
|
7
|
+
|
|
8
|
+
What we derive, auto-levelled onto the ladder (place → connect → group):
|
|
9
|
+
* L1 placement — `exists(type)`, or `count(type) >= n` when the board has several of one type;
|
|
10
|
+
* L2 connection — `link(a, b)` for each *type-pair* that's directly cabled;
|
|
11
|
+
* L3 containment — `contains_type(box, type)` for each element inside a grouping box (VPC/subnet…).
|
|
12
|
+
|
|
13
|
+
Live (L4) objectives (`reach/http/…`) can't be read off a static picture — the author adds those
|
|
14
|
+
explicitly. We offer the structural skeleton; the human supplies judgment and the live checks.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from . import devices as _devices
|
|
19
|
+
from .content import ENGINE_VERSION, FRAGMENT_SCHEMA
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _type(topo, dev_id: str) -> str:
|
|
23
|
+
d = topo.devices.get(dev_id)
|
|
24
|
+
return d.type_key if d else ""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _spec(topo, dev_id: str) -> str:
|
|
28
|
+
"""`type` for a delta element, `type@slot` for a scaffold element tagged with a slot."""
|
|
29
|
+
d = topo.devices.get(dev_id)
|
|
30
|
+
if d is None:
|
|
31
|
+
return ""
|
|
32
|
+
return f"{d.type_key}@{d.slot}" if getattr(d, "slot", "") else d.type_key
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _slot_label(spec: str) -> str:
|
|
36
|
+
"""Human label for a possibly slot-scoped type spec, e.g. 'switch (slot A)'."""
|
|
37
|
+
tk, _, slot = spec.partition("@")
|
|
38
|
+
return _label(tk) + (f" (slot {slot})" if slot else "")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _is_rider(type_key: str) -> bool:
|
|
42
|
+
"""Sources/Sinks are I/O ports, not structural elements — kept out of derived objectives."""
|
|
43
|
+
dt = _devices.get(type_key) if type_key else None
|
|
44
|
+
return bool(dt and getattr(dt, "rider", False))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _is_container(type_key: str) -> bool:
|
|
48
|
+
"""Grouping boxes (VPC / Subnet / Region) — the ones containment is about."""
|
|
49
|
+
dt = _devices.get(type_key)
|
|
50
|
+
cat = getattr(dt, "category", None)
|
|
51
|
+
return bool(dt) and (str(getattr(cat, "value", cat)).lower() in ("group", "boundary", "zone")
|
|
52
|
+
or type_key in ("vpc", "subnet", "region"))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def derive_objectives(topo, exclude=None) -> list[dict]:
|
|
56
|
+
"""Read a Topology → ordered candidate objectives (dicts: id, say, check, kind, level).
|
|
57
|
+
|
|
58
|
+
Deterministic and type-based (like everything gradable in GINI), so the derived checks match what
|
|
59
|
+
the student is actually graded on. The author confirms/prunes; nothing is auto-committed.
|
|
60
|
+
|
|
61
|
+
`exclude` = device ids belonging to a SCAFFOLD (a loaded dependency/provider). Those elements are
|
|
62
|
+
NOT this fragment's — they're provided at composition time — so they're skipped. A link with BOTH
|
|
63
|
+
endpoints in the scaffold is scaffold-internal (skipped); a link from a NEW element to a scaffold
|
|
64
|
+
one (e.g. router→switch) IS the delta and is kept."""
|
|
65
|
+
from .objectives import CONNECTION, CONTAINMENT, PLACEMENT
|
|
66
|
+
|
|
67
|
+
exclude = exclude or set()
|
|
68
|
+
out: list[dict] = []
|
|
69
|
+
seen: set[str] = set()
|
|
70
|
+
|
|
71
|
+
def add(key, oid, say, check, level):
|
|
72
|
+
if key in seen:
|
|
73
|
+
return
|
|
74
|
+
seen.add(key)
|
|
75
|
+
out.append({"id": oid, "say": say, "check": check, "kind": "structural", "level": level,
|
|
76
|
+
"key": key})
|
|
77
|
+
|
|
78
|
+
# --- L1: placement -------------------------------------------------------
|
|
79
|
+
# `key` is stable across count changes ("place:host"), so the recorder updates one step as more
|
|
80
|
+
# of a type appear rather than spawning a new step per element.
|
|
81
|
+
counts: dict[str, int] = {}
|
|
82
|
+
for d in topo.devices.values():
|
|
83
|
+
if d.id in exclude: # a scaffold (provided dependency) element — not ours
|
|
84
|
+
continue
|
|
85
|
+
if getattr(d.type, "rider", False): # Sources/Sinks are ports, not structure
|
|
86
|
+
continue
|
|
87
|
+
counts[d.type_key] = counts.get(d.type_key, 0) + 1
|
|
88
|
+
for tk, n in sorted(counts.items()):
|
|
89
|
+
label = _label(tk)
|
|
90
|
+
if n >= 2:
|
|
91
|
+
add(f"place:{tk}", f"place-{tk}", f"Place at least {n} {label}(s)",
|
|
92
|
+
f"count({tk}) >= {n}", PLACEMENT)
|
|
93
|
+
else:
|
|
94
|
+
add(f"place:{tk}", f"place-{tk}", f"Place a {label}", f"exists({tk})", PLACEMENT)
|
|
95
|
+
|
|
96
|
+
# --- L2: connection (one objective per directly-cabled TYPE pair) --------
|
|
97
|
+
pairs: set[tuple[str, str]] = set()
|
|
98
|
+
for l in topo.links.values():
|
|
99
|
+
if getattr(l, "kind", "link") == "attach": # a rider mount is not a cable
|
|
100
|
+
continue
|
|
101
|
+
if l.source_id in exclude and l.target_id in exclude: # scaffold-internal wiring — not ours
|
|
102
|
+
continue
|
|
103
|
+
# a delta→scaffold link scopes the scaffold end by its slot: link(router, switch@A)
|
|
104
|
+
sa, sb = _spec(topo, l.source_id), _spec(topo, l.target_id)
|
|
105
|
+
ta, tb = sa.split("@")[0], sb.split("@")[0]
|
|
106
|
+
if sa and sb and not _is_rider(ta) and not _is_rider(tb):
|
|
107
|
+
pairs.add(tuple(sorted((sa, sb))))
|
|
108
|
+
for a, b in sorted(pairs):
|
|
109
|
+
key = f"link:{a}|{b}"
|
|
110
|
+
oid = f"link-{a}-{b}".replace("@", "-at-")
|
|
111
|
+
add(key, oid, f"Wire the {_slot_label(a)} to the {_slot_label(b)}",
|
|
112
|
+
f"link({a}, {b})", CONNECTION)
|
|
113
|
+
|
|
114
|
+
# --- L3: containment (one per element type inside a grouping box) --------
|
|
115
|
+
contained: set[tuple[str, str]] = set()
|
|
116
|
+
for d in topo.devices.values():
|
|
117
|
+
if d.id in exclude or not d.parent_id:
|
|
118
|
+
continue
|
|
119
|
+
box = _type(topo, d.parent_id)
|
|
120
|
+
if box and _is_container(box) and box != d.type_key:
|
|
121
|
+
contained.add((box, d.type_key))
|
|
122
|
+
for box, tk in sorted(contained):
|
|
123
|
+
add(f"in:{box}|{tk}", f"in-{box}-{tk}", f"The {_label(tk)} sits inside the {_label(box)}",
|
|
124
|
+
f"contains_type({box}, {tk})", CONTAINMENT)
|
|
125
|
+
|
|
126
|
+
return out
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def live_check(src_type: str, dst_type: str, expect_ok: bool) -> dict:
|
|
130
|
+
"""Build a LIVE (L4) objective — a runtime probe the recorder can't capture, because it's an
|
|
131
|
+
assertion about behavior, not a drag-and-drop action. Added by hand in the editor."""
|
|
132
|
+
verb = "reaches" if expect_ok else "cannot reach"
|
|
133
|
+
tail = "ok" if expect_ok else "fail"
|
|
134
|
+
oid = f"live-{src_type}-{dst_type}-{tail}".replace("@", "-at-")
|
|
135
|
+
return {"id": oid,
|
|
136
|
+
"say": f"Live: the {_slot_label(src_type)} {verb} the {_slot_label(dst_type)} "
|
|
137
|
+
f"(Run to check)",
|
|
138
|
+
"check": "", "kind": "behavioral", "level": 4,
|
|
139
|
+
"probe": f"reach({src_type} -> {dst_type}) == {tail}",
|
|
140
|
+
"key": f"live:{src_type}|{dst_type}|{tail}"}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def derive_contract(topo, exclude=None) -> tuple[list[str], list[str]]:
|
|
144
|
+
"""Auto-derive a fragment's (provides, requires) capability contract from the built board — no
|
|
145
|
+
teacher typing. `provides` = the roles of everything built; `requires` = roles the board NEEDS
|
|
146
|
+
(a source needs a target; the grammar's required partners) but does NOT build itself. The
|
|
147
|
+
polarity is pure build-vs-consume. Matching is is-a aware, so a router-gateway satisfies a
|
|
148
|
+
required l3-gateway.
|
|
149
|
+
|
|
150
|
+
`exclude` = SCAFFOLD device ids (a loaded dependency). They're not part of THIS fragment, so they
|
|
151
|
+
contribute nothing here — the requirement they represent is added by the caller from the provider
|
|
152
|
+
fragment's own `provides` (a clean 'I require what that block provides')."""
|
|
153
|
+
from . import capabilities as _caps
|
|
154
|
+
from . import connection_rules as _cr
|
|
155
|
+
|
|
156
|
+
exclude = exclude or set()
|
|
157
|
+
provides: set[str] = set()
|
|
158
|
+
for d in topo.devices.values():
|
|
159
|
+
if d.id in exclude:
|
|
160
|
+
continue
|
|
161
|
+
provides.update(_caps.roles_for(d.type_key))
|
|
162
|
+
# a required role is met if any provided role is that role or a sub-role of it
|
|
163
|
+
provided_closure: set[str] = set()
|
|
164
|
+
for r in provides:
|
|
165
|
+
provided_closure |= _caps.ancestors(r)
|
|
166
|
+
|
|
167
|
+
requires: set[str] = set()
|
|
168
|
+
for d in topo.devices.values():
|
|
169
|
+
if d.id in exclude:
|
|
170
|
+
continue
|
|
171
|
+
for p in _cr.required_partners(d.type_key): # structural needs from the grammar
|
|
172
|
+
requires.update(_caps.roles_for(p.type_key))
|
|
173
|
+
if getattr(d.type, "role", "") == "source": # a stimulus needs something to hit
|
|
174
|
+
requires.add("traffic-sink")
|
|
175
|
+
requires = {r for r in requires if r not in provided_closure} # drop anything we build ourselves
|
|
176
|
+
return sorted(provides), sorted(requires)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def output_check(rider_type: str, metric: str, op: str, value: float) -> dict:
|
|
180
|
+
"""Build an OUTPUT objective — an assertion on a Source/Sink's measurement (the gradable output
|
|
181
|
+
of an experiment). Graded via the `measure(...)` probe, so it rides the same runner as reach."""
|
|
182
|
+
from .objectives import LIVE
|
|
183
|
+
v = int(value) if float(value).is_integer() else value
|
|
184
|
+
return {"id": f"out-{rider_type}-{metric}",
|
|
185
|
+
"say": f"{_label(rider_type)}: {metric.replace('_', ' ')} {op} {v}",
|
|
186
|
+
"check": "", "kind": "behavioral", "level": LIVE,
|
|
187
|
+
"probe": f"measure({rider_type}, {metric}) {op} {v}",
|
|
188
|
+
"key": f"measure:{rider_type}|{metric}"}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class Recorder:
|
|
192
|
+
"""Scan mode: capture ordered steps as the teacher builds on the canvas. `capture(topology)` is
|
|
193
|
+
called on every canvas change while recording; each new derivable fact becomes a step, IN THE
|
|
194
|
+
ORDER it first appeared. Steps are keyed so counts update in place (a second host bumps the
|
|
195
|
+
existing 'place host' step to count>=2, it doesn't add a new one). Deletes are NOT auto-pruned —
|
|
196
|
+
the teacher removes stray steps by hand (predictable beats clever)."""
|
|
197
|
+
|
|
198
|
+
def __init__(self, exclude=None) -> None:
|
|
199
|
+
self.steps: list[dict] = []
|
|
200
|
+
self._by_key: dict[str, dict] = {}
|
|
201
|
+
self.exclude = set(exclude or ()) # scaffold device ids — captured deltas skip these
|
|
202
|
+
|
|
203
|
+
def capture(self, topo) -> None:
|
|
204
|
+
for cand in derive_objectives(topo, exclude=self.exclude):
|
|
205
|
+
k = cand["key"]
|
|
206
|
+
existing = self._by_key.get(k)
|
|
207
|
+
if existing is None:
|
|
208
|
+
self.steps.append(cand)
|
|
209
|
+
self._by_key[k] = cand
|
|
210
|
+
else: # same fact, count/label may have grown — refresh
|
|
211
|
+
existing["say"] = cand["say"]
|
|
212
|
+
existing["check"] = cand["check"]
|
|
213
|
+
|
|
214
|
+
def result(self) -> list[dict]:
|
|
215
|
+
return list(self.steps)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _label(type_key: str) -> str:
|
|
219
|
+
dt = _devices.get(type_key)
|
|
220
|
+
return (dt.label if dt else type_key).lower()
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def materialize(ctx, objectives, x0: float = 60.0) -> list[str]:
|
|
224
|
+
"""Build a minimal board satisfying a provider fragment's STRUCTURAL objectives, as a SCAFFOLD
|
|
225
|
+
the teacher authors a dependent fragment on top of. Returns the created device ids (the scaffold
|
|
226
|
+
set). Behavioral objectives are ignored (a reach can't be placed). Links use a star: every A of a
|
|
227
|
+
`link(a, b)` is wired to the first B, so a LAN provider comes out as hosts-on-a-switch.
|
|
228
|
+
|
|
229
|
+
`x0` is the left edge of this scaffold's layout band. Each call otherwise starts at the same x,
|
|
230
|
+
so a fragment with several slots (or several representatives) would stack them all on top of
|
|
231
|
+
each other; the caller passes a per-slot offset to lay them out side by side."""
|
|
232
|
+
import re
|
|
233
|
+
created: list[str] = []
|
|
234
|
+
by_type: dict[str, list[str]] = {}
|
|
235
|
+
|
|
236
|
+
def place(type_key: str, n: int) -> None:
|
|
237
|
+
have = by_type.setdefault(type_key, [])
|
|
238
|
+
while len(have) < n:
|
|
239
|
+
inst = ctx.add_device(type_key, x=x0 + len(created) * 70, y=70 + len(by_type) * 90)
|
|
240
|
+
have.append(inst.id)
|
|
241
|
+
created.append(inst.id)
|
|
242
|
+
|
|
243
|
+
for o in objectives: # 1. placements
|
|
244
|
+
chk = o.get("check") or ""
|
|
245
|
+
m = re.match(r"^\s*exists\((\w+)\)\s*$", chk)
|
|
246
|
+
if m:
|
|
247
|
+
place(m.group(1), 1); continue
|
|
248
|
+
m = re.match(r"^\s*count\((\w+)\)\s*>=\s*(\d+)", chk)
|
|
249
|
+
if m:
|
|
250
|
+
place(m.group(1), int(m.group(2)))
|
|
251
|
+
|
|
252
|
+
for o in objectives: # 2. containment (place T inside a box)
|
|
253
|
+
m = re.match(r"^\s*contains_type\((\w+)\s*,\s*(\w+)\)\s*$", o.get("check") or "")
|
|
254
|
+
if m:
|
|
255
|
+
box, tk = m.group(1), m.group(2)
|
|
256
|
+
place(box, 1)
|
|
257
|
+
place(tk, 1)
|
|
258
|
+
child = by_type[tk][0]
|
|
259
|
+
ctx.topology.devices[child].parent_id = by_type[box][0]
|
|
260
|
+
|
|
261
|
+
for o in objectives: # 3. links (star: all A → first B)
|
|
262
|
+
m = re.match(r"^\s*link\((\w+)\s*,\s*(\w+)\)\s*$", o.get("check") or "")
|
|
263
|
+
if m and by_type.get(m.group(2)):
|
|
264
|
+
b0 = by_type[m.group(2)][0]
|
|
265
|
+
for aid in by_type.get(m.group(1), []):
|
|
266
|
+
if aid != b0:
|
|
267
|
+
try:
|
|
268
|
+
ctx.add_link(aid, b0)
|
|
269
|
+
except Exception: # noqa: BLE001 — grammar refusal: skip
|
|
270
|
+
pass
|
|
271
|
+
return created
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# -- assemble a fragment from authored parts + save it ----------------------- #
|
|
275
|
+
def build_fragment_dict(*, frag_id: str, teaches: str, summary: str, spirit: str,
|
|
276
|
+
objectives: list[dict], provides=None, requires=None,
|
|
277
|
+
forks: list[dict] | None = None, stage: dict | None = None,
|
|
278
|
+
author: str = "", certified: bool = False, slots=None, peerings=None) -> dict:
|
|
279
|
+
"""Assemble the fragment YAML dict from authored parts, stamped with the engine version so it can
|
|
280
|
+
be version-gated downstream. Returns a dict ready for `fragment_yaml.fragment_from_dict`."""
|
|
281
|
+
d: dict = {"id": frag_id, "layer": "core", "engine_version": ENGINE_VERSION,
|
|
282
|
+
"schema_version": FRAGMENT_SCHEMA}
|
|
283
|
+
if teaches:
|
|
284
|
+
d["teaches"] = teaches
|
|
285
|
+
if summary:
|
|
286
|
+
d["summary"] = summary
|
|
287
|
+
if spirit:
|
|
288
|
+
d["spirit"] = spirit
|
|
289
|
+
if provides:
|
|
290
|
+
d["provides"] = list(provides)
|
|
291
|
+
if requires:
|
|
292
|
+
d["requires"] = list(requires)
|
|
293
|
+
if author:
|
|
294
|
+
d["author"] = author
|
|
295
|
+
d["objectives"] = [_clean_obj(o) for o in objectives]
|
|
296
|
+
if forks:
|
|
297
|
+
d["forks"] = [{"id": f["id"], "label": f.get("label", ""),
|
|
298
|
+
"difficulty": int(f.get("difficulty", 2)), "kind": f.get("kind", "converge"),
|
|
299
|
+
"objectives": [_clean_obj(o) for o in f.get("objectives", [])]}
|
|
300
|
+
for f in forks]
|
|
301
|
+
if stage:
|
|
302
|
+
d["stage"] = stage
|
|
303
|
+
if slots:
|
|
304
|
+
d["slots"] = [dict(s) for s in slots]
|
|
305
|
+
if peerings:
|
|
306
|
+
d["peerings"] = [dict(p) for p in peerings]
|
|
307
|
+
if certified:
|
|
308
|
+
d["certified"] = True
|
|
309
|
+
return d
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def slug(text: str) -> str:
|
|
313
|
+
"""A safe fragment/lesson id: lowercase, spaces→hyphens, only [a-z0-9-]. Ids travel in URL routes
|
|
314
|
+
(e.g. /lessons/<id>/pack, matched by [\\w-]+), so a space or punctuation would break the fetch. A
|
|
315
|
+
teacher types 'Simple LAN'; we store 'simple-lan'."""
|
|
316
|
+
import re
|
|
317
|
+
s = re.sub(r"[^a-z0-9]+", "-", (text or "").strip().lower()).strip("-")
|
|
318
|
+
return s or "fragment"
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _clean_obj(o: dict) -> dict:
|
|
322
|
+
out = {"id": o["id"], "say": o.get("say", o["id"])}
|
|
323
|
+
if o.get("kind", "structural") != "structural":
|
|
324
|
+
out["kind"] = o["kind"]
|
|
325
|
+
if o.get("check"):
|
|
326
|
+
out["check"] = o["check"]
|
|
327
|
+
if o.get("probe"):
|
|
328
|
+
out["probe"] = o["probe"]
|
|
329
|
+
if o.get("level"):
|
|
330
|
+
out["level"] = o["level"]
|
|
331
|
+
if o.get("stars"):
|
|
332
|
+
out["stars"] = int(o["stars"])
|
|
333
|
+
return out
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def validate_dict(d: dict) -> list[str]:
|
|
337
|
+
"""Problems that would make the authored fragment unloadable (empty = good)."""
|
|
338
|
+
from . import fragment_yaml as _fy
|
|
339
|
+
return _fy.validate(_fy.fragment_from_dict(d))
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def save_fragment(d: dict) -> str:
|
|
343
|
+
"""Write a blessed fragment to the user content layer. Returns the path. Raises if it doesn't
|
|
344
|
+
validate — we never write an ungradable fragment to disk."""
|
|
345
|
+
from . import content as _content
|
|
346
|
+
from . import fragment_yaml as _fy
|
|
347
|
+
problems = validate_dict(d)
|
|
348
|
+
if problems:
|
|
349
|
+
raise ValueError("; ".join(problems))
|
|
350
|
+
frag = _fy.fragment_from_dict(d)
|
|
351
|
+
path = _content.ensure_user_content_dir() / f"{frag.id}.yaml"
|
|
352
|
+
path.write_text(_fy.to_yaml(frag), encoding="utf-8")
|
|
353
|
+
return str(path)
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
"""DEPRECATED — the requirement chip / auto-build Wizard was replaced by the goal-guided
|
|
2
|
+
Wizard (see ``domain/missions.py``). This module is intentionally empty and can be
|
|
3
|
+
deleted; it is kept only because the sandbox can't remove files (delete on the host:
|
|
4
|
+
``git rm src/gini/domain/blueprints.py``).
|
|
5
|
+
"""
|