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/ask.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Ask GINI orchestration (pure) — turn an Intent + retrieved knowledge into a Plan, and
|
|
2
|
+
assemble the grounded context for the reasoning call.
|
|
3
|
+
|
|
4
|
+
This is the routing matrix from the design, kept Qt-free so it's testable. The assistant
|
|
5
|
+
(Qt side) calls `plan()` to decide what to do and `grounded_context()` to build the block
|
|
6
|
+
the reasoning LLM sees; the actual building (apply_recipe), tool execution, and chat posting
|
|
7
|
+
happen in the assistant.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Plan:
|
|
16
|
+
action: str # reason | build_recipe | execute | diagnose | clarify | chitchat | meta
|
|
17
|
+
recipe_id: str = "" # for build_recipe, or the recipe to offer on a reason
|
|
18
|
+
offer_build: bool = False # reason path: end by offering to build recipe_id
|
|
19
|
+
clarify: str = "" # for clarify
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
_CLARIFY = ("Could you say a bit more? For example, name an element you want explained, "
|
|
23
|
+
"or tell me what you'd like to build.")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def plan(intent, retrieval, *, min_confidence: float = 0.45) -> Plan:
|
|
27
|
+
"""Dispatch by (type x output_form x anchor). See the design's routing matrix."""
|
|
28
|
+
it = intent
|
|
29
|
+
recipe = getattr(retrieval, "recipe", None)
|
|
30
|
+
rid = recipe.id if recipe is not None else ""
|
|
31
|
+
|
|
32
|
+
if it.type == "chitchat":
|
|
33
|
+
return Plan("chitchat")
|
|
34
|
+
if it.type == "meta":
|
|
35
|
+
return Plan("meta")
|
|
36
|
+
|
|
37
|
+
# genuinely ambiguous fragment -> ask one question rather than guess
|
|
38
|
+
if it.confidence < min_confidence and not it.topics and not it.refs:
|
|
39
|
+
return Plan("clarify", clarify=_CLARIFY)
|
|
40
|
+
|
|
41
|
+
# imperative command ("add a router", "connect R1 and S1") -> tool execution
|
|
42
|
+
if it.type == "build" and it.output_form not in ("show", "guide"):
|
|
43
|
+
return Plan("execute")
|
|
44
|
+
|
|
45
|
+
if it.type == "diagnose":
|
|
46
|
+
return Plan("diagnose")
|
|
47
|
+
|
|
48
|
+
# generative "show me / construct / build" with a known pattern
|
|
49
|
+
if it.output_form in ("show", "guide") and recipe is not None:
|
|
50
|
+
if it.anchor == "concept":
|
|
51
|
+
return Plan("build_recipe", recipe_id=rid) # empty canvas -> auto-build
|
|
52
|
+
return Plan("reason", offer_build=True, recipe_id=rid) # populated -> offer first
|
|
53
|
+
|
|
54
|
+
# explain / how_to / tell -> reason, offering the example if we have one
|
|
55
|
+
return Plan("reason", offer_build=recipe is not None, recipe_id=rid)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
_STANCE = {
|
|
59
|
+
"strong": (
|
|
60
|
+
"GROUNDING STANCE — CLOSED WORLD: the GINI knowledge and canvas below fully cover this "
|
|
61
|
+
"question. Answer only from them; every element you name must appear in the elements "
|
|
62
|
+
"list."
|
|
63
|
+
),
|
|
64
|
+
"thin": (
|
|
65
|
+
"GROUNDING STANCE — MOSTLY CLOSED: the KB match here is only partial. Lean on what's "
|
|
66
|
+
"provided; if a detail isn't covered, say so rather than inventing a GINI element."
|
|
67
|
+
),
|
|
68
|
+
"empty": (
|
|
69
|
+
"GROUNDING STANCE — OPEN BUT FENCED: this question falls outside GINI's built-in "
|
|
70
|
+
"topics. You may answer from general knowledge, but do NOT claim any GINI element or "
|
|
71
|
+
"feature exists unless it's in the elements list; if GINI has no element for it, say so "
|
|
72
|
+
"plainly and point to the nearest thing GINI does have."
|
|
73
|
+
),
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def grounding_stance(retrieval, intent=None) -> str:
|
|
78
|
+
"""One directive line telling the reasoner how tightly to stay on the GINI KB this turn,
|
|
79
|
+
derived from retrieval strength. The 'never invent a GINI element' rule holds in EVERY
|
|
80
|
+
tier — only the freedom to reason from general knowledge widens when the KB is thin."""
|
|
81
|
+
# no retrieval at all → default to the closed-world floor (keep the student on GINI); the
|
|
82
|
+
# open stance is only chosen when retrieval explicitly reports an empty/off-topic match.
|
|
83
|
+
strength = getattr(retrieval, "strength", "strong") if retrieval is not None else "strong"
|
|
84
|
+
return _STANCE.get(strength, _STANCE["strong"])
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def grounded_context(always_on: str, accumulator: str, retrieval, canvas_digest: str,
|
|
88
|
+
intent, machine_card: str = "", stance: str = "") -> str:
|
|
89
|
+
"""Assemble the context block the reasoning LLM sees: grounding stance + always-on index +
|
|
90
|
+
canvas + live machine (xv6) state + accumulated session knowledge + this turn's cards.
|
|
91
|
+
|
|
92
|
+
`machine_card` is the xv6 kernel state card (from domain.machine_state) when the focus is a
|
|
93
|
+
running xv6 Machine — a second 'ground truth' source alongside the canvas, so OS help is
|
|
94
|
+
grounded in this student's actual kernel run. `stance` (from `grounding_stance`) is prepended
|
|
95
|
+
so the model knows how closed-world to be for THIS question."""
|
|
96
|
+
parts = []
|
|
97
|
+
if stance:
|
|
98
|
+
parts.append(stance)
|
|
99
|
+
parts.append(always_on)
|
|
100
|
+
if canvas_digest:
|
|
101
|
+
parts.append("Current canvas (ground truth):\n" + canvas_digest)
|
|
102
|
+
if machine_card:
|
|
103
|
+
parts.append(machine_card)
|
|
104
|
+
if accumulator:
|
|
105
|
+
parts.append(accumulator)
|
|
106
|
+
rc = retrieval.as_context() if retrieval is not None else ""
|
|
107
|
+
if rc:
|
|
108
|
+
parts.append(rc)
|
|
109
|
+
return "\n\n".join(p for p in parts if p)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def machine_card_level(text: str) -> int:
|
|
113
|
+
"""How deep an xv6 state card the question warrants (progressive; keeps the small-LLM
|
|
114
|
+
budget lean). 0 = scheduling picture; 1 = + registers/stack; 2 = + memory/FS."""
|
|
115
|
+
q = (text or "").lower()
|
|
116
|
+
if any(w in q for w in ("page table", "pagetable", "satp", "virtual memory", "paging",
|
|
117
|
+
"address space", "inode", "file system", "filesystem", "journal",
|
|
118
|
+
"buffer cache", "disk", "block")):
|
|
119
|
+
return 2
|
|
120
|
+
if any(w in q for w in ("register", "pc", "stack", "backtrace", "trap", "trapframe",
|
|
121
|
+
"context", "swtch", "ra ", "sp ", "program counter")):
|
|
122
|
+
return 1
|
|
123
|
+
return 0
|
gini/agent/authoring.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Authoring loop — a teacher turns a rough intent into a releasable mission (M5).
|
|
2
|
+
|
|
3
|
+
The flow: **propose** (a vague intent → a composition-by-reference the teacher can read) →
|
|
4
|
+
**playtest** (assemble + validate + a dry-run report so nothing broken is released) → **to_pack**
|
|
5
|
+
(the YAML the Teaching Center distributes). The teacher is sovereign: the LLM drafts, the human
|
|
6
|
+
ratifies/tweaks the spec before releasing.
|
|
7
|
+
|
|
8
|
+
A proposal is a *composition* (references local fragment ids) — never new foundational fragments — so
|
|
9
|
+
whatever a teacher authors is gradable by construction on any install that has the referenced
|
|
10
|
+
fragments (guarded by the version/existence check in `domain.composition`).
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import yaml
|
|
15
|
+
|
|
16
|
+
from ..domain import composition as _comp
|
|
17
|
+
from ..domain import fragments as _frag
|
|
18
|
+
from ..domain import lesson as _lesson
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def propose(intent_text: str, llm=None, *, lesson_id: str = "", genre: str | None = None) -> dict:
|
|
22
|
+
"""Draft a composition spec from a rough intent (reuses the student-facing composer, but emits a
|
|
23
|
+
releasable REFERENCE spec — the core fragment ids + framing — not a one-off lesson)."""
|
|
24
|
+
from . import lesson_resolver
|
|
25
|
+
prop = lesson_resolver.compose(intent_text, llm, lesson_id=lesson_id or "authored", genre=genre)
|
|
26
|
+
if prop is None or prop.lesson is None:
|
|
27
|
+
return {}
|
|
28
|
+
les = prop.lesson
|
|
29
|
+
# reference only the CORE fragments the teacher chose; genre re-derives the enrichment on assembly
|
|
30
|
+
cores = [fid for fid in les.fragments
|
|
31
|
+
if (_frag.get(fid) is not None and _frag.get(fid).layer == _frag.CORE)]
|
|
32
|
+
spec = {"id": lesson_id or "authored", "fragments": cores or [prop.archetype_id],
|
|
33
|
+
"genre": les.genre or "expedition", "title": les.title,
|
|
34
|
+
"brief": les.brief.split("] ", 1)[-1] if les.brief.startswith("[") else les.brief}
|
|
35
|
+
if les.level is not None:
|
|
36
|
+
spec["level"] = les.level
|
|
37
|
+
return spec
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def playtest(spec: dict) -> dict:
|
|
41
|
+
"""Assemble + validate a spec without releasing. Returns a report: {ok, problems, summary,
|
|
42
|
+
objectives}. This is the safety gate — a teacher sees exactly what a student would be graded on."""
|
|
43
|
+
problems = list(_comp.missing_refs(spec))
|
|
44
|
+
les = None
|
|
45
|
+
if not problems:
|
|
46
|
+
try:
|
|
47
|
+
les = _comp.from_composition(spec)
|
|
48
|
+
except _comp.CompositionError as e:
|
|
49
|
+
problems.append(str(e))
|
|
50
|
+
if les is not None:
|
|
51
|
+
problems.extend(_lesson.validate(les))
|
|
52
|
+
return {
|
|
53
|
+
"ok": not problems,
|
|
54
|
+
"problems": problems,
|
|
55
|
+
"summary": (f"{les.title} — {len(les.objectives)} objectives"
|
|
56
|
+
f" · L{les.level} · {les.genre}") if les else "",
|
|
57
|
+
"objectives": [o.say for o in les.objectives] if les else [],
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def to_pack(spec: dict) -> str:
|
|
62
|
+
"""The releasable YAML pack (a composition-by-reference the Teaching Center serves)."""
|
|
63
|
+
ordered = {k: spec[k] for k in ("id", "fragments", "genre", "level", "title", "brief",
|
|
64
|
+
"time_limit", "attempts", "help", "persona") if k in spec}
|
|
65
|
+
return yaml.safe_dump(ordered, sort_keys=False, allow_unicode=True, width=100)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def ratify(spec: dict, **tweaks) -> dict:
|
|
69
|
+
"""Apply a teacher's edits (title, genre, level, fragments, brief…) to a proposed spec."""
|
|
70
|
+
out = dict(spec)
|
|
71
|
+
out.update({k: v for k, v in tweaks.items() if v is not None})
|
|
72
|
+
return out
|
gini/agent/blackboard.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""The Blackboard — the shared truth cache the deterministic swarm keeps current and the Reasoning
|
|
2
|
+
persona reads from (GINI_MISSIONS_AGENT_ARCHITECTURE.md §4). It holds the latest Verdict per subject
|
|
3
|
+
plus the shared, curated MissionMemory. Verdicts are recomputed incrementally: an update names the
|
|
4
|
+
state keys that changed, and only verifiers whose `deps()` intersect those keys re-run.
|
|
5
|
+
|
|
6
|
+
Pure Python (no Qt): the substrate is unit-testable with a real Topology + lesson. The live wiring
|
|
7
|
+
(bus → observers → verifiers → notifications → reasoning) sits on top in later phases.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from .contracts import Fact, MissionMemory, Verdict
|
|
12
|
+
from .verifiers import WorldView, for_lesson
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Registry:
|
|
16
|
+
def __init__(self) -> None:
|
|
17
|
+
self._verifiers: dict[str, object] = {}
|
|
18
|
+
|
|
19
|
+
def add(self, verifier) -> None:
|
|
20
|
+
self._verifiers[verifier.id] = verifier
|
|
21
|
+
|
|
22
|
+
def add_all(self, verifiers) -> None:
|
|
23
|
+
for v in verifiers:
|
|
24
|
+
self.add(v)
|
|
25
|
+
|
|
26
|
+
def remove(self, verifier_id: str) -> None:
|
|
27
|
+
self._verifiers.pop(verifier_id, None)
|
|
28
|
+
|
|
29
|
+
def clear(self) -> None:
|
|
30
|
+
self._verifiers.clear()
|
|
31
|
+
|
|
32
|
+
def verifiers(self) -> list:
|
|
33
|
+
return list(self._verifiers.values())
|
|
34
|
+
|
|
35
|
+
def affected(self, changed: set[str] | None) -> list:
|
|
36
|
+
"""Verifiers to re-run: all when `changed` is None, else those whose deps intersect it."""
|
|
37
|
+
if changed is None:
|
|
38
|
+
return self.verifiers()
|
|
39
|
+
return [v for v in self._verifiers.values() if set(v.deps()) & changed]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Blackboard:
|
|
43
|
+
def __init__(self) -> None:
|
|
44
|
+
self.registry = Registry()
|
|
45
|
+
self.memory = MissionMemory()
|
|
46
|
+
self._verdicts: dict[str, object] = {}
|
|
47
|
+
self._lesson = None
|
|
48
|
+
self._runner = None
|
|
49
|
+
|
|
50
|
+
# -- lifecycle ---------------------------------------------------------- #
|
|
51
|
+
def load_lesson(self, lesson, *, verifiers=None, runner=None) -> None:
|
|
52
|
+
"""Register the tiny verifiers a mission needs and reset the truth cache. `verifiers` lets a
|
|
53
|
+
domain pack supply its own set; it defaults to the networking `for_lesson`."""
|
|
54
|
+
self.registry.clear()
|
|
55
|
+
self.registry.add_all(verifiers if verifiers is not None else for_lesson(lesson))
|
|
56
|
+
self._verdicts.clear()
|
|
57
|
+
self._lesson = lesson
|
|
58
|
+
self._runner = runner
|
|
59
|
+
|
|
60
|
+
def clear(self) -> None:
|
|
61
|
+
self.registry.clear()
|
|
62
|
+
self._verdicts.clear()
|
|
63
|
+
self._lesson = None
|
|
64
|
+
self.memory = MissionMemory()
|
|
65
|
+
|
|
66
|
+
# -- truth maintenance -------------------------------------------------- #
|
|
67
|
+
def update(self, topology, *, changed: set[str] | None = None) -> list:
|
|
68
|
+
"""Re-run the affected verifiers against the current topology; refresh the cache. Returns the
|
|
69
|
+
verdicts that CHANGED (value flipped or newly appeared) — the raw material for notifications."""
|
|
70
|
+
if self._lesson is None:
|
|
71
|
+
return []
|
|
72
|
+
view = WorldView.of(topology, self._lesson, runner=self._runner)
|
|
73
|
+
flipped = []
|
|
74
|
+
for v in self.registry.affected(changed):
|
|
75
|
+
for verdict in v.check(view):
|
|
76
|
+
prev = self._verdicts.get(verdict.subject)
|
|
77
|
+
if prev is None or prev.value != verdict.value:
|
|
78
|
+
flipped.append(verdict)
|
|
79
|
+
self._verdicts[verdict.subject] = verdict
|
|
80
|
+
return flipped
|
|
81
|
+
|
|
82
|
+
def ingest_results(self, results) -> None:
|
|
83
|
+
"""Set objective verdicts directly from pre-computed ObjectiveResults (the mission already
|
|
84
|
+
evaluated them) — so the Reasoning persona is grounded without a second evaluation and even
|
|
85
|
+
when only results, not a topology, are on hand."""
|
|
86
|
+
for r in results:
|
|
87
|
+
self._verdicts[r.id] = Verdict(f"objective:{r.id}", subject=r.id, value=r.met,
|
|
88
|
+
evidence=Fact("status", r.status), deps=("topology",))
|
|
89
|
+
|
|
90
|
+
# -- reads -------------------------------------------------------------- #
|
|
91
|
+
def verdict(self, subject: str):
|
|
92
|
+
return self._verdicts.get(subject)
|
|
93
|
+
|
|
94
|
+
def value(self, subject: str) -> bool:
|
|
95
|
+
v = self._verdicts.get(subject)
|
|
96
|
+
return bool(v and v.value)
|
|
97
|
+
|
|
98
|
+
def verdicts(self) -> list:
|
|
99
|
+
return list(self._verdicts.values())
|
|
100
|
+
|
|
101
|
+
def unmet_objectives(self) -> list[str]:
|
|
102
|
+
return [v.subject for v in self._verdicts.values()
|
|
103
|
+
if v.verifier_id.startswith("objective:") and not v.value]
|
|
104
|
+
|
|
105
|
+
def all_objectives_met(self) -> bool:
|
|
106
|
+
objs = [v for v in self._verdicts.values() if v.verifier_id.startswith("objective:")]
|
|
107
|
+
return bool(objs) and all(v.value for v in objs)
|
|
108
|
+
|
|
109
|
+
def flags(self) -> dict:
|
|
110
|
+
"""Current legality problems (for the red badges), read straight off the cache."""
|
|
111
|
+
off = self.verdict("off_task")
|
|
112
|
+
bad = self.verdict("illegal_links")
|
|
113
|
+
return {"off_task": list(off.evidence.data) if off and off.evidence and not off.value else [],
|
|
114
|
+
"illegal_links": list(bad.evidence.data) if bad and bad.evidence and not bad.value else []}
|
gini/agent/contracts.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Multi-agent message contracts — the domain-neutral types the mission agents pass on the bus /
|
|
2
|
+
blackboard. Pinning these down first is the whole point: a multi-agent system with sloppy message
|
|
3
|
+
types is the real maintenance nightmare. See GINI_MISSIONS_AGENT_ARCHITECTURE.md §7.
|
|
4
|
+
|
|
5
|
+
Two families:
|
|
6
|
+
• truth & change (deterministic agents): Observation, Fact, Verdict, Notification;
|
|
7
|
+
• meaning (LLM personas): Intent, Claim, Move; plus the shared, curated MissionMemory.
|
|
8
|
+
|
|
9
|
+
Everything here is a plain dataclass (no Qt, no LLM) so the whole substrate is unit-testable. The
|
|
10
|
+
`Verifier`/`Observer` protocols are the domain-pack seam: a domain implements tiny verifiers and
|
|
11
|
+
observers; the framework never looks inside them.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import time
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import Any, Protocol, runtime_checkable
|
|
18
|
+
|
|
19
|
+
# ---- truth & change ------------------------------------------------------- #
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Fact:
|
|
24
|
+
"""A checkable datum — a counterexample, disconnected components, a value, a status. NEVER prose;
|
|
25
|
+
turning a Fact into meaning is always the Reasoning persona's job."""
|
|
26
|
+
kind: str
|
|
27
|
+
data: Any = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class Verdict:
|
|
32
|
+
"""The result of ONE tiny verifier for ONE subject: a boolean + the fact that evidences it."""
|
|
33
|
+
verifier_id: str
|
|
34
|
+
subject: str # what was checked (an objective id, a device id, "off_task" …)
|
|
35
|
+
value: bool
|
|
36
|
+
evidence: Fact | None = None
|
|
37
|
+
deps: tuple[str, ...] = () # state keys this verdict depends on (for incremental re-check)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class Observation:
|
|
42
|
+
"""A sensed datum from the running world (a snapshot or a discrete event)."""
|
|
43
|
+
source: str
|
|
44
|
+
data: Any = None
|
|
45
|
+
event: str = "" # "" for a snapshot; else a discrete event name (panic, run_done…)
|
|
46
|
+
ts: float = field(default_factory=time.time)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class Notification:
|
|
51
|
+
"""A salient CHANGE, routed to wake the Reasoning persona. Salience is rules-only (0..1)."""
|
|
52
|
+
change: str # e.g. "objective_met", "off_task_added", "run_complete", "question"
|
|
53
|
+
subjects: tuple[str, ...] = ()
|
|
54
|
+
salience: float = 1.0
|
|
55
|
+
data: Any = None
|
|
56
|
+
ts: float = field(default_factory=time.time)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ---- meaning -------------------------------------------------------------- #
|
|
60
|
+
|
|
61
|
+
# Intent kinds the Understanding persona classifies a student input into
|
|
62
|
+
CONCEPT, ENTITY, OBJECTIVE, NEXT_STEP, OFF_TASK, META = (
|
|
63
|
+
"concept", "entity", "objective", "next_step", "off_task", "meta")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class Intent:
|
|
68
|
+
"""A student input, understood: what they mean and what it references."""
|
|
69
|
+
kind: str = META
|
|
70
|
+
text: str = ""
|
|
71
|
+
refs: tuple[str, ...] = () # resolved world-entity ids
|
|
72
|
+
objective_ref: str = ""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True)
|
|
76
|
+
class Claim:
|
|
77
|
+
"""A single factual assertion inside a Move — re-checkable against the oracle, so the model may
|
|
78
|
+
reason freely while nothing false ships."""
|
|
79
|
+
predicate: str # a verifier subject or a structural predicate to re-evaluate
|
|
80
|
+
expected: bool = True
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True)
|
|
84
|
+
class Move:
|
|
85
|
+
"""The Reasoning persona's output for a turn."""
|
|
86
|
+
kind: str = "say" # say | hint | advance | flag | answer | quiet
|
|
87
|
+
text: str = ""
|
|
88
|
+
refs: tuple[str, ...] = ()
|
|
89
|
+
claims: tuple[Claim, ...] = ()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class MissionMemory:
|
|
94
|
+
"""The Reasoning persona's curated, SHARED working memory (read-only to other personas). Compact
|
|
95
|
+
and structured — not a raw transcript — so a small model stays sharp over a long mission."""
|
|
96
|
+
arc: str = "" # where the mission is, in a line
|
|
97
|
+
tried: list[str] = field(default_factory=list) # what the student has attempted
|
|
98
|
+
facts: list[str] = field(default_factory=list) # established facts worth carrying
|
|
99
|
+
threads: list[str] = field(default_factory=list) # open questions / loose ends
|
|
100
|
+
|
|
101
|
+
def note_tried(self, s: str) -> None:
|
|
102
|
+
if s and s not in self.tried:
|
|
103
|
+
self.tried.append(s)
|
|
104
|
+
|
|
105
|
+
def note_fact(self, s: str) -> None:
|
|
106
|
+
if s and s not in self.facts:
|
|
107
|
+
self.facts.append(s)
|
|
108
|
+
|
|
109
|
+
def digest(self, *, max_items: int = 6) -> str:
|
|
110
|
+
"""A compact slice for a persona prompt (bounded — small models drown in clutter)."""
|
|
111
|
+
parts = []
|
|
112
|
+
if self.arc:
|
|
113
|
+
parts.append(f"Where we are: {self.arc}")
|
|
114
|
+
if self.tried:
|
|
115
|
+
parts.append("Tried: " + "; ".join(self.tried[-max_items:]))
|
|
116
|
+
if self.facts:
|
|
117
|
+
parts.append("Known: " + "; ".join(self.facts[-max_items:]))
|
|
118
|
+
if self.threads:
|
|
119
|
+
parts.append("Open: " + "; ".join(self.threads[-max_items:]))
|
|
120
|
+
return "\n".join(parts)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ---- the domain-pack seam ------------------------------------------------- #
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@runtime_checkable
|
|
127
|
+
class Verifier(Protocol):
|
|
128
|
+
"""A tiny, single-aspect, deterministic checker. Produces Verdicts for a WorldView; declares the
|
|
129
|
+
state keys it depends on so the blackboard only re-runs it when relevant state changes."""
|
|
130
|
+
id: str
|
|
131
|
+
|
|
132
|
+
def deps(self) -> tuple[str, ...]: ...
|
|
133
|
+
|
|
134
|
+
def check(self, view) -> list[Verdict]: ...
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@runtime_checkable
|
|
138
|
+
class Observer(Protocol):
|
|
139
|
+
"""Senses the running world into Observations (a snapshot or discrete events)."""
|
|
140
|
+
id: str
|
|
141
|
+
|
|
142
|
+
def observe(self, world) -> list[Observation]: ...
|
gini/agent/domains.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Domain packs — the seam that makes the mission engine domain-agnostic (GINI_MISSIONS_AGENT_
|
|
2
|
+
ARCHITECTURE.md §8). The framework (blackboard, notifier, personas, reasoning) is blind to the domain;
|
|
3
|
+
a DomainPack supplies the domain-specific pieces behind one interface: the tiny Verifiers, the Observers,
|
|
4
|
+
the capability Vocabulary, the objective PredicateSet, the KB, the palette, and the fragments.
|
|
5
|
+
|
|
6
|
+
Networking is the first pack — it just wraps the modules we already have, so nothing is duplicated.
|
|
7
|
+
Cloud and xv6/OS become future packs implementing the same protocol; the reasoner discovers a pack's
|
|
8
|
+
tools by enumerating its verifiers/observers, which is the proof the reasoning is domain-neutral.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Protocol, runtime_checkable
|
|
13
|
+
|
|
14
|
+
from .contracts import Observation
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@runtime_checkable
|
|
18
|
+
class DomainPack(Protocol):
|
|
19
|
+
name: str
|
|
20
|
+
|
|
21
|
+
def verifiers(self, lesson) -> list: ... # tiny verifiers for this lesson
|
|
22
|
+
def observers(self) -> list: ... # world sensors
|
|
23
|
+
def vocabulary(self): ... # capability roles module (is-a hierarchy)
|
|
24
|
+
def predicates(self) -> set: ... # objective predicate names
|
|
25
|
+
def fragments(self) -> list: ... # composable content
|
|
26
|
+
def palette(self) -> dict: ... # buildable/observable elements
|
|
27
|
+
def knowledge(self): ... # KB module (retrieval)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TopologyObserver:
|
|
31
|
+
"""Senses the networking world (the drawn topology) as a snapshot Observation."""
|
|
32
|
+
id = "topology"
|
|
33
|
+
|
|
34
|
+
def observe(self, world) -> list[Observation]:
|
|
35
|
+
data = world.to_dict() if hasattr(world, "to_dict") else world
|
|
36
|
+
return [Observation(source="topology", data=data)]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class NetworkingPack:
|
|
40
|
+
"""The first domain pack — a thin adapter over the existing networking modules."""
|
|
41
|
+
name = "networking"
|
|
42
|
+
|
|
43
|
+
def verifiers(self, lesson) -> list:
|
|
44
|
+
from . import verifiers as _v
|
|
45
|
+
return _v.for_lesson(lesson)
|
|
46
|
+
|
|
47
|
+
def observers(self) -> list:
|
|
48
|
+
return [TopologyObserver()]
|
|
49
|
+
|
|
50
|
+
def vocabulary(self):
|
|
51
|
+
from ..domain import capabilities
|
|
52
|
+
return capabilities
|
|
53
|
+
|
|
54
|
+
def predicates(self) -> set:
|
|
55
|
+
from ..domain.objectives import _PREDICATES
|
|
56
|
+
return set(_PREDICATES)
|
|
57
|
+
|
|
58
|
+
def fragments(self) -> list:
|
|
59
|
+
from ..domain import fragments
|
|
60
|
+
return fragments.all_fragments()
|
|
61
|
+
|
|
62
|
+
def palette(self) -> dict:
|
|
63
|
+
from ..domain.devices import REGISTRY
|
|
64
|
+
return dict(REGISTRY)
|
|
65
|
+
|
|
66
|
+
def knowledge(self):
|
|
67
|
+
from . import kb
|
|
68
|
+
return kb
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# -- registry --------------------------------------------------------------- #
|
|
72
|
+
_PACKS: dict[str, DomainPack] = {}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def register(pack: DomainPack) -> None:
|
|
76
|
+
_PACKS[pack.name] = pack
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def get(name: str) -> DomainPack | None:
|
|
80
|
+
return _PACKS.get(name)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def names() -> list[str]:
|
|
84
|
+
return list(_PACKS)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
register(NetworkingPack())
|
|
88
|
+
|
|
89
|
+
# the OS pack — same protocol, drops xv6/OS onto the domain-neutral engine.
|
|
90
|
+
from .xv6_pack import Xv6Pack # noqa: E402 (import here to avoid a cycle at module top)
|
|
91
|
+
register(Xv6Pack())
|
gini/agent/embed.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""L2 semantic recall — embeddings over the (small, fixed) GINI knowledge base.
|
|
2
|
+
|
|
3
|
+
The KB is ~75 short documents (concept notes + recipes + element descriptions), so semantic
|
|
4
|
+
search is a cosine over a precomputed matrix, not a vector-database problem:
|
|
5
|
+
|
|
6
|
+
• POPULATE (offline, shipped): `scripts/build_kb_index.py` embeds every `kb_documents()`
|
|
7
|
+
entry once and writes `data/kb_index.json` (model tag + KB hash + id→vector). A test
|
|
8
|
+
asserts the hash matches the live KB, so an edited note without a rebuild fails CI.
|
|
9
|
+
• QUERY (online, reused infra): `OllamaEmbeddings` embeds the student's query via the SAME
|
|
10
|
+
Ollama server GINI already uses for the LLM (`/api/embed`) — no new Python dependency —
|
|
11
|
+
then cosines against the shipped vectors.
|
|
12
|
+
|
|
13
|
+
`NullEmbeddings` is the graceful fallback when no index/embed-model is available, so GINI
|
|
14
|
+
still runs offline (it simply drops to L0+L1). Callers use the `EmbeddingIndex` protocol and
|
|
15
|
+
never care which is active.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import hashlib
|
|
20
|
+
import json
|
|
21
|
+
import math
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Protocol
|
|
24
|
+
|
|
25
|
+
from ..domain import concepts as _concepts
|
|
26
|
+
from ..domain import recipes as _recipes
|
|
27
|
+
from ..domain.devices import all_devices
|
|
28
|
+
|
|
29
|
+
_INDEX_PATH = Path(__file__).resolve().parent.parent / "data" / "kb_index.json"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# -- the shared document set (builder + validation) ------------------------- #
|
|
33
|
+
def kb_documents() -> list[tuple[str, str]]:
|
|
34
|
+
"""(doc_id, text) for every embeddable KB card. Stable IDs `concept:<key>` /
|
|
35
|
+
`recipe:<id>` / `element:<key>` so recall can map a vector back to its object."""
|
|
36
|
+
docs: list[tuple[str, str]] = []
|
|
37
|
+
for c in _concepts.CONCEPTS:
|
|
38
|
+
docs.append((f"concept:{c.key}",
|
|
39
|
+
f"{c.title}. {' '.join(c.keywords)}. {c.body}"))
|
|
40
|
+
for r in _recipes.RECIPES:
|
|
41
|
+
docs.append((f"recipe:{r.id}",
|
|
42
|
+
f"{r.name}. {r.summary} {r.teaches} {' '.join(r.intent)}"))
|
|
43
|
+
for d in all_devices():
|
|
44
|
+
docs.append((f"element:{d.key}", f"{d.label}: {d.description}"))
|
|
45
|
+
return docs
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def kb_hash() -> str:
|
|
49
|
+
"""A stable hash of the KB text — the index records it so we can detect drift."""
|
|
50
|
+
h = hashlib.sha256()
|
|
51
|
+
for doc_id, text in kb_documents():
|
|
52
|
+
h.update(doc_id.encode()); h.update(b"\x00"); h.update(text.encode()); h.update(b"\x01")
|
|
53
|
+
return h.hexdigest()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# -- cosine helpers --------------------------------------------------------- #
|
|
57
|
+
def _cosine(a: list[float], b: list[float]) -> float:
|
|
58
|
+
if not a or not b or len(a) != len(b):
|
|
59
|
+
return 0.0
|
|
60
|
+
dot = sum(x * y for x, y in zip(a, b))
|
|
61
|
+
na = math.sqrt(sum(x * x for x in a))
|
|
62
|
+
nb = math.sqrt(sum(y * y for y in b))
|
|
63
|
+
return dot / (na * nb) if na and nb else 0.0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# -- the protocol + implementations ----------------------------------------- #
|
|
67
|
+
class EmbeddingIndex(Protocol):
|
|
68
|
+
def available(self) -> bool: ...
|
|
69
|
+
def query(self, text: str, k: int = 4) -> list[tuple[str, float]]: ...
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class NullEmbeddings:
|
|
73
|
+
"""The offline fallback — never matches, so recall degrades to L0+L1."""
|
|
74
|
+
|
|
75
|
+
def available(self) -> bool:
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
def query(self, text: str, k: int = 4) -> list[tuple[str, float]]:
|
|
79
|
+
return []
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def load_index(path: Path = _INDEX_PATH) -> dict | None:
|
|
83
|
+
try:
|
|
84
|
+
return json.loads(Path(path).read_text())
|
|
85
|
+
except (OSError, json.JSONDecodeError):
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class OllamaEmbeddings:
|
|
90
|
+
"""Semantic recall backed by the precomputed index + an Ollama `/api/embed` query call.
|
|
91
|
+
|
|
92
|
+
`backend` is any object exposing `embed(texts) -> list[vector]` (the OllamaBackend). The
|
|
93
|
+
index is loaded once; if it's missing or the KB has drifted, `available()` is False and we
|
|
94
|
+
fall back cleanly. `min_score` gates weak cosine matches out."""
|
|
95
|
+
|
|
96
|
+
def __init__(self, backend, *, index: dict | None = None, index_path: Path = _INDEX_PATH,
|
|
97
|
+
min_score: float = 0.55, check_hash: bool = True) -> None:
|
|
98
|
+
self.backend = backend
|
|
99
|
+
self.min_score = min_score
|
|
100
|
+
self._index = index if index is not None else load_index(index_path)
|
|
101
|
+
self._ok = bool(self._index) and bool(self._index.get("vectors"))
|
|
102
|
+
if self._ok and check_hash:
|
|
103
|
+
# a mismatched hash means the notes changed without a rebuild — don't trust it
|
|
104
|
+
if self._index.get("kb_hash") not in (None, kb_hash()):
|
|
105
|
+
self._ok = False
|
|
106
|
+
|
|
107
|
+
def available(self) -> bool:
|
|
108
|
+
return self._ok and self.backend is not None
|
|
109
|
+
|
|
110
|
+
def query(self, text: str, k: int = 4) -> list[tuple[str, float]]:
|
|
111
|
+
if not self.available():
|
|
112
|
+
return []
|
|
113
|
+
try:
|
|
114
|
+
vecs = self.backend.embed([text])
|
|
115
|
+
qv = vecs[0] if vecs else None
|
|
116
|
+
except Exception:
|
|
117
|
+
return []
|
|
118
|
+
if not qv:
|
|
119
|
+
return []
|
|
120
|
+
scored = [(doc_id, _cosine(qv, vec)) for doc_id, vec in self._index["vectors"].items()]
|
|
121
|
+
scored = [(d, s) for d, s in scored if s >= self.min_score]
|
|
122
|
+
scored.sort(key=lambda p: -p[1])
|
|
123
|
+
return scored[:k]
|