karcytics-sdk 2.2.0__tar.gz → 2.3.0__tar.gz

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 (108) hide show
  1. {karcytics_sdk-2.2.0/src/karcytics_sdk.egg-info → karcytics_sdk-2.3.0}/PKG-INFO +1 -1
  2. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/pyproject.toml +1 -1
  3. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/plugin/academy.py +67 -1
  4. karcytics_sdk-2.3.0/src/karcytics_sdk/plugin/question_panel.py +214 -0
  5. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/plugin/tutorial_models.py +87 -7
  6. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/plugin/tutorial_overlay.py +57 -0
  7. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0/src/karcytics_sdk.egg-info}/PKG-INFO +1 -1
  8. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk.egg-info/SOURCES.txt +1 -0
  9. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/LICENSE +0 -0
  10. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/README.md +0 -0
  11. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/setup.cfg +0 -0
  12. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/__init__.py +0 -0
  13. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/cli/__init__.py +0 -0
  14. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/cli/commands/__init__.py +0 -0
  15. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/cli/commands/diagnostics.py +0 -0
  16. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/cli/commands/migrate.py +0 -0
  17. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/cli/commands/scaffold.py +0 -0
  18. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/cli/commands/security.py +0 -0
  19. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/cli/main.py +0 -0
  20. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/contrib/__init__.py +0 -0
  21. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/contrib/image_utils.py +0 -0
  22. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/host/__init__.py +0 -0
  23. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/host/ai.py +0 -0
  24. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/host/core_services.py +0 -0
  25. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/host/docs.py +0 -0
  26. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/host/marketplace_cache.py +0 -0
  27. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/host/module_status_widget.py +0 -0
  28. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/host/qt_bridge.py +0 -0
  29. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/host/sign_plugin.py +0 -0
  30. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/host/trust_manager.py +0 -0
  31. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/host/trust_overrides.py +0 -0
  32. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/host/trust_path.py +0 -0
  33. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/host/trust_storage.py +0 -0
  34. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/interfaces/__init__.py +0 -0
  35. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/interfaces/i_crash_reporter.py +0 -0
  36. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/interfaces/i_event_bus.py +0 -0
  37. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/interfaces/i_logger.py +0 -0
  38. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/interfaces/i_task_scheduler.py +0 -0
  39. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/plugin/__init__.py +0 -0
  40. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/plugin/academy_driver.py +0 -0
  41. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/plugin/academy_window.py +0 -0
  42. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/plugin/analysis.py +0 -0
  43. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/plugin/autosave.py +0 -0
  44. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/plugin/base.py +0 -0
  45. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/plugin/components.py +0 -0
  46. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/plugin/context.py +0 -0
  47. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/plugin/course_complete_overlay.py +0 -0
  48. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/plugin/cyto_character.py +0 -0
  49. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/plugin/cyto_costumes.py +0 -0
  50. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/plugin/daemon.py +0 -0
  51. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/plugin/dialogs.py +0 -0
  52. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/plugin/draggable.py +0 -0
  53. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/plugin/effects.py +0 -0
  54. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/plugin/events.py +0 -0
  55. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/plugin/galactic_loader.py +0 -0
  56. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/plugin/galactic_loader.qml +0 -0
  57. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/plugin/history.py +0 -0
  58. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/plugin/interfaces.py +0 -0
  59. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/plugin/io.py +0 -0
  60. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/plugin/logging.py +0 -0
  61. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/plugin/managed_task.py +0 -0
  62. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/plugin/manifest.py +0 -0
  63. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/plugin/manifest_parser.py +0 -0
  64. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/plugin/menu_builder.py +0 -0
  65. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/plugin/preferences.py +0 -0
  66. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/plugin/rendering/__init__.py +0 -0
  67. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/plugin/rendering/graphics_scene.py +0 -0
  68. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/plugin/rendering/lock.py +0 -0
  69. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/plugin/rendering/mpl_canvas.py +0 -0
  70. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/plugin/rendering/pipeline.py +0 -0
  71. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/plugin/ribbon.py +0 -0
  72. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/plugin/runtime_services.py +0 -0
  73. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/plugin/security_parser.py +0 -0
  74. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/plugin/signals.py +0 -0
  75. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/plugin/state.py +0 -0
  76. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/plugin/theme_fallback.py +0 -0
  77. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/plugin/toast.py +0 -0
  78. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/plugin/ui_daemon_runtime.py +0 -0
  79. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/plugin/ui_preferences.py +0 -0
  80. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/plugin/update_check.py +0 -0
  81. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/plugin/validation.py +0 -0
  82. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/plugin/wizard.py +0 -0
  83. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/plugin/worker_thread.py +0 -0
  84. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/plugin/workflow.py +0 -0
  85. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/py.typed +0 -0
  86. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/sdk_cli.py +0 -0
  87. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/testing/__init__.py +0 -0
  88. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk/testing/contract.py +0 -0
  89. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk.egg-info/dependency_links.txt +0 -0
  90. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk.egg-info/entry_points.txt +0 -0
  91. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk.egg-info/requires.txt +0 -0
  92. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/src/karcytics_sdk.egg-info/top_level.txt +0 -0
  93. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/tests/test_host_ai.py +0 -0
  94. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/tests/test_host_security.py +0 -0
  95. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/tests/test_image_utils.py +0 -0
  96. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/tests/test_marketplace_cache.py +0 -0
  97. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/tests/test_namespaces.py +0 -0
  98. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/tests/test_plugin_architecture.py +0 -0
  99. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/tests/test_plugin_ribbon.py +0 -0
  100. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/tests/test_plugin_state.py +0 -0
  101. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/tests/test_plugin_ui.py +0 -0
  102. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/tests/test_sdk_cli.py +0 -0
  103. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/tests/test_sign_plugin.py +0 -0
  104. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/tests/test_testing_contract.py +0 -0
  105. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/tests/test_theme_fallback.py +0 -0
  106. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/tests/test_ui_daemon_runtime.py +0 -0
  107. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/tests/test_workflow_context.py +0 -0
  108. {karcytics_sdk-2.2.0 → karcytics_sdk-2.3.0}/tests/test_workflow_dialogs.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: karcytics-sdk
3
- Version: 2.2.0
3
+ Version: 2.3.0
4
4
  Summary: Software Development Kit and CLI for Karcytics.
5
5
  Author: Kalaimaran Balasothy
6
6
  License: # PolyForm Noncommercial License 1.0.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "karcytics-sdk"
7
- version = "2.2.0"
7
+ version = "2.3.0"
8
8
  description = "Software Development Kit and CLI for Karcytics."
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -21,16 +21,27 @@ string directly as its topic key.
21
21
  from __future__ import annotations
22
22
 
23
23
  import logging
24
+ import re
24
25
  from collections.abc import Callable
25
26
  from datetime import UTC, datetime
26
27
  from pathlib import Path
27
28
  from typing import Any, Protocol, runtime_checkable
28
29
 
29
30
  from .io import load_json, save_json
30
- from .tutorial_models import BaseStep, Course, ForcedInteractionStep, WaitForEventStep
31
+ from .tutorial_models import (
32
+ QUESTION_CHECK,
33
+ BaseStep,
34
+ Course,
35
+ ForcedInteractionStep,
36
+ QuestionStep,
37
+ WaitForEventStep,
38
+ )
31
39
 
32
40
  logger = logging.getLogger(__name__)
33
41
 
42
+ # `{answer:<question_id>}` in a step's text → the learner's recorded answer.
43
+ _ANSWER_PLACEHOLDER = re.compile(r"\{answer:([A-Za-z0-9_.-]+)\}")
44
+
34
45
  # Canonical topic names. These match the Hub's `KarcyticsEvent` enum member
35
46
  # names exactly (see `karcytics.core.event_bus`) so a Hub-side event bus
36
47
  # adapter can resolve them with a plain `KarcyticsEvent[topic]` lookup.
@@ -90,6 +101,13 @@ class AcademyManager:
90
101
  self.completed_courses: list[str] = []
91
102
  self.badges: list[dict[str, Any]] = []
92
103
  self.prerequisites_met: dict[str, str] = {} # course_id -> workflow_hash
104
+ # course_id -> question_id -> {"choices": [...], "attempts": n, "correct": bool}.
105
+ # Local only (progress.json); lets a later step — even in a later
106
+ # course — reveal a prediction the learner made.
107
+ self.answers: dict[str, dict[str, dict[str, Any]]] = {}
108
+ # Whether the live QuestionStep has been answered (correctly, for a
109
+ # "check" question); next_step() won't leave it otherwise.
110
+ self._question_answered = False
93
111
 
94
112
  # State tracking
95
113
  self.active_course: Course | None = None
@@ -121,6 +139,7 @@ class AcademyManager:
121
139
  self.completed_courses = data.get("completed_courses", [])
122
140
  self.badges = data.get("badges", [])
123
141
  self.prerequisites_met = data.get("prerequisites_met", {})
142
+ self.answers = data.get("answers", {})
124
143
 
125
144
  def _save_progress(self) -> None:
126
145
  """Saves current progress to disk."""
@@ -128,6 +147,7 @@ class AcademyManager:
128
147
  "completed_courses": self.completed_courses,
129
148
  "badges": self.badges,
130
149
  "prerequisites_met": self.prerequisites_met,
150
+ "answers": self.answers,
131
151
  }
132
152
  try:
133
153
  save_json(str(self.progress_file), data)
@@ -167,6 +187,7 @@ class AcademyManager:
167
187
 
168
188
  if course.steps:
169
189
  self.current_step = course.steps[0]
190
+ self._question_answered = False
170
191
  self.active_subtask_progress = {}
171
192
  # Subscribe immediately if the first step is a WaitForEventStep
172
193
  if isinstance(self.current_step, WaitForEventStep):
@@ -202,6 +223,10 @@ class AcademyManager:
202
223
  logger.warning("Cannot advance: not all sub-tasks completed.")
203
224
  return
204
225
 
226
+ if isinstance(self.current_step, QuestionStep) and not self._question_answered:
227
+ logger.warning("Cannot advance: question not answered yet.")
228
+ return
229
+
205
230
  if isinstance(self.current_step, WaitForEventStep) and not _internal_force and specific_step_id is None:
206
231
  logger.warning(f"Cannot advance: waiting for event {self.current_step.event_name}")
207
232
  return
@@ -222,6 +247,7 @@ class AcademyManager:
222
247
  self.abandon_course()
223
248
  return
224
249
 
250
+ self._question_answered = False
225
251
  if next_id and next_id != "__complete__":
226
252
  self.current_step = self.active_course.get_step(next_id) if self.active_course else None
227
253
  if self.current_step:
@@ -237,6 +263,46 @@ class AcademyManager:
237
263
  self.current_step = None
238
264
  self._emit_step_changed()
239
265
 
266
+ # ── Questions ──────────────────────────────────────────────────────────
267
+
268
+ def record_answer(self, selected: list[int], attempts: int) -> bool:
269
+ """Records the learner's answer to the live `QuestionStep`.
270
+
271
+ Returns whether it answers the question (any selection answers a
272
+ prediction). Only a correct answer unlocks `next_step()`; every
273
+ answer is saved, so attempts count how many tries it took.
274
+ """
275
+ step = self.current_step
276
+ if not self.active_course or not isinstance(step, QuestionStep) or self.is_reviewing:
277
+ return False
278
+ correct = step.is_correct(set(selected))
279
+ if correct:
280
+ self._question_answered = True
281
+ if step.question_id:
282
+ self.answers.setdefault(self.active_course.id, {})[step.question_id] = {
283
+ "choices": [step.choices[i].text for i in sorted(selected)],
284
+ "attempts": attempts,
285
+ "correct": correct if step.kind == QUESTION_CHECK else None,
286
+ }
287
+ self._save_progress()
288
+ return correct
289
+
290
+ def recorded_answer(self, question_id: str) -> str | None:
291
+ """The learner's recorded answer to `question_id`, from any course."""
292
+ for per_course in self.answers.values():
293
+ entry = per_course.get(question_id)
294
+ if entry and entry.get("choices"):
295
+ return " / ".join(entry["choices"])
296
+ return None
297
+
298
+ def format_step_text(self, text: str) -> str:
299
+ """Replaces each `{answer:<question_id>}` with the recorded answer.
300
+
301
+ An unanswered placeholder becomes "(not answered)" rather than
302
+ leaking the raw placeholder into the bubble.
303
+ """
304
+ return _ANSWER_PLACEHOLDER.sub(lambda m: self.recorded_answer(m.group(1)) or "(not answered)", text)
305
+
240
306
  def complete_subtask(self, subtask_id: str) -> None:
241
307
  """Marks a sub-task as complete for the current step."""
242
308
  if not self.active_course or not isinstance(self.current_step, ForcedInteractionStep):
@@ -0,0 +1,214 @@
1
+ """The answer area of a `QuestionStep`, shown inside Cyto's bubble.
2
+
3
+ Kept out of `tutorial_overlay.py` (already the overlay's layout, masking and
4
+ placement engine): this is only the choices, the feedback line, and the
5
+ check → retry → reveal logic. The overlay supplies `check`, which records the
6
+ answer with the `AcademyManager` and says whether it was correct, and turns
7
+ the footer's Check button into Continue when `answered` fires.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Callable
13
+
14
+ from PyQt6.QtCore import Qt, pyqtSignal
15
+ from PyQt6.QtWidgets import (
16
+ QAbstractButton,
17
+ QButtonGroup,
18
+ QCheckBox,
19
+ QFrame,
20
+ QHBoxLayout,
21
+ QLabel,
22
+ QRadioButton,
23
+ QVBoxLayout,
24
+ QWidget,
25
+ )
26
+
27
+ from .theme_fallback import theme_manager
28
+ from .tutorial_models import QUESTION_PREDICT, QuestionStep
29
+
30
+ _ROW_QSS = (
31
+ "QFrame#QuestionChoice {"
32
+ " background-color: {BG_MEDIUM}; border: 1px solid {BORDER}; border-radius: 6px;"
33
+ "}"
34
+ 'QFrame#QuestionChoice[state="selected"] { border: 1px solid {ACCENT_PRIMARY}; }'
35
+ 'QFrame#QuestionChoice[state="wrong"] { border: 1px solid {ACCENT_DANGER}; }'
36
+ 'QFrame#QuestionChoice[state="reveal"] { border: 2px dashed {ACCENT_SUCCESS}; }'
37
+ 'QFrame#QuestionChoice[state="correct"] { border: 2px solid {ACCENT_SUCCESS}; }'
38
+ "QFrame#QuestionChoice QLabel { color: {FG_PRIMARY}; background: transparent; border: none; }"
39
+ )
40
+ _FEEDBACK_QSS = (
41
+ "QLabel#QuestionFeedback { padding: 6px 2px; color: {FG_PRIMARY}; }"
42
+ 'QLabel#QuestionFeedback[state="wrong"] { color: {ACCENT_DANGER}; }'
43
+ 'QLabel#QuestionFeedback[state="correct"] { color: {ACCENT_SUCCESS}; }'
44
+ )
45
+ _HINT = "Choose an answer first."
46
+ _REVEAL_NOTE = "The outlined answer is correct — select it to continue."
47
+ _MULTI_MISSING = "Not quite — at least one correct answer is still unselected."
48
+
49
+
50
+ class _ChoiceRow(QFrame):
51
+ """One answer: an indicator plus a word-wrapped label; the whole row clicks."""
52
+
53
+ def __init__(self, text: str, indicator: QAbstractButton, width: int) -> None:
54
+ super().__init__()
55
+ self.setObjectName("QuestionChoice")
56
+ self.setCursor(Qt.CursorShape.PointingHandCursor)
57
+ self.indicator = indicator
58
+ self.label = QLabel(text)
59
+ self.label.setWordWrap(True)
60
+ self.label.setTextFormat(Qt.TextFormat.RichText)
61
+ row = QHBoxLayout(self)
62
+ row.setContentsMargins(10, 8, 10, 8)
63
+ row.setSpacing(8)
64
+ row.addWidget(indicator, 0, Qt.AlignmentFlag.AlignTop)
65
+ row.addWidget(self.label, 1)
66
+ self.setFixedWidth(width)
67
+ theme_manager.apply_style(self, _ROW_QSS)
68
+
69
+ def mousePressEvent(self, event) -> None: # noqa: N802
70
+ if self.indicator.isEnabled():
71
+ # A radio button's toggle() is a no-op when already checked;
72
+ # click() matches what clicking the indicator itself does.
73
+ self.indicator.click()
74
+ super().mousePressEvent(event)
75
+
76
+ def set_state(self, state: str) -> None:
77
+ self.setProperty("state", state)
78
+ style = self.style()
79
+ if style is not None: # re-evaluate [state=...] selectors
80
+ style.unpolish(self)
81
+ style.polish(self)
82
+
83
+
84
+ class QuestionPanel(QWidget):
85
+ """Choices + feedback for one `QuestionStep`.
86
+
87
+ Signals:
88
+ answered: the question is answered (correctly, for a "check"
89
+ question) — the overlay swaps Check for Continue.
90
+ content_changed: feedback/reveal changed the panel's height.
91
+ """
92
+
93
+ answered = pyqtSignal()
94
+ content_changed = pyqtSignal()
95
+
96
+ def __init__(
97
+ self,
98
+ step: QuestionStep,
99
+ check: Callable[[list[int], int], bool],
100
+ width: int,
101
+ parent: QWidget | None = None,
102
+ ) -> None:
103
+ super().__init__(parent)
104
+ self.setObjectName("QuestionPanel")
105
+ self._step = step
106
+ self._check = check
107
+ self.attempts = 0
108
+ self.is_answered = False
109
+
110
+ layout = QVBoxLayout(self)
111
+ layout.setContentsMargins(0, 0, 0, 0)
112
+ layout.setSpacing(6)
113
+
114
+ self._group = QButtonGroup(self)
115
+ self._group.setExclusive(not step.multi_select)
116
+ self.rows: list[_ChoiceRow] = []
117
+ for i, choice in enumerate(step.choices):
118
+ indicator: QAbstractButton = QCheckBox() if step.multi_select else QRadioButton()
119
+ indicator.setObjectName(f"QuestionChoiceIndicator_{i}")
120
+ self._group.addButton(indicator, i)
121
+ row = _ChoiceRow(choice.text, indicator, width)
122
+ indicator.toggled.connect(self._on_selection_changed)
123
+ self.rows.append(row)
124
+ layout.addWidget(row)
125
+
126
+ if step.multi_select:
127
+ hint = QLabel("Select all that apply.")
128
+ hint.setObjectName("QuestionMultiHint")
129
+ theme_manager.apply_style(hint, "color: {FG_SECONDARY}; font-size: 12px;")
130
+ layout.insertWidget(0, hint)
131
+
132
+ self.feedback = QLabel("")
133
+ self.feedback.setObjectName("QuestionFeedback")
134
+ self.feedback.setWordWrap(True)
135
+ self.feedback.setTextFormat(Qt.TextFormat.RichText)
136
+ self.feedback.setFixedWidth(width)
137
+ self.feedback.hide()
138
+ theme_manager.apply_style(self.feedback, _FEEDBACK_QSS)
139
+ layout.addWidget(self.feedback)
140
+
141
+ # ── Public API ──────────────────────────────────────────────────────
142
+
143
+ def selected(self) -> list[int]:
144
+ return [i for i, row in enumerate(self.rows) if row.indicator.isChecked()]
145
+
146
+ def submit(self) -> None:
147
+ """The footer's Check button: grade the current selection."""
148
+ if self.is_answered:
149
+ return
150
+ selection = self.selected()
151
+ if not selection:
152
+ self._show_feedback(_HINT, "")
153
+ return
154
+ self.attempts += 1
155
+ if self._check(selection, self.attempts):
156
+ self._on_answered(selection)
157
+ else:
158
+ self._on_wrong(selection)
159
+
160
+ # ── Internals ───────────────────────────────────────────────────────
161
+
162
+ def _on_selection_changed(self) -> None:
163
+ if self.is_answered:
164
+ return
165
+ chosen = set(self.selected())
166
+ revealed = self.attempts >= self._step.reveal_after_attempts
167
+ for i, row in enumerate(self.rows):
168
+ if i in chosen:
169
+ row.set_state("selected")
170
+ elif revealed and i in self._step.correct_indices:
171
+ row.set_state("reveal")
172
+ else:
173
+ row.set_state("")
174
+
175
+ def _on_answered(self, selection: list[int]) -> None:
176
+ self.is_answered = True
177
+ predict = self._step.kind == QUESTION_PREDICT
178
+ for i, row in enumerate(self.rows):
179
+ row.indicator.setEnabled(False)
180
+ row.setCursor(Qt.CursorShape.ArrowCursor)
181
+ if i in selection:
182
+ row.set_state("selected" if predict else "correct")
183
+ parts = [self._step.choices[i].feedback for i in selection if self._step.choices[i].feedback]
184
+ if self._step.explanation:
185
+ parts.append(self._step.explanation)
186
+ prefix = "" if predict else "✓ Correct. "
187
+ self._show_feedback(prefix + " ".join(parts), "" if predict else "correct")
188
+ self.answered.emit()
189
+
190
+ def _on_wrong(self, selection: list[int]) -> None:
191
+ correct = self._step.correct_indices
192
+ wrong_picks = [i for i in selection if i not in correct]
193
+ for i in wrong_picks:
194
+ self.rows[i].set_state("wrong")
195
+ if wrong_picks:
196
+ message = self._step.choices[wrong_picks[0]].feedback
197
+ else: # multi-select with only correct picks, but not all of them
198
+ message = _MULTI_MISSING
199
+ if self.attempts >= self._step.reveal_after_attempts:
200
+ for i in correct:
201
+ if i not in selection:
202
+ self.rows[i].set_state("reveal")
203
+ message = f"{message}<br><br><b>{_REVEAL_NOTE}</b>"
204
+ self._show_feedback(f"✗ {message}", "wrong")
205
+
206
+ def _show_feedback(self, text: str, state: str) -> None:
207
+ self.feedback.setText(text)
208
+ self.feedback.setProperty("state", state)
209
+ style = self.feedback.style()
210
+ if style is not None:
211
+ style.unpolish(self.feedback)
212
+ style.polish(self.feedback)
213
+ self.feedback.show()
214
+ self.content_changed.emit()
@@ -1,12 +1,8 @@
1
1
  """SOLID models and interfaces for the Karcytics Academy tutorial engine.
2
2
 
3
- Vendored from the Hub's `karcytics.core.models.tutorial_models` — these are
4
- pure dataclasses/interfaces with zero dependency on the Hub itself (only
5
- `abc`/`dataclasses`/`typing`), so a plugin building its own Academy course
6
- content can depend on this permanent, real copy directly rather than an
7
- unimportable Hub-internal path. Any plugin author writing course content
8
- imports from here; the Hub's own copy is the authoritative implementation
9
- these mirror.
3
+ Originally vendored from the Hub; this is now the only copy. Pure
4
+ dataclasses/interfaces with no dependency on the Hub (only
5
+ `abc`/`dataclasses`/`typing`), so plugin course content imports from here.
10
6
  """
11
7
 
12
8
  from abc import ABC, abstractmethod
@@ -206,6 +202,86 @@ class ConsentStep(BaseStep):
206
202
  on_decline_step_id: str | None = None
207
203
 
208
204
 
205
+ QUESTION_CHECK = "check"
206
+ QUESTION_PREDICT = "predict"
207
+
208
+
209
+ @dataclass
210
+ class AnswerChoice:
211
+ """One answer to a `QuestionStep`.
212
+
213
+ `feedback` is shown when the learner picks this choice: for a wrong
214
+ choice it says *why* it's wrong (required for "check" questions), for a
215
+ correct one it can add a short confirmation.
216
+ """
217
+
218
+ text: str
219
+ correct: bool = False
220
+ feedback: str = ""
221
+
222
+
223
+ @dataclass
224
+ class QuestionStep(BaseStep):
225
+ """A question answered in Cyto's bubble; `text` is the question.
226
+
227
+ Two kinds:
228
+
229
+ - ``"check"`` (default): the learner must select the correct choice(s)
230
+ to continue. A wrong pick shows its feedback; after
231
+ `reveal_after_attempts` wrong tries the correct choice(s) are
232
+ outlined — they still have to select them.
233
+ - ``"predict"``: any answer is accepted and recorded under
234
+ `question_id`, so a later step can reveal it (``{answer:<id>}`` in a
235
+ step's text is replaced with the learner's choice, across courses).
236
+
237
+ `multi_select` makes it "select all that apply": correct only when the
238
+ selection is exactly the set of correct choices. `explanation` is shown
239
+ once answered, before Continue.
240
+ """
241
+
242
+ choices: list[AnswerChoice] = field(default_factory=list)
243
+ multi_select: bool = False
244
+ kind: str = QUESTION_CHECK
245
+ question_id: str = ""
246
+ explanation: str = ""
247
+ reveal_after_attempts: int = 2
248
+
249
+ def __post_init__(self) -> None:
250
+ """Rejects questions that can't be answered (or can't teach)."""
251
+ if self.kind not in (QUESTION_CHECK, QUESTION_PREDICT):
252
+ raise ValueError(f"{self.id}: kind must be 'check' or 'predict', got {self.kind!r}")
253
+ if len(self.choices) < 2: # noqa: PLR2004
254
+ raise ValueError(f"{self.id}: a question needs at least 2 choices")
255
+ if self.kind == QUESTION_PREDICT:
256
+ if not self.question_id:
257
+ raise ValueError(f"{self.id}: a prediction needs a question_id to reveal it later")
258
+ return
259
+ n_correct = sum(c.correct for c in self.choices)
260
+ if self.multi_select and n_correct < 1:
261
+ raise ValueError(f"{self.id}: select-all question needs at least one correct choice")
262
+ if not self.multi_select and n_correct != 1:
263
+ raise ValueError(f"{self.id}: single-choice question needs exactly one correct choice")
264
+ missing = [c.text for c in self.choices if not c.correct and not c.feedback]
265
+ if missing:
266
+ raise ValueError(f"{self.id}: wrong choices need feedback explaining why: {missing}")
267
+
268
+ @property
269
+ def correct_indices(self) -> frozenset[int]:
270
+ """Indices of the correct choices (empty for a prediction)."""
271
+ return frozenset(i for i, c in enumerate(self.choices) if c.correct)
272
+
273
+ def is_correct(self, selected: set[int] | frozenset[int]) -> bool:
274
+ """Whether `selected` (choice indices) answers the question.
275
+
276
+ Any non-empty selection answers a prediction.
277
+ """
278
+ if not selected:
279
+ return False
280
+ if self.kind == QUESTION_PREDICT:
281
+ return True
282
+ return frozenset(selected) == self.correct_indices
283
+
284
+
209
285
  @dataclass
210
286
  class Course:
211
287
  """A collection of polymorphic steps representing a guided tutorial course."""
@@ -284,5 +360,9 @@ __all__ = [
284
360
  "SubplotCheckStep",
285
361
  "WaitForEventStep",
286
362
  "ConsentStep",
363
+ "QUESTION_CHECK",
364
+ "QUESTION_PREDICT",
365
+ "AnswerChoice",
366
+ "QuestionStep",
287
367
  "Course",
288
368
  ]
@@ -41,6 +41,7 @@ from .academy import (
41
41
  from .cyto_character import CytoWidget
42
42
  from .dialogs import ask_yes_no
43
43
  from .draggable import DraggableMixin
44
+ from .question_panel import QuestionPanel
44
45
  from .theme_fallback import Colors, theme_manager
45
46
  from .tutorial_models import (
46
47
  BaseStep,
@@ -49,6 +50,7 @@ from .tutorial_models import (
49
50
  ForcedInteractionStep,
50
51
  InfoStep,
51
52
  InteractionStep,
53
+ QuestionStep,
52
54
  SubplotCheckStep,
53
55
  VerificationStep,
54
56
  WaitForEventStep,
@@ -137,6 +139,8 @@ class TutorialOverlay(QWidget):
137
139
  self.setAttribute(Qt.WidgetAttribute.WA_NoSystemBackground, True)
138
140
 
139
141
  self.target_rects: list[QRect] = []
142
+ # The live QuestionStep's answer panel, if any (see _render_question).
143
+ self.question_panel: QuestionPanel | None = None
140
144
  # The widget a step's allow_scroll=True should forward wheel events
141
145
  # to (see set_scroll_target()/wheelEvent()) — set by AcademyStepDriver
142
146
  # from the ancestor QScrollArea of the step's own target widget(s),
@@ -442,6 +446,10 @@ class TutorialOverlay(QWidget):
442
446
  self.cyto.apply_theme()
443
447
 
444
448
  def _update_text_rendering(self, text: str) -> None:
449
+ # `{answer:<id>}` placeholders → the learner's recorded answers.
450
+ format_text = getattr(self._academy_manager, "format_step_text", None)
451
+ if callable(format_text):
452
+ text = format_text(text)
445
453
  import re
446
454
 
447
455
  # Replace newlines with <br>
@@ -666,6 +674,10 @@ class TutorialOverlay(QWidget):
666
674
  self.btn_next.hide()
667
675
  self._render_checklist(step)
668
676
 
677
+ elif isinstance(step, QuestionStep):
678
+ self.btn_next.hide()
679
+ self._render_question(step)
680
+
669
681
  elif isinstance(step, SubplotCheckStep):
670
682
  self.btn_next.show()
671
683
  self.btn_next.setText("Confirm Subplot")
@@ -1223,6 +1235,50 @@ class TutorialOverlay(QWidget):
1223
1235
  if remaining_count == 0 and not getattr(self.current_step, "auto_advance_when_complete", False):
1224
1236
  self.btn_next.show()
1225
1237
 
1238
+ def _render_question(self, step: QuestionStep) -> None:
1239
+ """Choices in the bubble; Check in the footer, then Continue once answered."""
1240
+ manager = self._academy_manager
1241
+ panel = QuestionPanel(
1242
+ step,
1243
+ check=lambda selection, attempts: manager.record_answer(selection, attempts),
1244
+ width=CONTENT_WIDTH,
1245
+ )
1246
+ self.question_panel = panel
1247
+ self.dynamic_content.addWidget(panel)
1248
+
1249
+ self._clear_buttons()
1250
+ self.btn_layout.addStretch()
1251
+ self.btn_layout.addWidget(self.btn_previous)
1252
+ self._show_previous_button()
1253
+ btn_check = QPushButton("Check answer")
1254
+ btn_check.setObjectName("QuestionCheckButton")
1255
+ theme_manager.apply_style(
1256
+ btn_check,
1257
+ "background-color: {ACCENT_PRIMARY}; color: white; border: none; border-radius: 4px; padding: 6px 12px; font-weight: bold;",
1258
+ )
1259
+ btn_check.setCursor(Qt.CursorShape.PointingHandCursor)
1260
+ btn_check.clicked.connect(panel.submit)
1261
+ self.btn_layout.addWidget(btn_check)
1262
+ btn_check.show() # see _render_consent_options
1263
+
1264
+ def _on_answered() -> None:
1265
+ btn_check.clicked.disconnect()
1266
+ btn_check.setObjectName("QuestionContinueButton")
1267
+ btn_check.setText("Let's find out →" if step.kind == "predict" else "Continue →")
1268
+ btn_check.clicked.connect(lambda _checked=False: manager.next_step())
1269
+
1270
+ panel.answered.connect(_on_answered)
1271
+ panel.content_changed.connect(self._on_question_content_changed)
1272
+
1273
+ def _on_question_content_changed(self) -> None:
1274
+ """Feedback appeared or grew: re-fit the bubble and re-punch the mask."""
1275
+ if not self._is_alive():
1276
+ return
1277
+ self._force_resize()
1278
+ if not self._user_positioned:
1279
+ self._reposition_cyto_and_bubble(getattr(self, "target_rects", []))
1280
+ self._update_mask()
1281
+
1226
1282
  def _render_branching_options(self, options: dict) -> None:
1227
1283
  """Render branching-option buttons that advance to their selected tutorial steps.
1228
1284
 
@@ -1379,6 +1435,7 @@ class TutorialOverlay(QWidget):
1379
1435
  self._update_mask()
1380
1436
 
1381
1437
  def _clear_dynamic_content(self) -> None:
1438
+ self.question_panel = None
1382
1439
  # Stop any running WaitForEventStep pulse timer
1383
1440
  if hasattr(self, "_wait_pulse_timer") and self._wait_pulse_timer.isActive():
1384
1441
  self._wait_pulse_timer.stop()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: karcytics-sdk
3
- Version: 2.2.0
3
+ Version: 2.3.0
4
4
  Summary: Software Development Kit and CLI for Karcytics.
5
5
  Author: Kalaimaran Balasothy
6
6
  License: # PolyForm Noncommercial License 1.0.0
@@ -64,6 +64,7 @@ src/karcytics_sdk/plugin/manifest.py
64
64
  src/karcytics_sdk/plugin/manifest_parser.py
65
65
  src/karcytics_sdk/plugin/menu_builder.py
66
66
  src/karcytics_sdk/plugin/preferences.py
67
+ src/karcytics_sdk/plugin/question_panel.py
67
68
  src/karcytics_sdk/plugin/ribbon.py
68
69
  src/karcytics_sdk/plugin/runtime_services.py
69
70
  src/karcytics_sdk/plugin/security_parser.py
File without changes
File without changes
File without changes