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.
Files changed (278) hide show
  1. gini/__init__.py +12 -0
  2. gini/__main__.py +107 -0
  3. gini/_version.py +24 -0
  4. gini/agent/__init__.py +17 -0
  5. gini/agent/agent_gamemaster.py +140 -0
  6. gini/agent/api.py +291 -0
  7. gini/agent/ask.py +123 -0
  8. gini/agent/authoring.py +72 -0
  9. gini/agent/blackboard.py +114 -0
  10. gini/agent/contracts.py +142 -0
  11. gini/agent/domains.py +91 -0
  12. gini/agent/embed.py +123 -0
  13. gini/agent/gamemaster.py +256 -0
  14. gini/agent/kb.py +148 -0
  15. gini/agent/lesson_resolver.py +261 -0
  16. gini/agent/llm/__init__.py +5 -0
  17. gini/agent/llm/backend.py +43 -0
  18. gini/agent/llm/fake.py +25 -0
  19. gini/agent/llm/ollama.py +206 -0
  20. gini/agent/loop.py +258 -0
  21. gini/agent/mcp_server.py +86 -0
  22. gini/agent/meaning.py +225 -0
  23. gini/agent/mission.py +210 -0
  24. gini/agent/mission_controller.py +208 -0
  25. gini/agent/narration.py +116 -0
  26. gini/agent/notifier.py +86 -0
  27. gini/agent/personas.py +79 -0
  28. gini/agent/reasoning.py +172 -0
  29. gini/agent/recall.py +248 -0
  30. gini/agent/session.py +79 -0
  31. gini/agent/teaching_center.py +482 -0
  32. gini/agent/tools/__init__.py +3 -0
  33. gini/agent/tools/registry.py +193 -0
  34. gini/agent/twin/__init__.py +28 -0
  35. gini/agent/twin/authoring.py +71 -0
  36. gini/agent/twin/contracts.py +54 -0
  37. gini/agent/twin/dialectic.py +189 -0
  38. gini/agent/twin/harness.py +93 -0
  39. gini/agent/twin/justify.py +156 -0
  40. gini/agent/twin/learner.py +64 -0
  41. gini/agent/twin/mission.py +60 -0
  42. gini/agent/twin/os_coach.py +79 -0
  43. gini/agent/twin/salience.py +30 -0
  44. gini/agent/understand.py +250 -0
  45. gini/agent/verifiers.py +106 -0
  46. gini/agent/wizard.py +178 -0
  47. gini/agent/xv6_pack.py +74 -0
  48. gini/app/__init__.py +3 -0
  49. gini/app/context.py +368 -0
  50. gini/app/paths.py +121 -0
  51. gini/data/README.md +21 -0
  52. gini/domain/__init__.py +9 -0
  53. gini/domain/assembly.py +209 -0
  54. gini/domain/authoring.py +353 -0
  55. gini/domain/blueprints.py +5 -0
  56. gini/domain/capabilities.py +177 -0
  57. gini/domain/catalog.py +85 -0
  58. gini/domain/certify.py +201 -0
  59. gini/domain/compose.py +413 -0
  60. gini/domain/composition.py +88 -0
  61. gini/domain/concepts.py +383 -0
  62. gini/domain/connection_rules.py +269 -0
  63. gini/domain/constraints.py +153 -0
  64. gini/domain/content.py +59 -0
  65. gini/domain/cpu_journey.py +89 -0
  66. gini/domain/devices.py +747 -0
  67. gini/domain/diagnose.py +201 -0
  68. gini/domain/element_guide.py +327 -0
  69. gini/domain/explain.py +90 -0
  70. gini/domain/fingerprint.py +201 -0
  71. gini/domain/firewall.py +34 -0
  72. gini/domain/flowlog.py +61 -0
  73. gini/domain/flowtable.py +179 -0
  74. gini/domain/fragment_yaml.py +230 -0
  75. gini/domain/fragments.py +169 -0
  76. gini/domain/games/__init__.py +2 -0
  77. gini/domain/games/paging_games.py +119 -0
  78. gini/domain/games/policy_game.py +86 -0
  79. gini/domain/games/process_game.py +48 -0
  80. gini/domain/games/thrash_game.py +75 -0
  81. gini/domain/games/translate_game.py +60 -0
  82. gini/domain/games/trap_game.py +86 -0
  83. gini/domain/grader.py +155 -0
  84. gini/domain/grouping.py +67 -0
  85. gini/domain/legality.py +103 -0
  86. gini/domain/lesson.py +241 -0
  87. gini/domain/lexicon.py +150 -0
  88. gini/domain/machine_state.py +410 -0
  89. gini/domain/missions/networking/basic-lan.yaml +32 -0
  90. gini/domain/missions/networking/cache-in-front.yaml +23 -0
  91. gini/domain/missions/networking/decouple-with-queue.yaml +31 -0
  92. gini/domain/missions/networking/drive-load.yaml +20 -0
  93. gini/domain/missions/networking/fix-the-address.yaml +75 -0
  94. gini/domain/missions/networking/fix-the-lan.yaml +43 -0
  95. gini/domain/missions/networking/inspect-flows.yaml +16 -0
  96. gini/domain/missions/networking/k8s-autoscale.yaml +27 -0
  97. gini/domain/missions/networking/least-privilege.yaml +21 -0
  98. gini/domain/missions/networking/load-balanced-web.yaml +29 -0
  99. gini/domain/missions/networking/observe-it.yaml +24 -0
  100. gini/domain/missions/networking/put-in-vpc.yaml +30 -0
  101. gini/domain/missions/networking/reachability-boundary.yaml +56 -0
  102. gini/domain/missions/networking/sdn-reactive.yaml +35 -0
  103. gini/domain/missions/networking/send-request.yaml +19 -0
  104. gini/domain/missions/networking/serverless-api.yaml +25 -0
  105. gini/domain/missions/networking/service-chain.yaml +33 -0
  106. gini/domain/missions/os/lottery-fix.yaml +19 -0
  107. gini/domain/missions/os/priority-fix.yaml +24 -0
  108. gini/domain/missions.py +111 -0
  109. gini/domain/modulechain.py +36 -0
  110. gini/domain/objectives.py +488 -0
  111. gini/domain/os_zoo.py +79 -0
  112. gini/domain/paging_sim.py +141 -0
  113. gini/domain/pricing.py +199 -0
  114. gini/domain/probes.py +226 -0
  115. gini/domain/profile.py +142 -0
  116. gini/domain/recipes.py +738 -0
  117. gini/domain/riders.py +309 -0
  118. gini/domain/router_modules.py +224 -0
  119. gini/domain/routetable.py +67 -0
  120. gini/domain/scoring.py +76 -0
  121. gini/domain/staging.py +122 -0
  122. gini/domain/syscall_builder.py +144 -0
  123. gini/domain/topic_cloud.py +62 -0
  124. gini/domain/topology.py +213 -0
  125. gini/domain/vocabulary.py +51 -0
  126. gini/domain/xv6.py +808 -0
  127. gini/domain/xv6_fs.py +250 -0
  128. gini/domain/xv6_runner.py +113 -0
  129. gini/domain/xv6_vm.py +385 -0
  130. gini/gloader.py +17 -0
  131. gini/runtime/__init__.py +18 -0
  132. gini/runtime/cloudfabric_agent.py +370 -0
  133. gini/runtime/console.py +68 -0
  134. gini/runtime/control.py +70 -0
  135. gini/runtime/frame.py +138 -0
  136. gini/runtime/gbridge.py +638 -0
  137. gini/runtime/grouter.py +223 -0
  138. gini/runtime/hostsim.py +90 -0
  139. gini/runtime/shuttle.py +348 -0
  140. gini/runtime/switch.py +109 -0
  141. gini/runtime/transport.py +77 -0
  142. gini/runtime/xv6_bridge.py +312 -0
  143. gini/server/__init__.py +22 -0
  144. gini/server/__main__.py +74 -0
  145. gini/server/app.py +140 -0
  146. gini/server/auth.py +82 -0
  147. gini/server/policy.py +57 -0
  148. gini/server/session.py +23 -0
  149. gini/services/__init__.py +15 -0
  150. gini/services/boardflash.py +248 -0
  151. gini/services/boardsetup.py +374 -0
  152. gini/services/cloud_catalog.py +143 -0
  153. gini/services/compiler.py +1858 -0
  154. gini/services/discovery.py +324 -0
  155. gini/services/gloader.py +183 -0
  156. gini/services/orchestrator.py +1460 -0
  157. gini/services/persistence.py +28 -0
  158. gini/services/probe_runner.py +149 -0
  159. gini/services/project.py +217 -0
  160. gini/services/remote.py +93 -0
  161. gini/services/rider_runner.py +96 -0
  162. gini/services/rider_session.py +171 -0
  163. gini/services/shadow_store.py +52 -0
  164. gini/services/terminal.py +45 -0
  165. gini/setup/__init__.py +17 -0
  166. gini/setup/cli.py +109 -0
  167. gini/setup/images.py +33 -0
  168. gini/setup/marker.py +43 -0
  169. gini/setup/runtime.py +69 -0
  170. gini/ui/__init__.py +3 -0
  171. gini/ui/assets/app_icon.icns +0 -0
  172. gini/ui/assets/app_icon.ico +0 -0
  173. gini/ui/assets/app_icon.png +0 -0
  174. gini/ui/assets/app_icon_1024.png +0 -0
  175. gini/ui/assets/cue/_w.txt +1 -0
  176. gini/ui/assets/cue/ai.png +0 -0
  177. gini/ui/assets/cue/canvas.png +0 -0
  178. gini/ui/assets/cue/cloud.png +0 -0
  179. gini/ui/assets/cue/cost.png +0 -0
  180. gini/ui/assets/cue/dark/ai.png +0 -0
  181. gini/ui/assets/cue/dark/canvas.png +0 -0
  182. gini/ui/assets/cue/dark/cloud.png +0 -0
  183. gini/ui/assets/cue/dark/cost.png +0 -0
  184. gini/ui/assets/cue/dark/metrics.png +0 -0
  185. gini/ui/assets/cue/dark/router.png +0 -0
  186. gini/ui/assets/cue/dark/run.png +0 -0
  187. gini/ui/assets/cue/dark/serverless.png +0 -0
  188. gini/ui/assets/cue/dark/settings.png +0 -0
  189. gini/ui/assets/cue/dark/welcome.png +0 -0
  190. gini/ui/assets/cue/dark/wizard.png +0 -0
  191. gini/ui/assets/cue/ginibrand/ai.png +0 -0
  192. gini/ui/assets/cue/ginibrand/canvas.png +0 -0
  193. gini/ui/assets/cue/ginibrand/cloud.png +0 -0
  194. gini/ui/assets/cue/ginibrand/cost.png +0 -0
  195. gini/ui/assets/cue/ginibrand/metrics.png +0 -0
  196. gini/ui/assets/cue/ginibrand/router.png +0 -0
  197. gini/ui/assets/cue/ginibrand/run.png +0 -0
  198. gini/ui/assets/cue/ginibrand/serverless.png +0 -0
  199. gini/ui/assets/cue/ginibrand/settings.png +0 -0
  200. gini/ui/assets/cue/ginibrand/welcome.png +0 -0
  201. gini/ui/assets/cue/ginibrand/wizard.png +0 -0
  202. gini/ui/assets/cue/highcontrast/ai.png +0 -0
  203. gini/ui/assets/cue/highcontrast/canvas.png +0 -0
  204. gini/ui/assets/cue/highcontrast/cloud.png +0 -0
  205. gini/ui/assets/cue/highcontrast/cost.png +0 -0
  206. gini/ui/assets/cue/highcontrast/metrics.png +0 -0
  207. gini/ui/assets/cue/highcontrast/router.png +0 -0
  208. gini/ui/assets/cue/highcontrast/run.png +0 -0
  209. gini/ui/assets/cue/highcontrast/serverless.png +0 -0
  210. gini/ui/assets/cue/highcontrast/settings.png +0 -0
  211. gini/ui/assets/cue/highcontrast/welcome.png +0 -0
  212. gini/ui/assets/cue/highcontrast/wizard.png +0 -0
  213. gini/ui/assets/cue/light/ai.png +0 -0
  214. gini/ui/assets/cue/light/canvas.png +0 -0
  215. gini/ui/assets/cue/light/cloud.png +0 -0
  216. gini/ui/assets/cue/light/cost.png +0 -0
  217. gini/ui/assets/cue/light/metrics.png +0 -0
  218. gini/ui/assets/cue/light/router.png +0 -0
  219. gini/ui/assets/cue/light/run.png +0 -0
  220. gini/ui/assets/cue/light/serverless.png +0 -0
  221. gini/ui/assets/cue/light/settings.png +0 -0
  222. gini/ui/assets/cue/light/welcome.png +0 -0
  223. gini/ui/assets/cue/light/wizard.png +0 -0
  224. gini/ui/assets/cue/metrics.png +0 -0
  225. gini/ui/assets/cue/router.png +0 -0
  226. gini/ui/assets/cue/run.png +0 -0
  227. gini/ui/assets/cue/serverless.png +0 -0
  228. gini/ui/assets/cue/settings.png +0 -0
  229. gini/ui/assets/cue/welcome.png +0 -0
  230. gini/ui/assets/cue/wizard.png +0 -0
  231. gini/ui/assistant.py +2111 -0
  232. gini/ui/author_dialog.py +184 -0
  233. gini/ui/board_dialog.py +247 -0
  234. gini/ui/branding.py +21 -0
  235. gini/ui/canvas.py +2007 -0
  236. gini/ui/chat_panel.py +7 -0
  237. gini/ui/cpu_journey.py +212 -0
  238. gini/ui/cpu_lab.py +306 -0
  239. gini/ui/cue_cards.py +214 -0
  240. gini/ui/dashboard.py +222 -0
  241. gini/ui/diagnose_game.py +336 -0
  242. gini/ui/fingerprint_lab.py +219 -0
  243. gini/ui/flash_dialog.py +244 -0
  244. gini/ui/flow_layout.py +63 -0
  245. gini/ui/fragment_manager.py +1415 -0
  246. gini/ui/game_catalog.py +184 -0
  247. gini/ui/game_renderers.py +340 -0
  248. gini/ui/games_lab.py +90 -0
  249. gini/ui/inspector.py +1055 -0
  250. gini/ui/live_metrics.py +130 -0
  251. gini/ui/machine_lab.py +1412 -0
  252. gini/ui/main_window.py +3153 -0
  253. gini/ui/memory_lab.py +371 -0
  254. gini/ui/mission_panel.py +302 -0
  255. gini/ui/mode_indicator.py +227 -0
  256. gini/ui/palette.py +112 -0
  257. gini/ui/peripherals.py +218 -0
  258. gini/ui/process_tree.py +130 -0
  259. gini/ui/reset_dialog.py +179 -0
  260. gini/ui/router_lab.py +776 -0
  261. gini/ui/run_button.py +183 -0
  262. gini/ui/settings_dialog.py +234 -0
  263. gini/ui/signin_dialog.py +111 -0
  264. gini/ui/storage_lab.py +219 -0
  265. gini/ui/syscall_builder.py +235 -0
  266. gini/ui/syscall_lab.py +152 -0
  267. gini/ui/theme/__init__.py +5 -0
  268. gini/ui/theme/icons.py +145 -0
  269. gini/ui/theme/manager.py +291 -0
  270. gini/ui/theme/tokens.py +194 -0
  271. gini/ui/trap_lab.py +270 -0
  272. gini/ui/worker_host.py +102 -0
  273. gini/ui/zoo_lab.py +112 -0
  274. gini_toolkit-6.0.1.dev0.dist-info/METADATA +77 -0
  275. gini_toolkit-6.0.1.dev0.dist-info/RECORD +278 -0
  276. gini_toolkit-6.0.1.dev0.dist-info/WHEEL +5 -0
  277. gini_toolkit-6.0.1.dev0.dist-info/entry_points.txt +3 -0
  278. gini_toolkit-6.0.1.dev0.dist-info/top_level.txt +1 -0
gini/agent/recall.py ADDED
@@ -0,0 +1,248 @@
1
+ """Hybrid recall — the layered retriever behind Ask GINI.
2
+
3
+ Given a student's question, find the GINI concept notes and recipes that ground the answer,
4
+ and report how CONFIDENT the match is (`strength`) so the reasoner can pick a grounding
5
+ stance. Three layers, cheap-first, each engaged only when the previous is thin:
6
+
7
+ L0 lexical — idf-weighted coverage over concepts+recipes, using `domain.lexicon` to
8
+ normalize student phrasing into GINI vocabulary (always runs, no deps);
9
+ L1 expand — if L0 is thin AND a model is available, ask it to rewrite the query into
10
+ GINI keywords, then re-run L0 (reuses the existing LLM; no embeddings);
11
+ L2 embed — if still empty AND an embedder is available, semantic recall over the
12
+ precomputed KB index (see `agent.embed`), which catches genuine synonyms
13
+ the finite lexicon can't anticipate ("software defined" ↔ SDN).
14
+
15
+ `strength ∈ {strong, thin, empty}` drives `ask.grounding_stance`. Pure/deterministic except
16
+ the injected `llm` (L1) and `embedder` (L2), so L0 is fully unit-testable offline.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import math
22
+ import re
23
+ from dataclasses import dataclass, field
24
+
25
+ from ..domain import concepts as _concepts
26
+ from ..domain import lexicon as _lex
27
+ from ..domain import recipes as _recipes
28
+
29
+ # strength thresholds on best idf-weighted coverage of the query's content terms
30
+ _STRONG = 0.45
31
+ _THIN = 0.15
32
+
33
+
34
+ @dataclass
35
+ class Hit:
36
+ kind: str # concept | recipe
37
+ key: str
38
+ obj: object
39
+ score: float # idf-weighted match mass (for ranking)
40
+ coverage: float # fraction of the query's idf mass that matched (for strength)
41
+
42
+
43
+ @dataclass
44
+ class RecallResult:
45
+ concept_hits: list[Hit] = field(default_factory=list)
46
+ recipe_hits: list[Hit] = field(default_factory=list)
47
+ strength: str = "empty"
48
+ used_expansion: bool = False
49
+ used_embedding: bool = False
50
+
51
+ @property
52
+ def concepts(self) -> list:
53
+ return [h.obj for h in self.concept_hits]
54
+
55
+ @property
56
+ def recipes(self) -> list:
57
+ return [h.obj for h in self.recipe_hits]
58
+
59
+
60
+ # -- corpus + idf (built once) --------------------------------------------- #
61
+ def _concept_text(c) -> str:
62
+ return " ".join((c.title, " ".join(c.keywords), " ".join(c.elements), c.body[:400]))
63
+
64
+
65
+ def _recipe_text(r) -> str:
66
+ return " ".join((r.name, " ".join(r.intent), r.summary, r.teaches))
67
+
68
+
69
+ @dataclass
70
+ class _Doc:
71
+ kind: str
72
+ key: str
73
+ obj: object
74
+ tokens: frozenset
75
+
76
+
77
+ def _build_docs() -> list[_Doc]:
78
+ docs: list[_Doc] = []
79
+ for c in _concepts.CONCEPTS:
80
+ docs.append(_Doc("concept", c.key, c, frozenset(_lex.normalize(_concept_text(c)))))
81
+ for r in _recipes.RECIPES:
82
+ docs.append(_Doc("recipe", r.id, r, frozenset(_lex.normalize(_recipe_text(r)))))
83
+ return docs
84
+
85
+
86
+ _DOCS: list[_Doc] = _build_docs()
87
+ _N = len(_DOCS)
88
+
89
+
90
+ def _idf_table() -> dict[str, float]:
91
+ df: dict[str, int] = {}
92
+ for d in _DOCS:
93
+ for t in d.tokens:
94
+ df[t] = df.get(t, 0) + 1
95
+ return {t: math.log((_N + 1) / (n + 0.5)) for t, n in df.items()}
96
+
97
+
98
+ _IDF = _idf_table()
99
+ _IDF_UNKNOWN = math.log((_N + 1) / 0.5) # a query term absent from the KB is rare = informative
100
+
101
+
102
+ def _idf(token: str) -> float:
103
+ return _IDF.get(token, _IDF_UNKNOWN)
104
+
105
+
106
+ def _query_tokens(text: str, terms) -> list[str]:
107
+ toks = _lex.normalize(text, query=True)
108
+ if terms:
109
+ for extra in _lex.normalize(" ".join(str(t) for t in terms)):
110
+ if extra not in toks:
111
+ toks.append(extra)
112
+ return toks
113
+
114
+
115
+ def _score(qtokens, doc: _Doc) -> tuple[float, float]:
116
+ """(ranking score, coverage) of a query against one doc."""
117
+ if not qtokens:
118
+ return 0.0, 0.0
119
+ matched = [t for t in qtokens if t in doc.tokens]
120
+ score = sum(_idf(t) for t in matched)
121
+ denom = sum(_idf(t) for t in qtokens)
122
+ coverage = (score / denom) if denom else 0.0
123
+ return score, coverage
124
+
125
+
126
+ def _rank(qtokens, kind: str) -> list[Hit]:
127
+ hits: list[Hit] = []
128
+ for d in _DOCS:
129
+ if d.kind != kind:
130
+ continue
131
+ score, cov = _score(qtokens, d)
132
+ if score > 0:
133
+ hits.append(Hit(kind, d.key, d.obj, score, cov))
134
+ hits.sort(key=lambda h: -h.score)
135
+ return hits
136
+
137
+
138
+ def _strength(*hit_lists) -> str:
139
+ best = max((h.coverage for hits in hit_lists for h in hits), default=0.0)
140
+ if best >= _STRONG:
141
+ return "strong"
142
+ if best >= _THIN:
143
+ return "thin"
144
+ return "empty"
145
+
146
+
147
+ # -- public L0 API ---------------------------------------------------------- #
148
+ def search_concepts(text: str, terms=None) -> list[Hit]:
149
+ return _rank(_query_tokens(text, terms or []), "concept")
150
+
151
+
152
+ def search_recipes(text: str, terms=None) -> list[Hit]:
153
+ return _rank(_query_tokens(text, terms or []), "recipe")
154
+
155
+
156
+ def best_concept(text: str) -> tuple[object | None, str]:
157
+ """Top concept + its strength — used by the offline 'explain <topic>' path."""
158
+ hits = search_concepts(text)
159
+ if not hits:
160
+ return None, "empty"
161
+ return hits[0].obj, _strength(hits[:1])
162
+
163
+
164
+ # -- L1 expansion ----------------------------------------------------------- #
165
+ _EXPAND_PROMPT = (
166
+ "A student in a networks/cloud lab asked: {q!r}. Reply with ONLY a JSON array of up to 6 "
167
+ "lowercase keywords naming the networking/cloud/OS topic (e.g. [\"load balancer\", "
168
+ "\"round robin\"]). No prose."
169
+ )
170
+ _expand_cache: dict[str, list[str]] = {}
171
+
172
+
173
+ def _parse_array(text: str) -> list[str]:
174
+ m = re.search(r"\[.*?\]", text or "", re.S)
175
+ if not m:
176
+ return []
177
+ try:
178
+ arr = json.loads(m.group(0))
179
+ except json.JSONDecodeError:
180
+ return []
181
+ return [str(x).strip() for x in arr if isinstance(x, (str, int)) and str(x).strip()]
182
+
183
+
184
+ def expand_query(text: str, llm) -> list[str]:
185
+ """Ask the model to name the topic in GINI-ish keywords (cached). Returns [] on any error
186
+ so the caller degrades to L0."""
187
+ key = (text or "").strip().lower()
188
+ if key in _expand_cache:
189
+ return _expand_cache[key]
190
+ out: list[str] = []
191
+ try:
192
+ out = _parse_array(llm(_EXPAND_PROMPT.format(q=text)))
193
+ except Exception:
194
+ out = []
195
+ _expand_cache[key] = out
196
+ return out
197
+
198
+
199
+ # -- L2 embedding ----------------------------------------------------------- #
200
+ def _obj_for(doc_id: str):
201
+ kind, _, key = doc_id.partition(":")
202
+ if kind == "concept":
203
+ return "concept", key, _concepts.by_key(key)
204
+ if kind == "recipe":
205
+ return "recipe", key, _recipes.get_recipe(key)
206
+ return kind, key, None
207
+
208
+
209
+ # -- the orchestrator ------------------------------------------------------- #
210
+ def recall(text: str, terms=None, *, llm=None, embedder=None, deep: bool = False) -> RecallResult:
211
+ """Run L0 → (L1 if thin) → (L2 if empty) and return ranked hits + strength."""
212
+ terms = list(terms or [])
213
+ chits = search_concepts(text, terms)
214
+ rhits = search_recipes(text, terms)
215
+ strength = _strength(chits, rhits)
216
+ used_expansion = used_embedding = False
217
+
218
+ if strength != "strong" and llm is not None:
219
+ extra = expand_query(text, llm)
220
+ if extra:
221
+ terms = terms + extra
222
+ chits = search_concepts(text, terms)
223
+ rhits = search_recipes(text, terms)
224
+ new_strength = _strength(chits, rhits)
225
+ used_expansion = new_strength != strength or bool(extra)
226
+ strength = new_strength
227
+
228
+ if strength == "empty" and embedder is not None and getattr(embedder, "available", lambda: False)():
229
+ seen_c = {h.key for h in chits}
230
+ seen_r = {h.key for h in rhits}
231
+ for doc_id, escore in embedder.query(text, k=4):
232
+ kind, key, obj = _obj_for(doc_id)
233
+ if obj is None:
234
+ continue
235
+ if kind == "concept" and key not in seen_c:
236
+ chits.append(Hit("concept", key, obj, float(escore), float(escore)))
237
+ seen_c.add(key)
238
+ used_embedding = True
239
+ elif kind == "recipe" and key not in seen_r:
240
+ rhits.append(Hit("recipe", key, obj, float(escore), float(escore)))
241
+ seen_r.add(key)
242
+ used_embedding = True
243
+ if used_embedding:
244
+ chits.sort(key=lambda h: -h.score)
245
+ rhits.sort(key=lambda h: -h.score)
246
+ strength = "thin" # semantic-only matches are treated as partial grounding
247
+
248
+ return RecallResult(chits, rhits, strength, used_expansion, used_embedding)
gini/agent/session.py ADDED
@@ -0,0 +1,79 @@
1
+ """Session accumulator — the agent's working *domain* memory.
2
+
3
+ Distinct from dialogue history (which is turns of conversation): this holds the GINI
4
+ knowledge CARDS retrieved so far this session, so the agent builds up understanding and can
5
+ reason over earlier facts on a later, deeper question. Cards are deduped by (kind, key) and
6
+ kept most-recent-last. When the block outgrows its budget the oldest cards are folded into a
7
+ running summary via a small-LLM pass (the chosen compaction) — with a bounded rule-based
8
+ fallback when no model is available.
9
+
10
+ Pure logic + an injectable `llm` callable(prompt)->str, so it's testable offline.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from .kb import Card
15
+
16
+ _SUMMARY_PROMPT = (
17
+ "Compress these GINI facts a student has already seen this session into a few compact "
18
+ "sentences. Keep element names and the key 'how it works' points; drop repetition. "
19
+ "Reply with only the summary.\n\n"
20
+ )
21
+
22
+
23
+ class SessionKnowledge:
24
+ def __init__(self, max_chars: int = 6000, keep_recent: int = 8) -> None:
25
+ self.cards: list[Card] = [] # most-recent last
26
+ self.summary: str = ""
27
+ self.max_chars = max_chars
28
+ self.keep_recent = keep_recent
29
+
30
+ # -- accumulate --------------------------------------------------------- #
31
+ def add(self, cards, llm=None) -> None:
32
+ """Fold newly-retrieved cards in (dedup by kind+key, move-to-end), then compact."""
33
+ for c in cards or []:
34
+ self.cards = [x for x in self.cards if (x.kind, x.key) != (c.kind, c.key)]
35
+ self.cards.append(c)
36
+ self.compact(llm)
37
+
38
+ def _size(self) -> int:
39
+ return len(self.summary) + sum(len(c.text) for c in self.cards)
40
+
41
+ # -- compaction --------------------------------------------------------- #
42
+ def compact(self, llm=None) -> None:
43
+ """Keep the block under budget by summarising the oldest cards into `summary`."""
44
+ guard = 0
45
+ while self._size() > self.max_chars and len(self.cards) > 1 and guard < 20:
46
+ guard += 1
47
+ keep = min(self.keep_recent, max(1, len(self.cards) - 1))
48
+ overflow = self.cards[:-keep]
49
+ if not overflow: # recent cards alone exceed budget
50
+ overflow = self.cards[:1]
51
+ self.summary = self._summarize(self.summary,
52
+ "\n".join(c.text for c in overflow), llm)
53
+ self.cards = self.cards[len(overflow):]
54
+ # last resort: even the summary is too big
55
+ if len(self.summary) > self.max_chars:
56
+ self.summary = self.summary[-self.max_chars:]
57
+
58
+ def _summarize(self, prior: str, new: str, llm) -> str:
59
+ if llm is not None:
60
+ try:
61
+ out = (llm(_SUMMARY_PROMPT + (prior + "\n" if prior else "") + new) or "").strip()
62
+ if out:
63
+ return out
64
+ except Exception:
65
+ pass
66
+ combined = (prior + " " + new).strip()
67
+ return combined[-(self.max_chars // 2):] # bounded rule-based fallback
68
+
69
+ # -- render ------------------------------------------------------------- #
70
+ def as_context(self) -> str:
71
+ parts = []
72
+ if self.summary:
73
+ parts.append("Session knowledge so far: " + self.summary)
74
+ if self.cards:
75
+ parts.append("\n".join("- " + c.text for c in self.cards))
76
+ return "\n".join(parts)
77
+
78
+ def clear(self) -> None:
79
+ self.cards, self.summary = [], ""