gini-toolkit 6.0.1.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gini/__init__.py +12 -0
- gini/__main__.py +107 -0
- gini/_version.py +24 -0
- gini/agent/__init__.py +17 -0
- gini/agent/agent_gamemaster.py +140 -0
- gini/agent/api.py +291 -0
- gini/agent/ask.py +123 -0
- gini/agent/authoring.py +72 -0
- gini/agent/blackboard.py +114 -0
- gini/agent/contracts.py +142 -0
- gini/agent/domains.py +91 -0
- gini/agent/embed.py +123 -0
- gini/agent/gamemaster.py +256 -0
- gini/agent/kb.py +148 -0
- gini/agent/lesson_resolver.py +261 -0
- gini/agent/llm/__init__.py +5 -0
- gini/agent/llm/backend.py +43 -0
- gini/agent/llm/fake.py +25 -0
- gini/agent/llm/ollama.py +206 -0
- gini/agent/loop.py +258 -0
- gini/agent/mcp_server.py +86 -0
- gini/agent/meaning.py +225 -0
- gini/agent/mission.py +210 -0
- gini/agent/mission_controller.py +208 -0
- gini/agent/narration.py +116 -0
- gini/agent/notifier.py +86 -0
- gini/agent/personas.py +79 -0
- gini/agent/reasoning.py +172 -0
- gini/agent/recall.py +248 -0
- gini/agent/session.py +79 -0
- gini/agent/teaching_center.py +482 -0
- gini/agent/tools/__init__.py +3 -0
- gini/agent/tools/registry.py +193 -0
- gini/agent/twin/__init__.py +28 -0
- gini/agent/twin/authoring.py +71 -0
- gini/agent/twin/contracts.py +54 -0
- gini/agent/twin/dialectic.py +189 -0
- gini/agent/twin/harness.py +93 -0
- gini/agent/twin/justify.py +156 -0
- gini/agent/twin/learner.py +64 -0
- gini/agent/twin/mission.py +60 -0
- gini/agent/twin/os_coach.py +79 -0
- gini/agent/twin/salience.py +30 -0
- gini/agent/understand.py +250 -0
- gini/agent/verifiers.py +106 -0
- gini/agent/wizard.py +178 -0
- gini/agent/xv6_pack.py +74 -0
- gini/app/__init__.py +3 -0
- gini/app/context.py +368 -0
- gini/app/paths.py +121 -0
- gini/data/README.md +21 -0
- gini/domain/__init__.py +9 -0
- gini/domain/assembly.py +209 -0
- gini/domain/authoring.py +353 -0
- gini/domain/blueprints.py +5 -0
- gini/domain/capabilities.py +177 -0
- gini/domain/catalog.py +85 -0
- gini/domain/certify.py +201 -0
- gini/domain/compose.py +413 -0
- gini/domain/composition.py +88 -0
- gini/domain/concepts.py +383 -0
- gini/domain/connection_rules.py +269 -0
- gini/domain/constraints.py +153 -0
- gini/domain/content.py +59 -0
- gini/domain/cpu_journey.py +89 -0
- gini/domain/devices.py +747 -0
- gini/domain/diagnose.py +201 -0
- gini/domain/element_guide.py +327 -0
- gini/domain/explain.py +90 -0
- gini/domain/fingerprint.py +201 -0
- gini/domain/firewall.py +34 -0
- gini/domain/flowlog.py +61 -0
- gini/domain/flowtable.py +179 -0
- gini/domain/fragment_yaml.py +230 -0
- gini/domain/fragments.py +169 -0
- gini/domain/games/__init__.py +2 -0
- gini/domain/games/paging_games.py +119 -0
- gini/domain/games/policy_game.py +86 -0
- gini/domain/games/process_game.py +48 -0
- gini/domain/games/thrash_game.py +75 -0
- gini/domain/games/translate_game.py +60 -0
- gini/domain/games/trap_game.py +86 -0
- gini/domain/grader.py +155 -0
- gini/domain/grouping.py +67 -0
- gini/domain/legality.py +103 -0
- gini/domain/lesson.py +241 -0
- gini/domain/lexicon.py +150 -0
- gini/domain/machine_state.py +410 -0
- gini/domain/missions/networking/basic-lan.yaml +32 -0
- gini/domain/missions/networking/cache-in-front.yaml +23 -0
- gini/domain/missions/networking/decouple-with-queue.yaml +31 -0
- gini/domain/missions/networking/drive-load.yaml +20 -0
- gini/domain/missions/networking/fix-the-address.yaml +75 -0
- gini/domain/missions/networking/fix-the-lan.yaml +43 -0
- gini/domain/missions/networking/inspect-flows.yaml +16 -0
- gini/domain/missions/networking/k8s-autoscale.yaml +27 -0
- gini/domain/missions/networking/least-privilege.yaml +21 -0
- gini/domain/missions/networking/load-balanced-web.yaml +29 -0
- gini/domain/missions/networking/observe-it.yaml +24 -0
- gini/domain/missions/networking/put-in-vpc.yaml +30 -0
- gini/domain/missions/networking/reachability-boundary.yaml +56 -0
- gini/domain/missions/networking/sdn-reactive.yaml +35 -0
- gini/domain/missions/networking/send-request.yaml +19 -0
- gini/domain/missions/networking/serverless-api.yaml +25 -0
- gini/domain/missions/networking/service-chain.yaml +33 -0
- gini/domain/missions/os/lottery-fix.yaml +19 -0
- gini/domain/missions/os/priority-fix.yaml +24 -0
- gini/domain/missions.py +111 -0
- gini/domain/modulechain.py +36 -0
- gini/domain/objectives.py +488 -0
- gini/domain/os_zoo.py +79 -0
- gini/domain/paging_sim.py +141 -0
- gini/domain/pricing.py +199 -0
- gini/domain/probes.py +226 -0
- gini/domain/profile.py +142 -0
- gini/domain/recipes.py +738 -0
- gini/domain/riders.py +309 -0
- gini/domain/router_modules.py +224 -0
- gini/domain/routetable.py +67 -0
- gini/domain/scoring.py +76 -0
- gini/domain/staging.py +122 -0
- gini/domain/syscall_builder.py +144 -0
- gini/domain/topic_cloud.py +62 -0
- gini/domain/topology.py +213 -0
- gini/domain/vocabulary.py +51 -0
- gini/domain/xv6.py +808 -0
- gini/domain/xv6_fs.py +250 -0
- gini/domain/xv6_runner.py +113 -0
- gini/domain/xv6_vm.py +385 -0
- gini/gloader.py +17 -0
- gini/runtime/__init__.py +18 -0
- gini/runtime/cloudfabric_agent.py +370 -0
- gini/runtime/console.py +68 -0
- gini/runtime/control.py +70 -0
- gini/runtime/frame.py +138 -0
- gini/runtime/gbridge.py +638 -0
- gini/runtime/grouter.py +223 -0
- gini/runtime/hostsim.py +90 -0
- gini/runtime/shuttle.py +348 -0
- gini/runtime/switch.py +109 -0
- gini/runtime/transport.py +77 -0
- gini/runtime/xv6_bridge.py +312 -0
- gini/server/__init__.py +22 -0
- gini/server/__main__.py +74 -0
- gini/server/app.py +140 -0
- gini/server/auth.py +82 -0
- gini/server/policy.py +57 -0
- gini/server/session.py +23 -0
- gini/services/__init__.py +15 -0
- gini/services/boardflash.py +248 -0
- gini/services/boardsetup.py +374 -0
- gini/services/cloud_catalog.py +143 -0
- gini/services/compiler.py +1858 -0
- gini/services/discovery.py +324 -0
- gini/services/gloader.py +183 -0
- gini/services/orchestrator.py +1460 -0
- gini/services/persistence.py +28 -0
- gini/services/probe_runner.py +149 -0
- gini/services/project.py +217 -0
- gini/services/remote.py +93 -0
- gini/services/rider_runner.py +96 -0
- gini/services/rider_session.py +171 -0
- gini/services/shadow_store.py +52 -0
- gini/services/terminal.py +45 -0
- gini/setup/__init__.py +17 -0
- gini/setup/cli.py +109 -0
- gini/setup/images.py +33 -0
- gini/setup/marker.py +43 -0
- gini/setup/runtime.py +69 -0
- gini/ui/__init__.py +3 -0
- gini/ui/assets/app_icon.icns +0 -0
- gini/ui/assets/app_icon.ico +0 -0
- gini/ui/assets/app_icon.png +0 -0
- gini/ui/assets/app_icon_1024.png +0 -0
- gini/ui/assets/cue/_w.txt +1 -0
- gini/ui/assets/cue/ai.png +0 -0
- gini/ui/assets/cue/canvas.png +0 -0
- gini/ui/assets/cue/cloud.png +0 -0
- gini/ui/assets/cue/cost.png +0 -0
- gini/ui/assets/cue/dark/ai.png +0 -0
- gini/ui/assets/cue/dark/canvas.png +0 -0
- gini/ui/assets/cue/dark/cloud.png +0 -0
- gini/ui/assets/cue/dark/cost.png +0 -0
- gini/ui/assets/cue/dark/metrics.png +0 -0
- gini/ui/assets/cue/dark/router.png +0 -0
- gini/ui/assets/cue/dark/run.png +0 -0
- gini/ui/assets/cue/dark/serverless.png +0 -0
- gini/ui/assets/cue/dark/settings.png +0 -0
- gini/ui/assets/cue/dark/welcome.png +0 -0
- gini/ui/assets/cue/dark/wizard.png +0 -0
- gini/ui/assets/cue/ginibrand/ai.png +0 -0
- gini/ui/assets/cue/ginibrand/canvas.png +0 -0
- gini/ui/assets/cue/ginibrand/cloud.png +0 -0
- gini/ui/assets/cue/ginibrand/cost.png +0 -0
- gini/ui/assets/cue/ginibrand/metrics.png +0 -0
- gini/ui/assets/cue/ginibrand/router.png +0 -0
- gini/ui/assets/cue/ginibrand/run.png +0 -0
- gini/ui/assets/cue/ginibrand/serverless.png +0 -0
- gini/ui/assets/cue/ginibrand/settings.png +0 -0
- gini/ui/assets/cue/ginibrand/welcome.png +0 -0
- gini/ui/assets/cue/ginibrand/wizard.png +0 -0
- gini/ui/assets/cue/highcontrast/ai.png +0 -0
- gini/ui/assets/cue/highcontrast/canvas.png +0 -0
- gini/ui/assets/cue/highcontrast/cloud.png +0 -0
- gini/ui/assets/cue/highcontrast/cost.png +0 -0
- gini/ui/assets/cue/highcontrast/metrics.png +0 -0
- gini/ui/assets/cue/highcontrast/router.png +0 -0
- gini/ui/assets/cue/highcontrast/run.png +0 -0
- gini/ui/assets/cue/highcontrast/serverless.png +0 -0
- gini/ui/assets/cue/highcontrast/settings.png +0 -0
- gini/ui/assets/cue/highcontrast/welcome.png +0 -0
- gini/ui/assets/cue/highcontrast/wizard.png +0 -0
- gini/ui/assets/cue/light/ai.png +0 -0
- gini/ui/assets/cue/light/canvas.png +0 -0
- gini/ui/assets/cue/light/cloud.png +0 -0
- gini/ui/assets/cue/light/cost.png +0 -0
- gini/ui/assets/cue/light/metrics.png +0 -0
- gini/ui/assets/cue/light/router.png +0 -0
- gini/ui/assets/cue/light/run.png +0 -0
- gini/ui/assets/cue/light/serverless.png +0 -0
- gini/ui/assets/cue/light/settings.png +0 -0
- gini/ui/assets/cue/light/welcome.png +0 -0
- gini/ui/assets/cue/light/wizard.png +0 -0
- gini/ui/assets/cue/metrics.png +0 -0
- gini/ui/assets/cue/router.png +0 -0
- gini/ui/assets/cue/run.png +0 -0
- gini/ui/assets/cue/serverless.png +0 -0
- gini/ui/assets/cue/settings.png +0 -0
- gini/ui/assets/cue/welcome.png +0 -0
- gini/ui/assets/cue/wizard.png +0 -0
- gini/ui/assistant.py +2111 -0
- gini/ui/author_dialog.py +184 -0
- gini/ui/board_dialog.py +247 -0
- gini/ui/branding.py +21 -0
- gini/ui/canvas.py +2007 -0
- gini/ui/chat_panel.py +7 -0
- gini/ui/cpu_journey.py +212 -0
- gini/ui/cpu_lab.py +306 -0
- gini/ui/cue_cards.py +214 -0
- gini/ui/dashboard.py +222 -0
- gini/ui/diagnose_game.py +336 -0
- gini/ui/fingerprint_lab.py +219 -0
- gini/ui/flash_dialog.py +244 -0
- gini/ui/flow_layout.py +63 -0
- gini/ui/fragment_manager.py +1415 -0
- gini/ui/game_catalog.py +184 -0
- gini/ui/game_renderers.py +340 -0
- gini/ui/games_lab.py +90 -0
- gini/ui/inspector.py +1055 -0
- gini/ui/live_metrics.py +130 -0
- gini/ui/machine_lab.py +1412 -0
- gini/ui/main_window.py +3153 -0
- gini/ui/memory_lab.py +371 -0
- gini/ui/mission_panel.py +302 -0
- gini/ui/mode_indicator.py +227 -0
- gini/ui/palette.py +112 -0
- gini/ui/peripherals.py +218 -0
- gini/ui/process_tree.py +130 -0
- gini/ui/reset_dialog.py +179 -0
- gini/ui/router_lab.py +776 -0
- gini/ui/run_button.py +183 -0
- gini/ui/settings_dialog.py +234 -0
- gini/ui/signin_dialog.py +111 -0
- gini/ui/storage_lab.py +219 -0
- gini/ui/syscall_builder.py +235 -0
- gini/ui/syscall_lab.py +152 -0
- gini/ui/theme/__init__.py +5 -0
- gini/ui/theme/icons.py +145 -0
- gini/ui/theme/manager.py +291 -0
- gini/ui/theme/tokens.py +194 -0
- gini/ui/trap_lab.py +270 -0
- gini/ui/worker_host.py +102 -0
- gini/ui/zoo_lab.py +112 -0
- gini_toolkit-6.0.1.dev0.dist-info/METADATA +77 -0
- gini_toolkit-6.0.1.dev0.dist-info/RECORD +278 -0
- gini_toolkit-6.0.1.dev0.dist-info/WHEEL +5 -0
- gini_toolkit-6.0.1.dev0.dist-info/entry_points.txt +3 -0
- gini_toolkit-6.0.1.dev0.dist-info/top_level.txt +1 -0
gini/agent/narration.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Mission narration — the LLM writes the story, GINI checks it against the truth.
|
|
2
|
+
|
|
3
|
+
A mission has two faces. The **objectives** are the truth: they come from verified fragments, they
|
|
4
|
+
are what gets graded, and the model never writes them. The **title and description** are the story
|
|
5
|
+
told about that truth — and prose is exactly what a small model is good at.
|
|
6
|
+
|
|
7
|
+
The failure this module exists to prevent: the model narrating a mission that isn't the one being
|
|
8
|
+
graded (a "decouple your web tier with a message queue" title sitting on top of VPC-isolation
|
|
9
|
+
objectives). Note that title and brief agreeing with *each other* proves nothing — they're written
|
|
10
|
+
in one breath, so they always agree. The only agreement worth checking is narrative ⟷ objectives.
|
|
11
|
+
|
|
12
|
+
So we check it, deterministically:
|
|
13
|
+
|
|
14
|
+
the elements the objectives grade = the only elements the prose may claim
|
|
15
|
+
|
|
16
|
+
If the story mentions a queue and no objective grades a queue, the story is false — reject it and
|
|
17
|
+
ask again, or fall back to the fragment's own summary. The model gets to be eloquent; it does not
|
|
18
|
+
get to be wrong. No LLM is asked to police itself.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import re
|
|
23
|
+
|
|
24
|
+
from ..domain import devices as _devices
|
|
25
|
+
from ..domain import objectives as _obj
|
|
26
|
+
from ..domain import probes as _probes
|
|
27
|
+
|
|
28
|
+
# words that name an element but are also ordinary English — only count them as a CLAIM about an
|
|
29
|
+
# element when the objectives don't already license them (checked below), never as free-text.
|
|
30
|
+
_STOP = {"internet", "cloud", "network", "host", "machine", "function", "gateway"}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def graded_types(lesson) -> set[str]:
|
|
34
|
+
"""Every element type the lesson's objectives actually grade — the licensed vocabulary."""
|
|
35
|
+
out: set[str] = set()
|
|
36
|
+
for o in lesson.objectives:
|
|
37
|
+
if o.check:
|
|
38
|
+
out.update(_obj.element_types_in_check(o.check))
|
|
39
|
+
if o.probe:
|
|
40
|
+
try:
|
|
41
|
+
p = _probes.parse(o.probe)
|
|
42
|
+
except _probes.ProbeError:
|
|
43
|
+
continue
|
|
44
|
+
out.update(x for x in (p.src, p.dst) if x)
|
|
45
|
+
return {t for t in out if _devices.get(t) is not None}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _phrases() -> list[tuple[str, str]]:
|
|
49
|
+
"""(phrase, type_key) for every element, longest phrase first so 'load balancer' beats 'balancer'."""
|
|
50
|
+
out: list[tuple[str, str]] = []
|
|
51
|
+
for dt in _devices.all_devices():
|
|
52
|
+
out.append((dt.label.lower(), dt.key))
|
|
53
|
+
out.append((dt.key.replace("_", " ").lower(), dt.key))
|
|
54
|
+
return sorted(set(out), key=lambda p: -len(p[0]))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def false_claims(text: str, allowed: set[str]) -> list[str]:
|
|
58
|
+
"""Element types the prose names that NO objective grades. These are the lies."""
|
|
59
|
+
low = " " + re.sub(r"[^a-z0-9 ]+", " ", (text or "").lower()) + " "
|
|
60
|
+
bad: list[str] = []
|
|
61
|
+
for phrase, key in _phrases():
|
|
62
|
+
if key in allowed or key in bad:
|
|
63
|
+
continue
|
|
64
|
+
if phrase in _STOP:
|
|
65
|
+
continue # too ordinary to read as a claim about an element
|
|
66
|
+
if re.search(rf"\b{re.escape(phrase)}s?\b", low):
|
|
67
|
+
bad.append(key)
|
|
68
|
+
return bad
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
_PROMPT = (
|
|
72
|
+
"You are writing the brief for a hands-on lab mission a student is about to play.\n\n"
|
|
73
|
+
"The student asked for: {intent!r}\n\n"
|
|
74
|
+
"These are the EXACT tasks they will be graded on — the mission IS these and nothing else:\n"
|
|
75
|
+
"{tasks}\n\n"
|
|
76
|
+
"Write:\n"
|
|
77
|
+
" title — a SHORT name, at most 6 words, no trailing period. Name the idea, not the steps.\n"
|
|
78
|
+
" description — 3 to 4 sentences. Say what they're building, WHY an engineer would want it, "
|
|
79
|
+
"and what 'done' looks like. Speak to the student as 'you'. Do not number the tasks or repeat "
|
|
80
|
+
"them one by one — they can already see them.\n\n"
|
|
81
|
+
"HARD RULE: mention ONLY these components: {allowed}. Do not mention any other technology "
|
|
82
|
+
"(no queues, caches, dashboards, load balancers, etc. unless listed). Naming something that "
|
|
83
|
+
"isn't in that list makes the brief WRONG.\n\n"
|
|
84
|
+
'Reply ONLY as JSON: {{"title": "...", "description": "..."}}'
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def narrate(lesson, intent: str, llm, *, retries: int = 1) -> tuple[str, str]:
|
|
89
|
+
"""Ask the model for a short title + a longer description, and REFUSE anything that claims an
|
|
90
|
+
element the objectives don't grade. Returns ("", "") if the model can't tell the truth — the
|
|
91
|
+
caller then falls back to the fragment's own summary, which is always true by construction."""
|
|
92
|
+
from .personas import first_json
|
|
93
|
+
allowed = graded_types(lesson)
|
|
94
|
+
if not allowed or llm is None:
|
|
95
|
+
return "", ""
|
|
96
|
+
labels = sorted((_devices.get(t).label for t in allowed), key=str.lower)
|
|
97
|
+
tasks = "\n".join(f" - {o.say}" for o in lesson.objectives)
|
|
98
|
+
prompt = _PROMPT.format(intent=intent, tasks=tasks, allowed=", ".join(labels))
|
|
99
|
+
|
|
100
|
+
for _ in range(retries + 1):
|
|
101
|
+
try:
|
|
102
|
+
obj = first_json(llm(prompt))
|
|
103
|
+
except Exception: # noqa: BLE001
|
|
104
|
+
return "", ""
|
|
105
|
+
if not isinstance(obj, dict):
|
|
106
|
+
continue
|
|
107
|
+
title = " ".join(str(obj.get("title", "")).split()).strip(" .")
|
|
108
|
+
desc = " ".join(str(obj.get("description", "")).split())
|
|
109
|
+
if not title or not desc:
|
|
110
|
+
continue
|
|
111
|
+
if len(title.split()) > 8: # "short" is part of the brief
|
|
112
|
+
continue
|
|
113
|
+
if false_claims(title, allowed) or false_claims(desc, allowed):
|
|
114
|
+
continue # it narrated a mission we aren't grading
|
|
115
|
+
return title, desc
|
|
116
|
+
return "", ""
|
gini/agent/notifier.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Notification / salience — the deterministic layer that decides which changes are worth waking the
|
|
2
|
+
Reasoning persona (GINI_MISSIONS_AGENT_ARCHITECTURE.md §3-4). Salience is **rules-only** (decided): a
|
|
3
|
+
fixed table, no LLM on the hot path. Input = the verdicts that FLIPPED on the last blackboard update
|
|
4
|
+
(plus explicit events like a student question or a run completing); output = salient Notifications.
|
|
5
|
+
|
|
6
|
+
`MissionMonitor` ties the deterministic swarm together: world change → blackboard re-verify → salient
|
|
7
|
+
notifications. It's the seam the live UI and the Reasoning persona both sit against.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from .blackboard import Blackboard
|
|
12
|
+
from .contracts import Notification
|
|
13
|
+
|
|
14
|
+
# rules-only salience table (0..1). Tune the RULES if noisy — never add a model here.
|
|
15
|
+
SALIENCE = {
|
|
16
|
+
"objective_met": 0.6,
|
|
17
|
+
"objective_unmet": 0.4,
|
|
18
|
+
"off_task_added": 0.9,
|
|
19
|
+
"off_task_cleared": 0.3,
|
|
20
|
+
"illegal_link_added": 0.9,
|
|
21
|
+
"illegal_link_cleared": 0.3,
|
|
22
|
+
"forbid_tripped": 1.0,
|
|
23
|
+
"forbid_cleared": 0.3,
|
|
24
|
+
"mission_complete": 1.0,
|
|
25
|
+
"question": 1.0,
|
|
26
|
+
"run_complete": 0.7,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _note(change: str, subjects=(), data=None) -> Notification:
|
|
31
|
+
return Notification(change, subjects=tuple(subjects), salience=SALIENCE.get(change, 0.5), data=data)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def notifications_from_flips(flipped, blackboard: Blackboard) -> list[Notification]:
|
|
35
|
+
"""Map flipped verdicts → salient notifications by rule. `flipped` is what `Blackboard.update`
|
|
36
|
+
returned (verdicts whose value changed or newly appeared)."""
|
|
37
|
+
notes: list[Notification] = []
|
|
38
|
+
for v in flipped:
|
|
39
|
+
if v.verifier_id.startswith("objective:"):
|
|
40
|
+
notes.append(_note("objective_met" if v.value else "objective_unmet", subjects=(v.subject,)))
|
|
41
|
+
elif v.subject == "off_task":
|
|
42
|
+
data = list(v.evidence.data) if v.evidence else []
|
|
43
|
+
notes.append(_note("off_task_cleared" if v.value else "off_task_added",
|
|
44
|
+
subjects=tuple(data), data=data))
|
|
45
|
+
elif v.subject == "illegal_links":
|
|
46
|
+
data = list(v.evidence.data) if v.evidence else []
|
|
47
|
+
notes.append(_note("illegal_link_cleared" if v.value else "illegal_link_added",
|
|
48
|
+
subjects=tuple(data), data=data))
|
|
49
|
+
elif v.subject.startswith("forbid:"):
|
|
50
|
+
say = v.evidence.data if v.evidence else ""
|
|
51
|
+
notes.append(_note("forbid_cleared" if v.value else "forbid_tripped",
|
|
52
|
+
subjects=(v.subject,), data=say))
|
|
53
|
+
# a completion is derived from the whole board, not a single flip
|
|
54
|
+
if flipped and blackboard.all_objectives_met():
|
|
55
|
+
notes.append(_note("mission_complete"))
|
|
56
|
+
return notes
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def top(notes) -> Notification | None:
|
|
60
|
+
"""The single most salient notification (what the reasoner should react to first), or None."""
|
|
61
|
+
return max(notes, key=lambda n: n.salience) if notes else None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class MissionMonitor:
|
|
65
|
+
"""The deterministic swarm, packaged: keeps a blackboard current and emits salient notifications
|
|
66
|
+
on every world change. The Reasoning persona reads the blackboard; this tells it when to wake."""
|
|
67
|
+
|
|
68
|
+
def __init__(self) -> None:
|
|
69
|
+
self.bb = Blackboard()
|
|
70
|
+
|
|
71
|
+
def load(self, lesson, *, pack=None, runner=None) -> None:
|
|
72
|
+
verifiers = pack.verifiers(lesson) if pack is not None else None
|
|
73
|
+
self.bb.load_lesson(lesson, verifiers=verifiers, runner=runner)
|
|
74
|
+
|
|
75
|
+
def clear(self) -> None:
|
|
76
|
+
self.bb.clear()
|
|
77
|
+
|
|
78
|
+
def on_world_change(self, topology, *, changed: set[str] | None = None) -> list[Notification]:
|
|
79
|
+
flipped = self.bb.update(topology, changed=changed)
|
|
80
|
+
return notifications_from_flips(flipped, self.bb)
|
|
81
|
+
|
|
82
|
+
def on_question(self, text: str) -> Notification:
|
|
83
|
+
return _note("question", data=text)
|
|
84
|
+
|
|
85
|
+
def on_run_complete(self) -> Notification:
|
|
86
|
+
return _note("run_complete")
|
gini/agent/personas.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""One small model, many narrow personas (GINI_MISSIONS_AGENT_ARCHITECTURE.md §5). A Persona is a
|
|
2
|
+
first-class object — a "you do ONLY this" system prompt, decoding params, and a stateful flag. The
|
|
3
|
+
PersonaRunner is the single, clean-isolation call site: it builds a prompt from exactly
|
|
4
|
+
`system + context + task` and carries NOTHING else across a call (the defence against persona-leak).
|
|
5
|
+
|
|
6
|
+
State lives on the blackboard (the shared curated memory), never inside the runner — so the runner is
|
|
7
|
+
stateless and any persona's "history" is reconstructed per call from the memory slice it's handed.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import inspect
|
|
12
|
+
import json
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def first_json(text: str):
|
|
17
|
+
"""The first balanced {...} object in a model reply, parsed — or None. Small models wrap JSON in
|
|
18
|
+
prose; this is the tolerant extractor every persona uses to read structured output."""
|
|
19
|
+
depth, start = 0, None
|
|
20
|
+
for i, ch in enumerate(text or ""):
|
|
21
|
+
if ch == "{":
|
|
22
|
+
if depth == 0:
|
|
23
|
+
start = i
|
|
24
|
+
depth += 1
|
|
25
|
+
elif ch == "}" and depth > 0:
|
|
26
|
+
depth -= 1
|
|
27
|
+
if depth == 0 and start is not None:
|
|
28
|
+
try:
|
|
29
|
+
return json.loads(text[start:i + 1])
|
|
30
|
+
except json.JSONDecodeError:
|
|
31
|
+
start = None
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, eq=False)
|
|
36
|
+
class Persona:
|
|
37
|
+
name: str
|
|
38
|
+
system: str # "you do ONLY this" — no general-assistant framing
|
|
39
|
+
temperature: float = 0.3
|
|
40
|
+
stateful: bool = False # only the Reasoning persona is stateful (reads shared memory)
|
|
41
|
+
schema: dict | None = None # optional JSON Schema → decoder-constrained output (Reasoning 2.0)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class PersonaRunner:
|
|
45
|
+
"""Invokes personas on one injected model. `llm` is `llm(prompt) -> str`, optionally accepting
|
|
46
|
+
`temperature=` and/or `schema=` keyword args (the runner passes each only when the callable
|
|
47
|
+
accepts it — so simple lambdas in tests and older backends keep working unchanged)."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, llm) -> None:
|
|
50
|
+
self._llm = llm
|
|
51
|
+
self._accepts_temp = False
|
|
52
|
+
self._accepts_schema = False
|
|
53
|
+
try:
|
|
54
|
+
params = inspect.signature(llm).parameters
|
|
55
|
+
self._accepts_temp = "temperature" in params
|
|
56
|
+
self._accepts_schema = "schema" in params
|
|
57
|
+
except (TypeError, ValueError):
|
|
58
|
+
pass
|
|
59
|
+
self.last_prompt = "" # for tests / debugging — the exact text last sent
|
|
60
|
+
|
|
61
|
+
def call(self, persona: Persona, *, context: str = "", task: str = "") -> str:
|
|
62
|
+
parts = [persona.system]
|
|
63
|
+
if context:
|
|
64
|
+
parts.append(context)
|
|
65
|
+
if task:
|
|
66
|
+
parts.append(task)
|
|
67
|
+
prompt = "\n\n".join(p for p in parts if p).strip()
|
|
68
|
+
self.last_prompt = prompt
|
|
69
|
+
if self._llm is None:
|
|
70
|
+
return ""
|
|
71
|
+
kwargs = {}
|
|
72
|
+
if self._accepts_temp:
|
|
73
|
+
kwargs["temperature"] = persona.temperature
|
|
74
|
+
if self._accepts_schema and persona.schema is not None:
|
|
75
|
+
kwargs["schema"] = persona.schema
|
|
76
|
+
try:
|
|
77
|
+
return (self._llm(prompt, **kwargs) or "").strip()
|
|
78
|
+
except Exception:
|
|
79
|
+
return ""
|
gini/agent/reasoning.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""The Reasoning persona — the centre of the loop (GINI_MISSIONS_AGENT_ARCHITECTURE.md §3,5,6). It
|
|
2
|
+
reads TRUTH from the blackboard (never recomputes it), the mission intent, and the shared curated
|
|
3
|
+
memory, then reasons out the next move for whatever woke it (a salient Notification or a student
|
|
4
|
+
Intent). The deterministic layer decides *when* to wake it and supplies the facts; the wording and the
|
|
5
|
+
pedagogy are entirely the model's.
|
|
6
|
+
|
|
7
|
+
Grounding is by construction: the prompt carries the live objective/legality facts, so the answer is
|
|
8
|
+
about the student's actual board — not canned. A non-LLM fallback keeps it usable offline (degraded).
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from .contracts import Intent, Move, Notification
|
|
13
|
+
from .personas import Persona, PersonaRunner, first_json
|
|
14
|
+
|
|
15
|
+
_SYSTEM = (
|
|
16
|
+
"You are the game master of a hands-on lab mission — warm, concise, and in character. You ONLY "
|
|
17
|
+
"produce the next short line to the student (1-2 sentences). Ground every statement in the FACTS "
|
|
18
|
+
"you are given; never invent devices, connections, or states that aren't listed. If the facts "
|
|
19
|
+
"don't cover the question, say what IS known and stop — don't guess.")
|
|
20
|
+
|
|
21
|
+
REASONING = Persona("Reasoning", system=_SYSTEM, temperature=0.35, stateful=True)
|
|
22
|
+
|
|
23
|
+
# The covered variant (Reasoning 2.0): same voice, but the reply is ONE JSON object carrying the
|
|
24
|
+
# line plus a coverage report the Twin diffs exactly. Decoder-constrained where the backend
|
|
25
|
+
# supports structured outputs (schema); the tolerant first_json path handles the rest.
|
|
26
|
+
def _covered_persona():
|
|
27
|
+
from .twin.dialectic import COVERAGE_SCHEMA
|
|
28
|
+
return Persona("Reasoning", system=_SYSTEM, temperature=0.35, stateful=True,
|
|
29
|
+
schema=COVERAGE_SCHEMA)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
REASONING_COVERED = None # built lazily (avoids a twin import at module load)
|
|
33
|
+
|
|
34
|
+
# how a triggering change maps to the SHAPE of the move (routing, not reasoning — the model still
|
|
35
|
+
# writes the content)
|
|
36
|
+
_MOVE_KIND = {
|
|
37
|
+
"mission_complete": "advance", "objective_met": "say", "objective_unmet": "hint",
|
|
38
|
+
"off_task_added": "flag", "illegal_link_added": "flag", "forbid_tripped": "flag",
|
|
39
|
+
"question": "answer",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ReasoningAgent:
|
|
44
|
+
def __init__(self, runner: PersonaRunner, blackboard, lesson) -> None:
|
|
45
|
+
self.runner = runner
|
|
46
|
+
self.bb = blackboard
|
|
47
|
+
self.lesson = lesson
|
|
48
|
+
self.last_coverage = None # the last covered turn's report (None = coverage-silent)
|
|
49
|
+
|
|
50
|
+
# -- grounding: turn the blackboard's verdicts into a compact fact sheet -- #
|
|
51
|
+
def _situation(self) -> str:
|
|
52
|
+
say = {o.id: o.say for o in self.lesson.objectives}
|
|
53
|
+
met = [say.get(s, s) for s in
|
|
54
|
+
(v.subject for v in self.bb.verdicts() if v.verifier_id.startswith("objective:") and v.value)]
|
|
55
|
+
unmet_ids = self.bb.unmet_objectives()
|
|
56
|
+
unmet = [say.get(s, s) + self._why(s) for s in unmet_ids] # each open item + WHY it's red
|
|
57
|
+
flags = self.bb.flags()
|
|
58
|
+
lines = [f"Objectives met ({len(met)}/{len(met) + len(unmet)}): " + ("; ".join(met) or "none"),
|
|
59
|
+
"Still open: " + ("; ".join(unmet) or "none — all done")]
|
|
60
|
+
if flags.get("off_task"):
|
|
61
|
+
lines.append("Off-task elements on the board: " + ", ".join(flags["off_task"]))
|
|
62
|
+
if flags.get("illegal_links"):
|
|
63
|
+
lines.append("Illegal connections present.")
|
|
64
|
+
return "\n".join(lines)
|
|
65
|
+
|
|
66
|
+
def _why(self, objective_id: str) -> str:
|
|
67
|
+
"""A deterministic, board-grounded reason an objective is red (the predicate explainer). We
|
|
68
|
+
need the live topology for this; the blackboard caches the world on evaluation."""
|
|
69
|
+
obj = next((o for o in self.lesson.objectives if o.id == objective_id), None)
|
|
70
|
+
world = getattr(self.bb, "_world", None)
|
|
71
|
+
if obj is None or world is None:
|
|
72
|
+
return ""
|
|
73
|
+
try:
|
|
74
|
+
from ..domain import explain as _explain
|
|
75
|
+
reason = _explain.diagnose(obj, world)
|
|
76
|
+
except Exception:
|
|
77
|
+
reason = ""
|
|
78
|
+
return f" — {reason}" if reason else ""
|
|
79
|
+
|
|
80
|
+
def _intent(self) -> str:
|
|
81
|
+
it = self.lesson.intent
|
|
82
|
+
parts = [f"Mission goal: {it.goal}"] if it.goal else []
|
|
83
|
+
if it.spirit:
|
|
84
|
+
parts.append(f"What success means: {it.spirit}")
|
|
85
|
+
if it.misconceptions:
|
|
86
|
+
parts.append("Watch for these misconceptions: " + "; ".join(it.misconceptions))
|
|
87
|
+
# the instructor's own nuance, authored on the Teaching Center and interpreted HERE (this is
|
|
88
|
+
# why the course server needs no LLM — the teacher writes plain language, we reason over it)
|
|
89
|
+
if getattr(it, "notes", ""):
|
|
90
|
+
parts.append(f"Your instructor's guidance for this lab: {it.notes}")
|
|
91
|
+
return "\n".join(parts)
|
|
92
|
+
|
|
93
|
+
def _context(self) -> str:
|
|
94
|
+
return "\n".join(p for p in (self._intent(), "FACTS:\n" + self._situation(),
|
|
95
|
+
self.bb.memory.digest()) if p)
|
|
96
|
+
|
|
97
|
+
# -- the reasoning turn ------------------------------------------------- #
|
|
98
|
+
def react(self, trigger, *, note: str = "", coverage_concerns=None) -> Move:
|
|
99
|
+
"""React to a Notification or a student Intent → one grounded Move. `note` (optional) is a
|
|
100
|
+
critique from the Critic persona used to revise a first draft. `coverage_concerns`
|
|
101
|
+
(Reasoning 2.0) asks the persona to ALSO report coverage against the Twin's concern list;
|
|
102
|
+
the parsed report lands on `self.last_coverage` (None = coverage-silent)."""
|
|
103
|
+
change, task, refs = self._frame(trigger)
|
|
104
|
+
if note:
|
|
105
|
+
task = f"{task}\nA reviewer noted: {note}. Fix that in your line."
|
|
106
|
+
self.last_coverage = None
|
|
107
|
+
if coverage_concerns:
|
|
108
|
+
global REASONING_COVERED
|
|
109
|
+
if REASONING_COVERED is None:
|
|
110
|
+
REASONING_COVERED = _covered_persona()
|
|
111
|
+
from .twin.dialectic import concern_context, coverage_instruction
|
|
112
|
+
# twin-as-context: the concern set rides in the GROUNDING (guaranteed recall shapes
|
|
113
|
+
# the draft), and again as the coverage checklist the exact diff runs against.
|
|
114
|
+
ctx = "\n\n".join(p for p in (self._context(),
|
|
115
|
+
concern_context(coverage_concerns)) if p)
|
|
116
|
+
raw = self.runner.call(REASONING_COVERED, context=ctx,
|
|
117
|
+
task=task + coverage_instruction(coverage_concerns))
|
|
118
|
+
text = self._parse_covered(raw) or self._fallback(change)
|
|
119
|
+
else:
|
|
120
|
+
text = (self.runner.call(REASONING, context=self._context(), task=task)
|
|
121
|
+
or self._fallback(change))
|
|
122
|
+
if not note: # don't double-record on a revision pass
|
|
123
|
+
self._remember(change, trigger)
|
|
124
|
+
return Move(kind=_MOVE_KIND.get(change, "say"), text=text, refs=refs)
|
|
125
|
+
|
|
126
|
+
def _parse_covered(self, raw: str) -> str:
|
|
127
|
+
"""A covered reply -> the tutor line, with the coverage report set aside for the Twin.
|
|
128
|
+
Tolerant: a model that ignored the JSON shape just becomes coverage-silent prose."""
|
|
129
|
+
from .twin.contracts import parse_coverage
|
|
130
|
+
obj = first_json(raw or "")
|
|
131
|
+
if isinstance(obj, dict) and isinstance(obj.get("text"), str):
|
|
132
|
+
self.last_coverage = parse_coverage(obj.get("coverage"))
|
|
133
|
+
return obj["text"].strip()
|
|
134
|
+
return (raw or "").strip()
|
|
135
|
+
|
|
136
|
+
def _frame(self, trigger) -> tuple[str, str, tuple]:
|
|
137
|
+
if isinstance(trigger, Intent):
|
|
138
|
+
return ("question",
|
|
139
|
+
f"The student asks: {trigger.text!r}. Answer using ONLY the facts above, tied to "
|
|
140
|
+
"what they've built.", trigger.refs)
|
|
141
|
+
change = getattr(trigger, "change", "say")
|
|
142
|
+
subs = tuple(getattr(trigger, "subjects", ()) or ())
|
|
143
|
+
tasks = {
|
|
144
|
+
"mission_complete": "The student just completed every objective. Congratulate them in ONE "
|
|
145
|
+
"specific line tied to what they achieved.",
|
|
146
|
+
"objective_met": "The student just satisfied an objective. Say 'warmer' in ONE line, naming "
|
|
147
|
+
"what improved — do NOT reveal the next step.",
|
|
148
|
+
"objective_unmet": "The student moved away from a goal. Nudge in ONE line — no solution.",
|
|
149
|
+
"off_task_added": f"The student placed something off-task ({', '.join(subs)}). Flag it in "
|
|
150
|
+
"ONE in-character line and tell them to remove it — do NOT remove it yourself.",
|
|
151
|
+
"illegal_link_added": "The student made a connection that isn't allowed. Flag it in ONE line "
|
|
152
|
+
"and tell them to rewire it.",
|
|
153
|
+
"forbid_tripped": f"The student tripped a rule that must stay false ({trigger.data}). Flag it "
|
|
154
|
+
"in ONE line.",
|
|
155
|
+
}
|
|
156
|
+
return (change, tasks.get(change, "Give the student ONE short, helpful line for the current "
|
|
157
|
+
"situation."), subs)
|
|
158
|
+
|
|
159
|
+
def _remember(self, change: str, trigger) -> None:
|
|
160
|
+
if change == "question" and isinstance(trigger, Intent):
|
|
161
|
+
self.bb.memory.threads.append(trigger.text)
|
|
162
|
+
elif change == "objective_met":
|
|
163
|
+
self.bb.memory.note_fact("progress: an objective was satisfied")
|
|
164
|
+
elif change in ("off_task_added", "illegal_link_added", "forbid_tripped"):
|
|
165
|
+
self.bb.memory.note_tried(f"made a flagged move ({change})")
|
|
166
|
+
self.bb.memory.arc = self._situation().splitlines()[0]
|
|
167
|
+
|
|
168
|
+
def _fallback(self, change: str) -> str:
|
|
169
|
+
return {"mission_complete": "Nice — every objective is green. You did it!",
|
|
170
|
+
"off_task_added": "That doesn't belong in this mission — take it off the board.",
|
|
171
|
+
"objective_met": "Warmer — that helped.",
|
|
172
|
+
}.get(change, "Keep going — check what's still open above.")
|