From f5bebf7b168cd1089125426032ed3df4eb17a901 Mon Sep 17 00:00:00 2001 From: Ashutoshx7 Date: Tue, 23 Jun 2026 21:43:20 +0530 Subject: [PATCH 01/28] Add Activity on Demand: AI-assisted Sugar activity generation Activity on Demand (AOD) lets any learner describe an activity in plain language and receive a working, installable XO bundle. A model-agnostic LLM router, RAG corpus built from real Sugar Activity source code, and AST-based security validator work together to produce genuine, safe Python with proper GTK3 widgets, toolbar scaffolding, and Journal hooks. Backend (src/jarabe/model/aod*.py): - aodspec: ActivitySpec dataclass, structured prompt parsing, validation - aodgenerator: template selection, plan building, project assembly - aodpipeline: full generation pipeline (spec -> RAG -> plan -> codegen -> validate -> assemble), 3 retries with validation feedback to LLM - aodllm: multi-provider support (Gemini, OpenAI, OpenRouter, DeepSeek, Qwen, Moonshot, OpenCode, FreeModel, Claude, Ollama, local-template) - aodrag: keyword-based RAG corpus from real installed Sugar activities - aodvalidator: AST-based security (forbidden imports/calls, class conformance, required methods, prompt-specific checks) - aodcodegen: codegen prompt builder, fenced code extraction, truncated response detection - aodjobs: file-backed job store with state machine - aodservice: local backend service with job queue and session tracking - aodsessions: session store for revision history and chat messages - aodcredentials: API key storage with keyring/profile-file fallback - aodqueue: threaded job queue worker - aodprompts: LLM planner system/user prompt builders - aodtemplates: Sugar activity template archetypes - aodlicenses: SPDX license ID registry Frontend (src/jarabe/desktop/): - homebox.py: AOD panel with prompt screen, generation animation with step cards, studio review with code viewer, live edit, version history - homewindow.py: lazy import hook for AOD panel - viewtoolbar.py: Create with AI toolbar button entry point Tooling: - aod_test_cli.py: CLI harness for testing generation with any provider - aod_benchmark.py: multi-provider benchmark runner - start_sugar_xephyr.sh: nested Xephyr test environment Tests: 80 tests covering spec validation, generator, pipeline, LLM providers, RAG search, validator, prompts, credentials, and service. --- AOD_TEST_README.md | 107 + aod_benchmark.py | 152 + aod_full_test.py | 508 ++ aod_sample_prompts.txt | 5 + aod_test_cli.py | 202 + src/jarabe/desktop/homebox.py | 7100 +++++++++++++++++++++++++++- src/jarabe/desktop/homewindow.py | 3 + src/jarabe/desktop/viewtoolbar.py | 61 + src/jarabe/model/Makefile.am | 15 + src/jarabe/model/aodcodegen.py | 258 + src/jarabe/model/aodcredentials.py | 355 ++ src/jarabe/model/aodgenerator.py | 1010 ++++ src/jarabe/model/aodjobs.py | 233 + src/jarabe/model/aodlicenses.py | 136 + src/jarabe/model/aodllm.py | 1067 +++++ src/jarabe/model/aodpipeline.py | 378 ++ src/jarabe/model/aodprompts.py | 174 + src/jarabe/model/aodqueue.py | 66 + src/jarabe/model/aodrag.py | 383 ++ src/jarabe/model/aodservice.py | 490 ++ src/jarabe/model/aodsessions.py | 249 + src/jarabe/model/aodspec.py | 154 + src/jarabe/model/aodtemplates.py | 1324 ++++++ src/jarabe/model/aodvalidator.py | 437 ++ start_sugar_xephyr.sh | 76 + tests/test_aodcredentials.py | 121 + tests/test_aodgenerator.py | 171 + tests/test_aodllm.py | 716 +++ tests/test_aodpipeline.py | 344 ++ tests/test_aodprompts.py | 39 + tests/test_aodrag.py | 93 + tests/test_aodservice.py | 357 ++ tests/test_aodspec.py | 49 + tests/test_aodvalidator.py | 80 + 34 files changed, 16908 insertions(+), 5 deletions(-) create mode 100644 AOD_TEST_README.md create mode 100755 aod_benchmark.py create mode 100644 aod_full_test.py create mode 100644 aod_sample_prompts.txt create mode 100755 aod_test_cli.py create mode 100644 src/jarabe/model/aodcodegen.py create mode 100644 src/jarabe/model/aodcredentials.py create mode 100644 src/jarabe/model/aodgenerator.py create mode 100644 src/jarabe/model/aodjobs.py create mode 100644 src/jarabe/model/aodlicenses.py create mode 100644 src/jarabe/model/aodllm.py create mode 100644 src/jarabe/model/aodpipeline.py create mode 100644 src/jarabe/model/aodprompts.py create mode 100644 src/jarabe/model/aodqueue.py create mode 100644 src/jarabe/model/aodrag.py create mode 100644 src/jarabe/model/aodservice.py create mode 100644 src/jarabe/model/aodsessions.py create mode 100644 src/jarabe/model/aodspec.py create mode 100644 src/jarabe/model/aodtemplates.py create mode 100644 src/jarabe/model/aodvalidator.py create mode 100755 start_sugar_xephyr.sh create mode 100644 tests/test_aodcredentials.py create mode 100644 tests/test_aodgenerator.py create mode 100644 tests/test_aodllm.py create mode 100644 tests/test_aodpipeline.py create mode 100644 tests/test_aodprompts.py create mode 100644 tests/test_aodrag.py create mode 100644 tests/test_aodservice.py create mode 100644 tests/test_aodspec.py create mode 100644 tests/test_aodvalidator.py diff --git a/AOD_TEST_README.md b/AOD_TEST_README.md new file mode 100644 index 0000000000..baa325b1dd --- /dev/null +++ b/AOD_TEST_README.md @@ -0,0 +1,107 @@ +# Activity-on-Demand — Quick Test Harness + +The Activity-on-Demand (AOD) backend and Home View UI panel are already implemented in this branch. + +- Backend modules: `src/jarabe/model/aod*.py` +- UI panel: `src/jarabe/desktop/homebox.py` (`_CreateAIActivityPanel`) +- Toolbar entry point: `src/jarabe/desktop/viewtoolbar.py` + +## Run the tests + +```bash +PYTHONPATH=src python3 -m pytest tests/test_aod*.py -q +``` + +All 55 AOD tests should pass. + +## Generate an activity from the command line + +### Local template (no API key) + +```bash +python3 aod_test_cli.py \ + --provider local-template \ + --prompt "a drawing activity where I can paint colorful shapes" +``` + +### Gemini + +```bash +GEMINI_API_KEY=YOUR_KEY python3 aod_test_cli.py \ + --provider gemini \ + --model gemini-2.5-flash \ + --prompt "a quiz game about animals for young learners" +``` + +### OpenAI + +```bash +OPENAI_API_KEY=YOUR_KEY python3 aod_test_cli.py \ + --provider openai \ + --model gpt-4.1-mini \ + --prompt "a typing practice activity with word bank" +``` + +### OpenCode Go (Kimi) + +```bash +OPENCODE_API_KEY=YOUR_KEY python3 aod_test_cli.py \ + --provider opencode-go \ + --model kimi-k2.7-code \ + --prompt "a fractions playground where teams build models" +``` + +### Ollama (local) + +```bash +AOD_LLM_PROVIDER=ollama AOD_OLLAMA_MODEL=llama3.1 python3 aod_test_cli.py \ + --provider ollama \ + --prompt "a simple calculator tool for fractions" +``` + +## Benchmark multiple providers/models + +Create a `prompts.txt` file with one prompt per line, then: + +```bash +GEMINI_API_KEY=... OPENCODE_API_KEY=... python3 aod_benchmark.py \ + --providers gemini,opencode-go \ + --models gemini-2.5-flash,kimi-k2.7-code \ + --prompts aod_sample_prompts.txt \ + --output /tmp/aod_benchmark +``` + +Results are written to `benchmark.csv` and `benchmark.json` in the output directory. + +## Check provider status + +```bash +python3 aod_test_cli.py --status +``` + +## Test from Python directly + +```python +import sys +sys.path.insert(0, 'src') + +from jarabe.model.aodspec import ActivitySpec, name_from_prompt +from jarabe.model.aodpipeline import generate_activity +from jarabe.model.aodllm import create_provider + +spec = ActivitySpec( + name=name_from_prompt("a drawing activity where I can paint colorful shapes"), + prompt="a drawing activity where I can paint colorful shapes", + category="creation", + license_id="GPL-3.0-or-later", +) + +provider = create_provider('gemini') # requires GEMINI_API_KEY +result = generate_activity(spec, provider=provider, provider_name='gemini') +print(result.bundle_path) +``` + +## Notes + +- The generated `.xo` bundle is placed under `~/.sugar/default/aod/projects/` by default, or in the directory you pass with `--output`. +- The Home View UI panel can only be fully exercised inside a running Sugar session because it depends on D-Bus, Telepathy, and the full GTK3 desktop stack. diff --git a/aod_benchmark.py b/aod_benchmark.py new file mode 100755 index 0000000000..3cddd381c8 --- /dev/null +++ b/aod_benchmark.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +"""Benchmark Activity-on-Demand across multiple providers/models. + +Example: + GEMINI_API_KEY=... OPENCODE_API_KEY=... PYTHONPATH=src python3 \ + aod_benchmark.py --providers gemini,opencode-go \ + --models gemini-2.5-flash,kimi-k2.7-code \ + --prompts prompts.txt --output /tmp/aod_benchmark +""" + +import argparse +import csv +import json +import os +import sys +import time + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src')) + +from jarabe.model.aodspec import ActivitySpec +from jarabe.model.aodspec import name_from_prompt +from jarabe.model.aodpipeline import generate_activity +from jarabe.model.aodpipeline import PipelineError +from jarabe.model.aodllm import create_provider +from jarabe.model.aodllm import ProviderError + + +def _generate_one(prompt, provider_name, model, output_root): + spec = ActivitySpec( + name=name_from_prompt(prompt), + prompt=prompt, + category='creation', + license_id='GPL-3.0-or-later', + template='auto', + age_band='all', + ) + errors = spec.validate() + if errors: + return {'error': 'spec: %s' % '; '.join(errors)} + + provider = None + if provider_name != 'local-template': + try: + provider = create_provider(provider_name, model=model) + except ProviderError as error: + return {'error': 'provider: %s' % error} + + start = time.time() + try: + result = generate_activity( + spec, + output_root=output_root, + provider=provider, + provider_name=provider_name, + use_rag=True, + ) + except PipelineError as error: + return {'error': 'pipeline: %s' % error} + elapsed = time.time() - start + + return { + 'success': True, + 'elapsed': elapsed, + 'bundle_id': result.bundle_id, + 'project_path': result.project_path, + 'bundle_path': result.bundle_path, + 'provider': result.provider, + 'model': result.model, + 'template': result.plan.get('template'), + 'code_source': result.plan.get('code_source', 'template'), + 'provider_fallback': result.plan.get('provider_fallback_reason', ''), + 'codegen_fallback': result.plan.get('codegen_fallback_reason', ''), + 'codegen_attempts': result.plan.get('codegen_attempts', 0), + } + + +def main(): + parser = argparse.ArgumentParser( + description='Benchmark AOD across providers.', + ) + parser.add_argument('--providers', default='local-template', + help='Comma-separated provider names') + parser.add_argument('--models', default=None, + help='Comma-separated model names (same length as ' + 'providers, or one shared model)') + parser.add_argument('--prompts', required=True, + help='File with one prompt per line') + parser.add_argument('--output', required=True, + help='Output directory for benchmark results') + parser.add_argument('--csv', default='benchmark.csv', + help='CSV output filename') + parser.add_argument('--json', default='benchmark.json', + help='JSON output filename') + + args = parser.parse_args() + + providers = [p.strip() for p in args.providers.split(',') if p.strip()] + if args.models: + models = [m.strip() for m in args.models.split(',') if m.strip()] + if len(models) == 1: + models = models * len(providers) + elif len(models) != len(providers): + print("--models must have one value or same count as --providers") + return 1 + else: + models = [''] * len(providers) + + with open(args.prompts, encoding='utf-8') as f: + prompts = [line.strip() for line in f if line.strip()] + + os.makedirs(args.output, exist_ok=True) + rows = [] + for provider_name, model in zip(providers, models): + for index, prompt in enumerate(prompts, 1): + run_dir = os.path.join( + args.output, + '%s_%s_prompt%d' % (provider_name, model or 'default', index), + ) + os.makedirs(run_dir, exist_ok=True) + print("[%s / %s] prompt %d: %s" % ( + provider_name, model or 'default', index, prompt[:60])) + result = _generate_one(prompt, provider_name, model, run_dir) + rows.append({ + 'provider': provider_name, + 'model': model or '', + 'prompt_index': index, + 'prompt': prompt, + **result, + }) + status = 'OK' if result.get('success') else 'FAIL' + print(" -> %s (%.1fs)" % ( + status, result.get('elapsed', 0.0))) + + csv_path = os.path.join(args.output, args.csv) + with open(csv_path, 'w', newline='', encoding='utf-8') as f: + writer = csv.DictWriter(f, fieldnames=rows[0].keys()) + writer.writeheader() + writer.writerows(rows) + + json_path = os.path.join(args.output, args.json) + with open(json_path, 'w', encoding='utf-8') as f: + json.dump(rows, f, indent=2) + + success = sum(1 for r in rows if r.get('success')) + print("\nBenchmark complete: %d/%d succeeded" % (success, len(rows))) + print("CSV: %s" % csv_path) + print("JSON: %s" % json_path) + return 0 if success == len(rows) else 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/aod_full_test.py b/aod_full_test.py new file mode 100644 index 0000000000..462775f16e --- /dev/null +++ b/aod_full_test.py @@ -0,0 +1,508 @@ +#!/usr/bin/env python3 +"""Comprehensive Activity-on-Demand test and code-structure report.""" + +import os +import sys +import tempfile +import threading +import time +import json + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src')) + +from jarabe.model.aodspec import ActivitySpec +from jarabe.model.aodspec import name_from_prompt +from jarabe.model.aodspec import CATEGORIES +from jarabe.model.aodspec import TEMPLATES +from jarabe.model.aodspec import LICENSE_IDS +from jarabe.model.aodgenerator import build_plan +from jarabe.model.aodgenerator import enrich_plan +from jarabe.model.aodgenerator import infer_template +from jarabe.model.aodgenerator import create_prototype_activity +from jarabe.model.aodgenerator import package_project +from jarabe.model.aodgenerator import normalize_plan +from jarabe.model.aodrag import build_corpus +from jarabe.model.aodrag import search +from jarabe.model.aodrag import get_api_reference +from jarabe.model.aodrag import get_example_sources +from jarabe.model.aodvalidator import validate_source +from jarabe.model.aodvalidator import validate_project +from jarabe.model.aodvalidator import validate_bundle +from jarabe.model.aodvalidator import ALLOWED_IMPORT_ROOTS +from jarabe.model.aodvalidator import FORBIDDEN_IMPORT_ROOTS +from jarabe.model.aodvalidator import FORBIDDEN_CALLS +from jarabe.model.aodllm import LLMProvider +from jarabe.model.aodllm import create_provider +from jarabe.model.aodllm import get_provider_statuses +from jarabe.model.aodllm import get_configured_provider +from jarabe.model.aodllm import normalize_provider_name +from jarabe.model.aodllm import ProviderError +from jarabe.model.aodpipeline import generate_activity +from jarabe.model.aodpipeline import PipelineError +from jarabe.model.aodjobs import AODJob +from jarabe.model.aodjobs import AODJobStore +from jarabe.model.aodjobs import STATUS_FINISHED +from jarabe.model.aodjobs import STATUS_FAILED +from jarabe.model.aodservice import AODService +from jarabe.model.aodcredentials import AODCredentialStore + +PASS = 0 +FAIL = 0 +REPORT = [] + + +def section(title): + REPORT.append('\n=== %s ===' % title) + print('\n=== %s ===' % title) + + +def ok(msg): + global PASS + PASS += 1 + REPORT.append(' PASS: %s' % msg) + print(' PASS: %s' % msg) + + +def fail(msg): + global FAIL + FAIL += 1 + REPORT.append(' FAIL: %s' % msg) + print(' FAIL: %s' % msg) + + +def check(cond, msg): + if cond: + ok(msg) + else: + fail(msg) + + +def test_spec(): + section('ActivitySpec') + + spec = ActivitySpec( + name='Fraction Playground', + prompt='a fractions playground where teams build models', + category='logic_math', + license_id='GPL-3.0-or-later', + template='auto', + age_band='8-10', + learner_goal='Understand equivalent fractions.', + ) + errors = spec.validate() + check(not errors, 'valid spec passes validation') + + bad = ActivitySpec( + name='', + prompt='', + category='invalid', + license_id='invalid', + template='invalid', + age_band='', + ) + errors = bad.validate() + check(len(errors) >= 5, 'invalid spec reports multiple errors (%d)' % len(errors)) + + auto_name = name_from_prompt('a drawing activity where I can paint') + check(auto_name and 'Drawing' in auto_name, 'name_from_prompt extracts keywords: %s' % auto_name) + + check(set(CATEGORIES) == {'logic_math', 'tools_utils', 'games', 'creation'}, + 'CATEGORIES match expected set') + check('auto' in TEMPLATES and 'canvas' in TEMPLATES, + 'TEMPLATES includes auto and canvas') + check('GPL-3.0-or-later' in LICENSE_IDS, 'LICENSE_IDS includes GPL-3.0-or-later') + + +def test_template_inference(): + section('Template Inference') + + cases = [ + ('a drawing canvas for kids', 'creation', 'canvas'), + ('a quiz about animals', 'logic_math', 'quiz'), + ('a chess board for two players', 'games', 'chess'), + ('a story writing activity', 'creation', 'narrative'), + ('a word counter tool', 'tools_utils', 'utility'), + ('something completely vague', 'games', 'grid'), + ] + for prompt, category, expected in cases: + spec = ActivitySpec( + name=name_from_prompt(prompt), + prompt=prompt, + category=category, + license_id='GPL-3.0-or-later', + template='auto', + ) + template = infer_template(spec) + check(template == expected, '%r -> %s (expected %s)' % (prompt, template, expected)) + + +def test_plan_building(): + section('Plan Building & Enrichment') + + spec = ActivitySpec( + name='Quiz Game', + prompt='a quiz about animals for young learners', + category='logic_math', + license_id='GPL-3.0-or-later', + template='auto', + ) + plan = build_plan(spec) + check('template' in plan, 'plan has template') + check('bundle_id' in plan, 'plan has bundle_id') + check('class_name' in plan, 'plan has class_name') + check('learner_steps' in plan, 'plan has learner_steps') + + enriched = enrich_plan(spec, plan) + check('features' in enriched, 'enriched plan has features') + check('classroom_flow' in enriched, 'enriched plan has classroom_flow') + check('teacher_notes' in enriched, 'enriched plan has teacher_notes') + + if enriched['template'] == 'quiz': + check('questions' in enriched, 'quiz plan has questions') + else: + ok('template was %s, not quiz; skipping question check' % enriched['template']) + + +def test_rag(): + section('RAG Corpus') + + corpus = build_corpus(activity_roots=('/nonexistent',)) + check(len(corpus) >= 5, 'corpus has at least built-in documents (%d)' % len(corpus)) + + refs = search('drawing canvas', limit=3, template='canvas', corpus=corpus) + check(len(refs) > 0, 'RAG search returns results for drawing canvas') + if refs: + check('canvas' in refs[0].tags or 'drawing' in refs[0].tags, + 'top result is canvas-related: %s' % refs[0].title) + + api = get_api_reference() + check('Activity' in api and 'ToolbarBox' in api, 'API reference mentions Activity and ToolbarBox') + + examples = get_example_sources('quiz question answer', template='quiz', limit=2, corpus=corpus) + check(len(examples) > 0, 'get_example_sources returns quiz examples') + + +def test_validator(): + section('AST Validator') + + good = ''' +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk +from sugar3.activity import activity +from sugar3.activity.widgets import ActivityToolbarButton, StopButton +from sugar3.graphics.toolbarbox import ToolbarBox + +class GeneratedActivity(activity.Activity): + def __init__(self, handle): + activity.Activity.__init__(self, handle) + self._build_toolbar() + self._build_canvas() + + def _build_toolbar(self): + toolbar_box = ToolbarBox() + toolbar_box.toolbar.insert(ActivityToolbarButton(self), 0) + toolbar_box.toolbar.insert(StopButton(self), -1) + self.set_toolbar_box(toolbar_box) + toolbar_box.show_all() + + def _build_canvas(self): + canvas = Gtk.Label(label='Hello') + self.set_canvas(canvas) + canvas.show_all() + + def write_file(self, file_path): + pass + + def read_file(self, file_path): + pass +''' + report = validate_source(good) + check(report.valid, 'valid activity source passes validation') + + bad_import = good + '\nimport os\n' + report = validate_source(bad_import) + check(not report.valid and any('Forbidden import: os' in e for e in report.errors), + 'validator rejects forbidden import os') + + bad_call = good.replace('pass', 'eval("1")') + report = validate_source(bad_call) + check(not report.valid and any('Forbidden call: eval' in e for e in report.errors), + 'validator rejects forbidden call eval') + + missing_stop = good.replace('StopButton', 'SomeButton') + report = validate_source(missing_stop) + check(not report.valid and any('StopButton' in e for e in report.errors), + 'validator requires StopButton') + + syntax_error = good.replace('def __init__', 'def __init__') + syntax_error = syntax_error.replace('pass', 'def broken(:') + report = validate_source(syntax_error) + check(not report.valid and any('syntax error' in e for e in report.errors), + 'validator reports syntax errors') + + check('os' in FORBIDDEN_IMPORT_ROOTS, 'os is forbidden import') + check('subprocess' in FORBIDDEN_IMPORT_ROOTS, 'subprocess is forbidden import') + check('exec' in FORBIDDEN_CALLS, 'exec is forbidden call') + check('sugar3' in ALLOWED_IMPORT_ROOTS, 'sugar3 is allowed import') + + +def test_providers(): + section('LLM Providers') + + statuses = get_provider_statuses() + names = {s['name'] for s in statuses} + check(names >= {'local-template', 'gemini', 'openai', 'claude', 'ollama'}, + 'provider statuses include expected providers') + + local = create_provider('local-template') + check(local is None, 'local-template provider is None') + + for name in ('gemini', 'openai', 'claude', 'deepseek', 'qwen', 'moonshot', + 'opencode', 'opencode-go'): + try: + create_provider(name) + fail('%s provider created without key (unexpected)' % name) + except ProviderError: + ok('%s provider requires API key' % name) + + try: + ollama = create_provider('ollama') + ok('ollama provider created without key') + except ProviderError as e: + ok('ollama provider error: %s' % e) + + check(normalize_provider_name('Default') == 'default', 'normalize default') + check(normalize_provider_name('GOOGLE') == 'gemini', 'normalize google -> gemini') + check(normalize_provider_name('template') == 'local-template', 'normalize template') + check(normalize_provider_name('anthropic') == 'claude', 'normalize anthropic -> claude') + + +def test_pipeline_local(): + section('Pipeline: Local Template') + + spec = ActivitySpec( + name=name_from_prompt('a typing practice game'), + prompt='a typing practice game', + category='tools_utils', + license_id='GPL-3.0-or-later', + template='auto', + ) + output = tempfile.mkdtemp(prefix='aod_pipeline_local_') + result = generate_activity(spec, output_root=output, provider_name='local-template', use_rag=False) + + check(result.bundle_path and os.path.isfile(result.bundle_path), + 'local pipeline produces XO bundle') + check(result.project_path and os.path.isdir(result.project_path), + 'local pipeline produces project directory') + check('activity.py' in result.files, 'project contains activity.py') + check(result.plan.get('code_source') == 'template', 'code_source is template') + + bundle_report = validate_bundle(result.bundle_path) + check(bundle_report.valid, 'generated bundle passes validation') + + project_report = validate_project(result.project_path) + check(project_report.valid, 'generated project passes validation') + + +def test_pipeline_provider_code(): + section('Pipeline: Provider Code Path') + + class FakeCodeProvider(LLMProvider): + name = 'fake' + model = 'fake-model' + + def generate_plan(self, system_prompt, user_prompt, timeout=45): + return {'template': 'quiz'} + + def generate_activity_source(self, system_prompt, user_prompt, timeout=90): + return '''import gi\ngi.require_version('Gtk', '3.0')\nfrom gi.repository import Gtk\nfrom sugar3.activity import activity\nfrom sugar3.activity.widgets import ActivityToolbarButton, StopButton\nfrom sugar3.graphics.toolbarbox import ToolbarBox\n\nclass GeneratedActivity(activity.Activity):\n def __init__(self, handle):\n activity.Activity.__init__(self, handle)\n self._build_toolbar()\n self._build_canvas()\n def _build_toolbar(self):\n toolbar_box = ToolbarBox()\n toolbar_box.toolbar.insert(ActivityToolbarButton(self), 0)\n toolbar_box.toolbar.insert(StopButton(self), -1)\n self.set_toolbar_box(toolbar_box)\n toolbar_box.show_all()\n def _build_canvas(self):\n canvas = Gtk.Label(label='Fake provider activity')\n self.set_canvas(canvas)\n canvas.show_all()\n def write_file(self, file_path):\n pass\n def read_file(self, file_path):\n pass\n''' + + spec = ActivitySpec( + name=name_from_prompt('a quiz about animals'), + prompt='a quiz about animals', + category='creation', + license_id='GPL-3.0-or-later', + template='auto', + ) + output = tempfile.mkdtemp(prefix='aod_pipeline_fake_') + result = generate_activity(spec, output_root=output, provider=FakeCodeProvider(), + provider_name='fake', use_rag=False) + + check(result.plan.get('code_source') == 'provider', + 'provider code path sets code_source=provider') + check(result.bundle_path and os.path.isfile(result.bundle_path), + 'provider code pipeline produces XO bundle') + + +def test_job_store(): + section('Job Store') + + root = tempfile.mkdtemp(prefix='aod_jobs_') + store = AODJobStore(root_path=root) + + spec = ActivitySpec( + name='Test', + prompt='test prompt', + category='creation', + license_id='GPL-3.0-or-later', + ) + job = AODJob.create(spec, provider_name='gemini') + store.save(job) + + loaded = store.load(job.job_id) + check(loaded is not None, 'job store loads saved job') + check(loaded.spec.name == 'Test', 'loaded job preserves spec name') + + jobs = store.list_jobs() + check(len(jobs) == 1, 'job store lists one job') + + from types import SimpleNamespace + mock_result = SimpleNamespace( + spec=spec, bundle_id='test.bundle', bundle_path='/tmp/test.xo', + project_path='/tmp/test.activity', provider='local', model='', + plan={'template': 'canvas', 'code_source': 'template'}, + ) + job.finish(mock_result) + check(job.is_terminal(), 'finished job is terminal') + + job2 = AODJob.create(spec, provider_name='local-template') + job2.fail('test error') + check(job2.is_terminal() and job2.status == STATUS_FAILED, + 'failed job is terminal with failed status') + + +def test_service(): + section('AOD Service') + + root = tempfile.mkdtemp(prefix='aod_service_') + store = AODJobStore(root_path=root) + service = AODService(job_store=store, worker_count=1) + + spec = ActivitySpec( + name=name_from_prompt('a simple counting tool'), + prompt='a simple counting tool', + category='tools_utils', + license_id='GPL-3.0-or-later', + template='auto', + ) + + events = [] + + def callback(job): + events.append((job.status, job.progress)) + + job = service.submit_activity(spec, provider_name='local-template', use_rag=False, + output_root=tempfile.mkdtemp(prefix='aod_service_out_'), + callback=callback) + + # wait for completion + for _ in range(60): + if job.is_terminal(): + break + time.sleep(0.5) + + check(job.is_terminal(), 'service job reaches terminal state') + check(job.status == STATUS_FINISHED, 'service job finishes successfully') + check(job.result is not None, 'service job has result') + check(len(events) > 0, 'service emitted progress callbacks') + + service.shutdown(wait=True) + + +def test_credentials(): + section('Credential Store') + + root = tempfile.mkdtemp(prefix='aod_creds_') + store = AODCredentialStore(root_path=root, secret_backend=False) + + store.save_provider('gemini', api_key='secret-key-123', model='gemini-test') + loaded = store.load_provider('gemini') + check(loaded['api_key'] == 'secret-key-123', 'credential store saves API key') + check(loaded['model'] == 'gemini-test', 'credential store saves model') + + status = store.provider_status('gemini') + check(status['has_api_key'], 'provider status reports key present') + + default = store.get_default_provider_name() + check(default == 'gemini', 'last saved provider becomes default') + + removed = store.remove_api_key('gemini') + check(removed, 'remove_api_key succeeds') + status = store.provider_status('gemini') + check(not status['has_api_key'], 'provider status reports key removed') + + check(os.path.isfile(store.path), 'providers.env file exists') + mode = os.stat(store.path).st_mode + check((mode & 0o777) == 0o600, 'providers.env has 0o600 permissions') + + +def test_code_structure(): + section('Code Structure Report') + + base = os.path.join(os.path.dirname(__file__), 'src', 'jarabe', 'model') + expected = [ + 'aodcodegen.py', 'aodcredentials.py', 'aodgenerator.py', 'aodjobs.py', + 'aodlicenses.py', 'aodllm.py', 'aodpipeline.py', 'aodprompts.py', + 'aodqueue.py', 'aodrag.py', 'aodservice.py', 'aodspec.py', + 'aodtemplates.py', 'aodvalidator.py', + ] + missing = [f for f in expected if not os.path.isfile(os.path.join(base, f))] + check(not missing, 'all expected AOD model modules exist') + if missing: + fail('missing: %s' % ', '.join(missing)) + + desktop = os.path.join(os.path.dirname(__file__), 'src', 'jarabe', 'desktop') + check(os.path.isfile(os.path.join(desktop, 'homebox.py')), 'homebox.py exists') + check(os.path.isfile(os.path.join(desktop, 'viewtoolbar.py')), 'viewtoolbar.py exists') + check(os.path.isfile(os.path.join(desktop, 'homewindow.py')), 'homewindow.py exists') + + # Count key classes/functions + with open(os.path.join(base, 'aodllm.py'), encoding='utf-8') as f: + text = f.read() + check('GeminiProvider' in text, 'aodllm.py defines GeminiProvider') + check('OpenAIProvider' in text, 'aodllm.py defines OpenAIProvider') + check('ClaudeProvider' in text, 'aodllm.py defines ClaudeProvider') + check('OllamaProvider' in text, 'aodllm.py defines OllamaProvider') + + with open(os.path.join(base, 'aodtemplates.py'), encoding='utf-8') as f: + text = f.read() + for t in ('canvas', 'grid', 'narrative', 'quiz', 'utility', 'chess'): + check('_render_%s' % t in text, 'aodtemplates.py has _render_%s' % t) + + +def main(): + global PASS, FAIL + PASS = 0 + FAIL = 0 + + section('Starting comprehensive AOD test') + + test_spec() + test_template_inference() + test_plan_building() + test_rag() + test_validator() + test_providers() + test_pipeline_local() + test_pipeline_provider_code() + test_job_store() + test_service() + test_credentials() + test_code_structure() + + section('Summary') + check(True, 'PASS: %d' % PASS) + check(FAIL == 0, 'FAIL: %d' % FAIL) + + report_path = os.path.join(tempfile.gettempdir(), 'aod_full_test_report.txt') + with open(report_path, 'w', encoding='utf-8') as f: + f.write('\n'.join(REPORT)) + f.write('\n') + print('\nReport written to:', report_path) + + return 0 if FAIL == 0 else 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/aod_sample_prompts.txt b/aod_sample_prompts.txt new file mode 100644 index 0000000000..9774eb9602 --- /dev/null +++ b/aod_sample_prompts.txt @@ -0,0 +1,5 @@ +a drawing activity where I can paint colorful shapes +a quiz game about animals for young learners +a typing practice activity with word bank +a simple calculator tool for fractions +a storytelling activity with multiple pages diff --git a/aod_test_cli.py b/aod_test_cli.py new file mode 100755 index 0000000000..fc305f6f61 --- /dev/null +++ b/aod_test_cli.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +"""CLI harness to test Activity-on-Demand generation with any provider. + +Examples: + # Local template (no API key) + PYTHONPATH=src python3 aod_test_cli.py --provider local-template \ + --prompt "a drawing activity where I can paint colorful shapes" + + # Gemini + GEMINI_API_KEY=... PYTHONPATH=src python3 aod_test_cli.py --provider gemini \ + --prompt "a typing game for kids to practice spelling animals" + + # OpenAI-compatible endpoint + OPENCODE_API_KEY=... PYTHONPATH=src python3 aod_test_cli.py \ + --provider opencode-go --model kimi-k2.7-code \ + --prompt "a fractions playground where teams build models" +""" + +import argparse +import json +import os +import sys +import tempfile +import time + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src')) + +from jarabe.model.aodspec import ActivitySpec +from jarabe.model.aodspec import name_from_prompt +from jarabe.model.aodpipeline import generate_activity +from jarabe.model.aodpipeline import PipelineError +from jarabe.model.aodllm import create_provider +from jarabe.model.aodllm import get_provider_statuses +from jarabe.model.aodllm import ProviderError + + +def _clean_error(error_text): + """Strip redundant pipeline prefixes so testers see the real reasons.""" + text = str(error_text or '').strip() + for prefix in ( + 'Provider could not generate valid activity code: ', + 'Provider generated code did not pass validation: '): + if text.startswith(prefix): + text = text[len(prefix):].strip() + return text + + +def _status_table(): + print("Provider status:") + for status in get_provider_statuses(): + mark = "OK" if status['available'] else "--" + print(" [%s] %-14s %-22s %s" % ( + mark, + status['name'], + status['model'], + status['reason'], + )) + print() + + +def _run_one(prompt, provider_name, model=None, category='creation', + template='auto', license_id='GPL-3.0-or-later', age_band='all', + use_rag=True, output_dir=None): + spec = ActivitySpec( + name=name_from_prompt(prompt), + prompt=prompt, + category=category, + license_id=license_id, + template=template, + age_band=age_band, + ) + errors = spec.validate() + if errors: + print("Spec errors:", errors) + return None + + provider = None + if provider_name not in ('local-template', 'local', 'template'): + try: + provider = create_provider(provider_name, model=model) + except ProviderError as error: + print("Provider error:", error) + return None + + output_root = output_dir or tempfile.mkdtemp(prefix="aod_test_") + print("Generating: %s" % spec.name) + print(" provider : %s" % provider_name) + print(" model : %s" % (model or (provider.model if provider else 'template'))) + print(" template : %s" % template) + print(" output : %s" % output_root) + print() + + captured_draft = [''] + + def _progress_cb(stage, fraction, message, metadata=None): + print(" [%3d%%] %-12s %s" % ( + int(fraction * 100), stage, message)) + if isinstance(metadata, dict): + draft = metadata.get('draft_activity_source', '') + if isinstance(draft, str) and draft: + captured_draft[0] = draft + + start = time.time() + try: + result = generate_activity( + spec, + output_root=output_root, + provider=provider, + provider_name=provider_name, + use_rag=use_rag, + progress_cb=_progress_cb, + ) + except PipelineError as error: + print("\nPipeline failed:\n%s" % _clean_error(str(error))) + draft = captured_draft[0] + if draft: + draft_path = os.path.join(output_root, 'draft_activity.py') + with open(draft_path, 'w', encoding='utf-8') as f: + f.write(draft) + print("\nDraft activity.py (failed validation) saved to:") + print(" %s" % draft_path) + print(" (%d chars, %d lines)" % ( + len(draft), draft.count('\n') + 1)) + print("\nTry:") + print(" - A smaller or simpler prompt") + print(" - A different model (--model kimi-k2.7-code)") + print(" - A larger output budget " + "(AOD_OPENROUTER_CODEGEN_MAX_TOKENS=32000)") + return None + + elapsed = time.time() - start + print("\nDone in %.1fs" % elapsed) + print(" bundle_id: %s" % result.bundle_id) + print(" project : %s" % result.project_path) + print(" bundle : %s" % result.bundle_path) + print(" provider : %s" % result.provider) + print(" model : %s" % result.model) + print(" template : %s" % result.plan.get('template')) + print(" code_src : %s" % result.plan.get('code_source', 'template')) + if result.plan.get('provider_fallback_reason'): + print(" plan_fallback: %s" % result.plan['provider_fallback_reason']) + if result.plan.get('codegen_fallback_reason'): + print(" code_fallback: %s" % result.plan['codegen_fallback_reason']) + print(" files : %s" % ', '.join(sorted(result.files.keys()))) + + plan_path = os.path.join(result.project_path, 'aod_plan.json') + if os.path.isfile(plan_path): + with open(plan_path, encoding='utf-8') as f: + plan = json.load(f) + print("\nPlan summary:") + for key in ('summary', 'learner_goal', 'learner_steps', 'word_bank'): + print(" %s: %s" % (key, plan.get(key))) + return result + + +def main(): + parser = argparse.ArgumentParser( + description='Test Activity-on-Demand generation.', + ) + parser.add_argument('--provider', default='local-template', + help='Provider name (local-template, gemini, openai, ' + 'deepseek, qwen, moonshot, opencode, ' + 'opencode-go, claude, ollama)') + parser.add_argument('--model', default=None, + help='Override model name') + parser.add_argument('--prompt', required=True, + help='Activity description') + parser.add_argument('--category', default='creation', + help='Activity category') + parser.add_argument('--template', default='auto', + help='Template hint (auto, canvas, quiz, grid, ...)') + parser.add_argument('--license', default='GPL-3.0-or-later', + help='License ID') + parser.add_argument('--no-rag', action='store_true', + help='Disable RAG retrieval') + parser.add_argument('--output', default=None, + help='Output directory (default: temp dir)') + parser.add_argument('--status', action='store_true', + help='Show provider status and exit') + + args = parser.parse_args() + + if args.status: + _status_table() + return 0 + + _status_table() + result = _run_one( + args.prompt, + args.provider, + model=args.model, + category=args.category, + template=args.template, + license_id=args.license, + use_rag=not args.no_rag, + output_dir=args.output, + ) + return 0 if result else 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/src/jarabe/desktop/homebox.py b/src/jarabe/desktop/homebox.py index 40629d5edf..b291d9ce17 100644 --- a/src/jarabe/desktop/homebox.py +++ b/src/jarabe/desktop/homebox.py @@ -13,11 +13,28 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +import json +import difflib import logging +import math +import os +import re +import shutil +import glob +import subprocess +import threading +import time +from gettext import gettext as _ from gi.repository import Gtk from gi.repository import Gdk from gi.repository import Gio +from gi.repository import GObject +from gi.repository import GLib +from gi.repository import Pango + +from sugar3.graphics import style +from sugar3.graphics.icon import Icon from jarabe.desktop.favoritesview import FavoritesBox from jarabe.desktop.activitieslist import ActivitiesList @@ -32,6 +49,7 @@ def __init__(self, toolbar): logging.debug('STARTUP: Loading the home view') Gtk.VBox.__init__(self) + self._toolbar = toolbar self._favorites_views_indicies = [] for i in range(desktop.get_number_of_views()): @@ -50,6 +68,8 @@ def __init__(self, toolbar): toolbar.search_entry._icon_selected = [] toolbar.connect('query-changed', self.__toolbar_query_changed_cb) toolbar.connect('view-changed', self.__toolbar_view_changed_cb) + toolbar.connect('create-ai-activity', + self.__toolbar_create_ai_activity_cb) toolbar.search_entry.connect('key-press-event', self.__search_entry_key_press_event_cb) toolbar.search_entry.connect('icon-press', @@ -57,7 +77,15 @@ def __init__(self, toolbar): self._list_view.connect('clear-clicked', self.__activitylist_clear_clicked_cb, toolbar) - self._set_view(self._favorites_views_indicies[0]) + self._active_view = self._favorites_views_indicies[0] + self._set_view(self._active_view) + + self._create_ai_panel = _CreateAIActivityPanel() + self._create_ai_panel.connect('close-requested', + self.__create_ai_panel_close_cb) + self.pack_start(self._create_ai_panel, True, True, 0) + self._create_ai_panel.hide() + self._query = '' self._resume_mode = Gio.Settings.new( 'org.sugarlabs.user').get_boolean('resume-activity') @@ -110,7 +138,15 @@ def __toolbar_query_changed_cb(self, toolbar, query): def __toolbar_view_changed_cb(self, toolbar, view): self._set_view(view) + def __toolbar_create_ai_activity_cb(self, toolbar): + self._show_create_ai_panel() + def __search_entry_key_press_event_cb(self, entry, event): + if self.is_create_ai_panel_visible(): + GObject.idle_add( + self.__redirect_search_text_to_create_ai_prompt, entry) + return False + # wherever a single item is selected in a desktop view, # launch the activity on pressing return if event.keyval == Gdk.KEY_Return and len(entry._icon_selected) == 1: @@ -126,6 +162,10 @@ def __clear_icon_pressed_cb(self, entry, icon_pos, event): self.grab_focus() def grab_focus(self): + if self._create_ai_panel.get_visible(): + self._create_ai_panel.grab_focus() + return + # overwrite grab focus to be able to grab focus on the # views which are packed inside a box children = self.get_children() @@ -137,6 +177,13 @@ def grab_focus(self): self._favorites_boxes[i].grab_focus() def _set_view(self, view): + self._active_view = view + + if hasattr(self, '_create_ai_panel') and \ + self._create_ai_panel.get_visible(): + self._create_ai_panel.cancel_generation() + self._create_ai_panel.hide() + if view in self._favorites_views_indicies: favorite = self._favorites_views_indicies.index(view) @@ -150,8 +197,8 @@ def _set_view(self, view): if self._favorites_boxes[favorite] not in children: self.add(self._favorites_boxes[favorite]) - self._favorites_boxes[favorite].show() - self._favorites_boxes[favorite].grab_focus() + self._favorites_boxes[favorite].show() + self._favorites_boxes[favorite].grab_focus() elif view == self._list_view_index: children = self.get_children() for i in range(desktop.get_number_of_views()): @@ -160,11 +207,47 @@ def _set_view(self, view): if self._list_view not in children: self.add(self._list_view) - self._list_view.show() - self._list_view.grab_focus() + self._list_view.show() + self._list_view.grab_focus() else: raise ValueError('Invalid view: %r' % view) + def _show_create_ai_panel(self): + self._toolbar.clear_query() + + children = self.get_children() + if self._list_view in children: + self._list_view.hide() + else: + for i in range(desktop.get_number_of_views()): + if self._favorites_boxes[i] in children: + self._favorites_boxes[i].hide() + + self._create_ai_panel.reset_view() + self._create_ai_panel.show() + self._create_ai_panel.grab_focus() + + def __create_ai_panel_close_cb(self, panel): + self._create_ai_panel.cancel_generation() + self._create_ai_panel.hide() + self._set_view(self._active_view) + + def is_create_ai_panel_visible(self): + return hasattr(self, '_create_ai_panel') and \ + self._create_ai_panel.get_visible() + + def __redirect_search_text_to_create_ai_prompt(self, entry): + if not self.is_create_ai_panel_visible(): + return False + + text = entry.get_text() + if text: + self._create_ai_panel.append_prompt_text(text) + entry.set_text('') + + self._create_ai_panel.focus_prompt() + return False + _REDRAW_TIMEOUT = 5 * 60 * 1000 # 5 minutes def resume(self): @@ -180,3 +263,7010 @@ def set_resume_mode(self, resume_mode, favorite_view=0): self._list_view.set_filter(self._query) for i in range(desktop.get_number_of_views()): self._favorites_boxes[i].set_filter(self._query) + + +class _CreateAIActivityPanel(Gtk.EventBox): + __gtype_name__ = 'SugarCreateAIActivityPanel' + _css_loaded = False + _CODE_COLORS = { + 'line_number': '#6e6e6e', + 'keyword': '#0000cc', + 'class_name': '#267f99', + 'function': '#795e26', + 'string': '#a31515', + 'comment': '#007000', + 'number': '#098658', + 'constant': '#0000cc', + 'property': '#001080', + 'plain': '#111111', + 'markdown': '#0451a5', + 'diff_added': '#007000', + 'diff_deleted': '#b00000', + 'diff_added_bg': '#dff8df', + 'diff_deleted_bg': '#ffe0e0', + } + _PYTHON_KEYWORDS = set([ + 'and', 'as', 'assert', 'break', 'class', 'continue', 'def', 'del', + 'elif', 'else', 'except', 'False', 'finally', 'for', 'from', 'global', + 'if', 'import', 'in', 'is', 'lambda', 'None', 'nonlocal', 'not', 'or', + 'pass', 'raise', 'return', 'True', 'try', 'while', 'with', 'yield', + ]) + _PYTHON_TYPES = set([ + 'Gtk', 'activity', 'GeneratedActivity', 'ToolbarBox', + 'ActivityToolbarButton', 'StopButton', 'Orientation', 'VERTICAL', + 'Activity', + ]) + + __gsignals__ = { + 'close-requested': (GObject.SignalFlags.RUN_FIRST, None, ([])), + } + + def __init__(self): + Gtk.EventBox.__init__(self) + self._ensure_css() + self.modify_bg(Gtk.StateType.NORMAL, style.COLOR_WHITE.get_gdk_color()) + self.get_style_context().add_class('create-ai-panel') + self._option_buttons = {} + self._selected_options = { + 'mode': 'make', + 'template': 'logic_math', + 'planner': 'rag', + 'policy': 'standard', + 'provider': 'default', + 'license': 'mit', + } + self._template_hint = None + self._planner_hint = None + self._provider_combo = None + self._provider_key_entry = None + self._provider_paste_button = None + self._provider_model_entry = None + self._provider_model_switch_row = None + self._provider_endpoint_entry = None + self._provider_apply_button = None + self._provider_remove_button = None + self._provider_status_label = None + self._provider_test_running = False + self._license_hint = None + self._prompt_text = None + self._prompt_char_label = None + self._prompt_status_label = None + self._studio_prompt_labels = [] + self._preview_content_box = None + self._preview_empty_title = None + self._preview_empty_note = None + self._preview_generation_spinner = None + self._preview_generation_progress = None + self._preview_generation_stage = None + self._preview_generation_steps = [] + self._generation_animation_id = 0 + self._generation_animation_hide_id = 0 + self._studio_preview_tab = None + self._studio_review_tab = None + self._studio_versions_tab = None + self._studio_mode_stack = None + self._review_file_buttons = [] + self._review_title_label = None + self._review_summary_label = None + self._review_meta_label = None + self._review_code_label = None + self._current_review_file = 'activity_py' + self._review_generation_context = {} + self._version_history_buttons = [] + self._version_history_box = None + self._version_source_button = None + self._version_diff_button = None + self._version_title_label = None + self._version_meta_label = None + self._version_code_label = None + self._selected_version = 'v6' + self._version_mode = 'diff' + self._review_draft_was_shown = False + self._live_edit_entry = None + self._live_edit_status_label = None + self._live_edit_target_label = None + self._live_edit_target = _('activity canvas') + self._live_edit_on_button = None + self._live_edit_off_button = None + self._live_edit_enabled = True + self._chat_messages_box = None + self._chat_entry = None + self._chat_scroll = None + self._aod_session_id = '' + self._aod_active_revision_id = '' + self._sidebar_messages_box = None + self._sidebar_chat_scroll = None + self._sidebar_refine_entry = None + self._sidebar_refine_status_label = None + self._prompt_is_placeholder = False + self._generation_result = None + self._generation_job_id = None + self._generation_job_callback = \ + self._generation_job_updated_from_worker + self._prompt_placeholder_text = _( + 'Example: "Create a fractions playground where teams build ' + 'and explain models."') + self._is_fullscreen = False + + box = Gtk.VBox(spacing=style.zoom(8)) + box.set_border_width(style.zoom(10)) + self.add(box) + box.show() + + self._content_alignment = Gtk.Alignment(xalign=0.5, yalign=0.46, + xscale=1, yscale=0) + box.pack_start(self._content_alignment, True, True, 0) + self._content_alignment.show() + + self._stack = Gtk.Stack() + self._stack.set_transition_type(Gtk.StackTransitionType.CROSSFADE) + self._stack.set_transition_duration(180) + self._content_alignment.add(self._stack) + self._stack.show() + + choose_view = self._create_choose_view() + self._stack.add_named(choose_view, 'choose') + choose_view.show() + + create_view = self._create_create_view() + self._stack.add_named(create_view, 'create') + create_view.show() + + studio_view = self._create_studio_view() + self._stack.add_named(studio_view, 'studio') + studio_view.show() + + self._stack.set_visible_child_name('choose') + + def reset_view(self): + self.cancel_generation() + self._generation_result = None + self._review_generation_context = {} + self._review_draft_was_shown = False + self._aod_session_id = '' + self._aod_active_revision_id = '' + self._show_empty_activity_preview() + self._use_centered_layout() + self._stack.set_visible_child_name('choose') + self._reset_prompt() + + def cancel_generation(self): + self._detach_generation_job(cancel=True) + self._stop_generation_animation() + self._review_generation_context = {} + self._review_draft_was_shown = False + + def _detach_generation_job(self, cancel=False): + if self._generation_job_id is None: + return + + from jarabe.model.aodservice import get_service + + service = get_service() + job_id = self._generation_job_id + self._generation_job_id = None + service.unwatch(job_id, self._generation_job_callback) + if cancel: + service.cancel_job(job_id) + + def _create_choose_view(self): + content = Gtk.VBox(spacing=style.zoom(22)) + content.set_size_request(style.zoom(1080), -1) + + title = Gtk.Label() + title.set_markup('%s' % + _('How would you like to start?')) + title.get_style_context().add_class('create-ai-title') + title.set_justify(Gtk.Justification.CENTER) + content.pack_start(title, False, False, 0) + title.show() + + subtitle = Gtk.Label(_('Start here if you\'re new, then try changing ' + 'things, then create your own!')) + subtitle.get_style_context().add_class('create-ai-subtitle') + subtitle.set_justify(Gtk.Justification.CENTER) + subtitle.set_line_wrap(True) + subtitle.set_max_width_chars(72) + content.pack_start(subtitle, False, False, 0) + subtitle.show() + + cards = Gtk.HBox(spacing=style.zoom(32)) + cards.set_halign(Gtk.Align.CENTER) + content.pack_start(cards, False, False, style.zoom(18)) + cards.show() + + cards.pack_start(self._create_stage_card( + _('MODIFY'), + _('Change an existing activity\nPick a starter activity and\n' + 'modify it with guided\nchallenges and hints.'), + _('Then try\nchanging things')), False, False, 0) + + cards.pack_start(self._create_stage_card( + _('CREATE'), + _('Describe something new\nDescribe an activity in your\n' + 'own words and the AI will\ngenerate it for you.'), + _('Then create\nyour own!'), + on_click=self.__open_create_view), False, False, 0) + + return content + + def _create_create_view(self): + container = Gtk.VBox(spacing=style.zoom(11)) + container.set_size_request(style.zoom(1280), -1) + + title = Gtk.Label() + title.set_text(_('Let\'s make a playful learning activity')) + title.get_style_context().add_class('create-ai-builder-title') + title.set_justify(Gtk.Justification.CENTER) + title.set_halign(Gtk.Align.CENTER) + container.pack_start(title, False, False, 0) + title.show() + + subtitle = Gtk.Label(_('Tell me what learners make, play with, and ' + 'share.')) + subtitle.get_style_context().add_class('create-ai-builder-subtitle') + subtitle.set_justify(Gtk.Justification.CENTER) + subtitle.set_halign(Gtk.Align.CENTER) + container.pack_start(subtitle, False, False, style.zoom(2)) + subtitle.show() + + options_area = Gtk.VBox(spacing=style.zoom(9)) + options_area.set_halign(Gtk.Align.CENTER) + container.pack_start(options_area, False, False, style.zoom(8)) + options_area.show() + + options_area.pack_start(self._create_option_group( + _('Start with'), + 'mode', + [ + ('make', _('Make'), _('Create something\nnew')), + ('play', _('Play'), _('Try the activity\nquickly')), + ('share', _('Share'), _('Prepare an XO\nbundle')), + ], + 'make'), False, False, 0) + + options_area.pack_start(self._create_option_group( + _('Learning area'), + 'template', + [ + ('logic_math', _('Logic & math'), _('Puzzles,\npatterns')), + ('tools_utils', _('Tools/utilities'), _('Build helpful\n' + 'tools')), + ('games', _('Games'), _('Play loops\nand score')), + ('creation', _('Creation'), _('Make and\nexpress')), + ], + 'logic_math'), False, False, 0) + + template_hint = Gtk.Label( + _('Learning area: logic and math activities for puzzles, patterns, ' + 'and reasoning.')) + self._template_hint = template_hint + template_hint.get_style_context().add_class('create-ai-meta-note') + template_hint.set_halign(Gtk.Align.CENTER) + template_hint.set_justify(Gtk.Justification.CENTER) + container.pack_start(template_hint, False, False, style.zoom(1)) + template_hint.show() + + planner_options = Gtk.HBox(spacing=style.zoom(14)) + planner_options.set_halign(Gtk.Align.CENTER) + container.pack_start(planner_options, False, False, style.zoom(4)) + planner_options.show() + + planner_options.pack_start(self._create_option_group( + _('Planner'), + 'planner', + [ + ('rag', _('RAG'), _('Use Sugar\nexamples')), + ], + 'rag'), False, False, 0) + + planner_options.pack_start(self._create_option_group( + _('Policy'), + 'policy', + [ + ('standard', _('Standard'), + _('RAG + model\nvalidated')), + ], + 'standard'), False, False, 0) + + planner_hint = Gtk.Label() + planner_hint.get_style_context().add_class('create-ai-meta-note') + self._planner_hint = planner_hint + planner_hint.set_halign(Gtk.Align.CENTER) + planner_hint.set_justify(Gtk.Justification.CENTER) + planner_hint.set_line_wrap(True) + planner_hint.set_max_width_chars(132) + container.pack_start(planner_hint, False, False, 0) + planner_hint.show() + self._update_planner_hint() + + container.pack_start(self._create_provider_selector(), + False, False, style.zoom(3)) + + container.pack_start(self._create_license_selector(), + False, False, style.zoom(3)) + + guided_button = Gtk.ToggleButton.new_with_label(_('Guided spec')) + guided_button.get_style_context().add_class('create-ai-meta-button') + guided_button.set_halign(Gtk.Align.CENTER) + guided_button.connect('toggled', self.__guided_button_toggled_cb) + container.pack_start(guided_button, False, False, style.zoom(3)) + guided_button.show() + + prompt_box = Gtk.EventBox() + prompt_box.get_style_context().add_class('create-ai-prompt-box') + prompt_box.set_above_child(False) + prompt_box.set_size_request(style.zoom(1040), style.zoom(226)) + prompt_box.set_halign(Gtk.Align.CENTER) + container.pack_start(prompt_box, False, False, style.zoom(10)) + prompt_box.show() + + prompt_inner = Gtk.VBox(spacing=style.zoom(8)) + prompt_inner.set_border_width(style.zoom(9)) + prompt_box.add(prompt_inner) + prompt_inner.show() + + prompt_header = Gtk.HBox() + prompt_inner.pack_start(prompt_header, False, False, 0) + prompt_header.show() + + prompt_label = Gtk.Label(_('Learning prompt')) + prompt_label.get_style_context().add_class('create-ai-meta-label') + prompt_label.set_xalign(0) + prompt_header.pack_start(prompt_label, False, False, 0) + prompt_label.show() + + char_label = Gtk.Label(_('0 chars')) + self._prompt_char_label = char_label + char_label.get_style_context().add_class('create-ai-meta-note') + prompt_header.pack_end(char_label, False, False, 0) + char_label.show() + + text = Gtk.TextView() + self._prompt_text = text + text.set_can_focus(True) + text.set_editable(True) + text.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) + text.set_left_margin(style.zoom(10)) + text.set_right_margin(style.zoom(10)) + text.set_top_margin(style.zoom(10)) + text.set_bottom_margin(style.zoom(10)) + text.get_style_context().add_class('create-ai-textview') + text.get_buffer().connect('changed', + self.__prompt_buffer_changed_cb) + text.connect('button-press-event', + self.__prompt_button_press_event_cb) + text.connect('key-press-event', self.__prompt_key_press_event_cb) + text.set_size_request(-1, style.zoom(86)) + prompt_inner.pack_start(text, True, True, 0) + text.show() + self._set_prompt_placeholder() + + actions = Gtk.EventBox() + actions.get_style_context().add_class('create-ai-prompt-actions') + actions.set_above_child(False) + prompt_inner.pack_start(actions, False, False, 0) + actions.show() + + bottom_row = Gtk.HBox(spacing=style.zoom(8)) + bottom_row.set_border_width(style.zoom(6)) + actions.add(bottom_row) + bottom_row.show() + + hint_icon = Gtk.Button() + add_icon = Icon(icon_name='list-add', + pixel_size=style.SMALL_ICON_SIZE, + stroke_color=style.COLOR_WHITE.get_svg(), + fill_color=style.COLOR_BLACK.get_svg()) + add_icon.show() + hint_icon.set_image(add_icon) + hint_icon.get_style_context().add_class('create-ai-plus') + hint_icon.connect('clicked', self.__prompt_example_clicked_cb) + bottom_row.pack_start(hint_icon, False, False, 0) + hint_icon.show() + + hint_text = Gtk.Label(_('Try: treasure-map quest where teams solve ' + 'clues and explain each step.')) + hint_text.get_style_context().add_class('create-ai-meta-note') + hint_text.set_xalign(0) + bottom_row.pack_start(hint_text, True, True, 0) + hint_text.show() + + send_row = Gtk.HBox(spacing=style.zoom(6)) + bottom_row.pack_end(send_row, False, False, 0) + send_row.show() + + thinking = Gtk.Label(_('Thinking')) + self._prompt_status_label = thinking + thinking.get_style_context().add_class('create-ai-pill-button') + send_row.pack_start(thinking, False, False, 0) + thinking.show() + + send_btn = Gtk.Button() + send_icon = Icon(icon_name='go-up', + pixel_size=style.SMALL_ICON_SIZE, + stroke_color=style.COLOR_WHITE.get_svg(), + fill_color=style.COLOR_BLACK.get_svg()) + send_icon.show() + send_btn.set_image(send_icon) + send_btn.get_style_context().add_class('create-ai-send') + send_btn.connect('clicked', self.__send_button_clicked_cb) + send_row.pack_start(send_btn, False, False, 0) + send_btn.show() + + return container + + def _create_provider_selector(self): + selector = Gtk.VBox(spacing=style.zoom(4)) + selector.set_halign(Gtk.Align.CENTER) + + heading = Gtk.Label(_('LLM provider')) + heading.get_style_context().add_class('create-ai-option-heading') + heading.set_halign(Gtk.Align.CENTER) + selector.pack_start(heading, False, False, 0) + heading.show() + + row = Gtk.HBox(spacing=style.zoom(7)) + row.set_halign(Gtk.Align.CENTER) + selector.pack_start(row, False, False, 0) + row.show() + + combo = Gtk.ComboBoxText() + self._provider_combo = combo + for provider_name, label in self._get_provider_options(): + combo.append(provider_name, label) + initial_provider = self._initial_provider_option() + self._selected_options['provider'] = initial_provider + combo.set_active_id(initial_provider) + combo.set_size_request(style.zoom(170), style.zoom(42)) + combo.get_style_context().add_class('create-ai-provider-combo') + combo.connect('changed', self.__provider_combo_changed_cb) + row.pack_start(combo, False, False, 0) + combo.show() + + key_entry = Gtk.Entry() + self._provider_key_entry = key_entry + key_entry.set_visibility(False) + key_entry.set_invisible_char('*') + key_entry.set_input_purpose(Gtk.InputPurpose.PASSWORD) + key_entry.set_placeholder_text(_('API key (masked)')) + key_entry.set_tooltip_text( + _('Saved keys stay in the private Sugar profile and are never ' + 'added to generated activities.')) + key_entry.set_size_request(style.zoom(245), style.zoom(42)) + key_entry.get_style_context().add_class('create-ai-provider-entry') + key_entry.connect('key-press-event', + self.__provider_key_entry_key_press_event_cb) + key_entry.connect('paste-clipboard', + self.__provider_key_entry_paste_clipboard_cb) + row.pack_start(key_entry, False, False, 0) + key_entry.show() + + paste_button = Gtk.Button.new_with_label(_('Paste')) + self._provider_paste_button = paste_button + paste_button.set_size_request(style.zoom(75), style.zoom(42)) + paste_button.set_tooltip_text(_('Paste an API key from the clipboard.')) + paste_button.get_style_context().add_class( + 'create-ai-provider-button') + paste_button.connect('clicked', self.__provider_paste_clicked_cb) + row.pack_start(paste_button, False, False, 0) + paste_button.show() + + model_entry = Gtk.Entry() + self._provider_model_entry = model_entry + model_entry.set_placeholder_text(_('Model (optional)')) + model_entry.set_size_request(style.zoom(190), style.zoom(42)) + model_entry.get_style_context().add_class( + 'create-ai-provider-entry') + row.pack_start(model_entry, False, False, 0) + model_entry.show() + + endpoint_entry = Gtk.Entry() + self._provider_endpoint_entry = endpoint_entry + endpoint_entry.set_placeholder_text(_('Endpoint (optional)')) + endpoint_entry.set_tooltip_text( + _('Use the provider API endpoint, or Ollama /api/generate URL.')) + endpoint_entry.set_size_request(style.zoom(245), style.zoom(42)) + endpoint_entry.get_style_context().add_class( + 'create-ai-provider-entry') + row.pack_start(endpoint_entry, False, False, 0) + endpoint_entry.show() + + apply_button = Gtk.Button.new_with_label(_('Save & test')) + self._provider_apply_button = apply_button + apply_button.set_size_request(style.zoom(125), style.zoom(42)) + apply_button.get_style_context().add_class( + 'create-ai-provider-button') + apply_button.connect('clicked', self.__provider_apply_clicked_cb) + row.pack_start(apply_button, False, False, 0) + apply_button.show() + + remove_button = Gtk.Button.new_with_label(_('Remove key')) + self._provider_remove_button = remove_button + remove_button.set_size_request(style.zoom(115), style.zoom(42)) + remove_button.get_style_context().add_class( + 'create-ai-provider-button') + remove_button.connect('clicked', self.__provider_remove_clicked_cb) + row.pack_start(remove_button, False, False, 0) + remove_button.show() + + model_switch_row = Gtk.HBox(spacing=style.zoom(6)) + self._provider_model_switch_row = model_switch_row + model_switch_row.set_halign(Gtk.Align.CENTER) + selector.pack_start(model_switch_row, False, False, 0) + + for label, model in ( + (_('Kimi K2.6'), 'kimi-k2.6'), + (_('Kimi K2.7 Code'), 'kimi-k2.7-code')): + model_button = Gtk.Button.new_with_label(label) + model_button.get_style_context().add_class( + 'create-ai-provider-button') + model_button.connect( + 'clicked', + self.__provider_model_switch_clicked_cb, + model, + ) + model_switch_row.pack_start(model_button, False, False, 0) + model_button.show() + model_switch_row.hide() + + status = Gtk.Label() + self._provider_status_label = status + status.get_style_context().add_class('create-ai-meta-note') + status.set_halign(Gtk.Align.CENTER) + status.set_justify(Gtk.Justification.CENTER) + status.set_line_wrap(True) + status.set_max_width_chars(132) + selector.pack_start(status, False, False, 0) + status.show() + + self._update_provider_controls() + selector.show() + return selector + + def _get_provider_options(self): + return [ + ('default', _('Automatic')), + ('freemodel', _('FreeModel')), + ('openrouter', _('OpenRouter')), + ('gemini', _('Gemini')), + ('openai', _('OpenAI')), + ('deepseek', _('DeepSeek')), + ('qwen', _('Qwen')), + ('moonshot', _('Moonshot')), + ('opencode', _('OpenCode Zen')), + ('opencode-go', _('OpenCode Go')), + ('claude', _('Claude')), + ('ollama', _('Ollama')), + ] + + def _get_provider_label(self, provider_name): + labels = dict(self._get_provider_options()) + return labels.get(provider_name, provider_name) + + def _initial_provider_option(self): + try: + from jarabe.model.aodservice import get_service + + provider_name = get_service().preferred_provider_name() + except Exception: + logging.exception('Could not read preferred AOD provider') + return 'default' + + options = dict(self._get_provider_options()) + if provider_name in options and provider_name != 'local-template': + return provider_name + return 'default' + + def _create_license_selector(self): + selector = Gtk.VBox(spacing=style.zoom(4)) + selector.set_halign(Gtk.Align.CENTER) + + selector.pack_start(self._create_option_group( + _('License'), + 'license', + self._get_license_option_cards(), + 'mit', + card_width=142, + card_height=76, + detail_width=15), False, False, 0) + + hint = Gtk.Label() + self._license_hint = hint + hint.get_style_context().add_class('create-ai-meta-note') + hint.set_halign(Gtk.Align.CENTER) + hint.set_justify(Gtk.Justification.CENTER) + hint.set_line_wrap(True) + hint.set_max_width_chars(120) + selector.pack_start(hint, False, False, 0) + hint.show() + + self._update_license_hint() + selector.show() + return selector + + def _get_license_option_cards(self): + return [(option['value'], option['label'], option['card_detail']) + for option in self._get_license_options()] + + def _get_license_options(self): + return [ + { + 'value': 'mit', + 'label': _('MIT'), + 'card_detail': _('Simple\npermissive'), + 'spdx': 'MIT', + 'description': _('Short permissive license'), + }, + { + 'value': 'gplv3_plus', + 'label': _('GPLv3+'), + 'card_detail': _('Sugar\nshare-alike'), + 'spdx': 'GPL-3.0-or-later', + 'description': _('Share-alike default for Sugar activities'), + }, + { + 'value': 'apache_2', + 'label': _('Apache'), + 'card_detail': _('Patent\ngrant'), + 'spdx': 'Apache-2.0', + 'description': _('Permissive license with patent grant'), + }, + { + 'value': 'agplv3', + 'label': _('AGPLv3'), + 'card_detail': _('Network\nshare-alike'), + 'spdx': 'AGPL-3.0-or-later', + 'description': _('Network share-alike license'), + }, + { + 'value': 'lgplv3', + 'label': _('LGPLv3'), + 'card_detail': _('Library\nshare-alike'), + 'spdx': 'LGPL-3.0-or-later', + 'description': _('Library-focused copyleft license'), + }, + { + 'value': 'mpl_2', + 'label': _('MPL-2.0'), + 'card_detail': _('File-level\nshare-alike'), + 'spdx': 'MPL-2.0', + 'description': _('File-level copyleft license'), + }, + { + 'value': 'bsd_3', + 'label': _('BSD-3'), + 'card_detail': _('Permissive\nattribution'), + 'spdx': 'BSD-3-Clause', + 'description': _('Permissive license with attribution'), + }, + ] + + def _get_selected_license(self): + selected = self._selected_options.get('license', 'mit') + for option in self._get_license_options(): + if option['value'] == selected: + return option + return self._get_license_options()[0] + + def _create_option_group(self, title, group_name, options, active_value, + card_width=142, card_height=76, + detail_width=15): + group = Gtk.VBox(spacing=style.zoom(4)) + group.set_halign(Gtk.Align.CENTER) + + label = Gtk.Label(title) + label.get_style_context().add_class('create-ai-option-heading') + label.set_halign(Gtk.Align.CENTER) + group.pack_start(label, False, False, 0) + label.show() + + row = Gtk.HBox(spacing=style.zoom(7)) + row.set_halign(Gtk.Align.CENTER) + group.pack_start(row, False, False, 0) + row.show() + + self._option_buttons[group_name] = [] + self._selected_options[group_name] = active_value + for value, option_title, option_detail in options: + button = self._create_option_card( + group_name, value, option_title, option_detail, + card_width, card_height, detail_width) + if value == active_value: + button.get_style_context().add_class( + 'create-ai-option-card-active') + row.pack_start(button, False, False, 0) + button.show() + + group.show() + return group + + def _create_option_card(self, group_name, value, title, detail, + card_width=142, card_height=76, + detail_width=15): + button = Gtk.Button() + button.set_relief(Gtk.ReliefStyle.NONE) + button.get_style_context().add_class('create-ai-option-card') + button.set_size_request(style.zoom(card_width), + style.zoom(card_height)) + button.connect('clicked', self.__option_card_clicked_cb, + group_name, value) + + content = Gtk.VBox(spacing=style.zoom(2)) + content.set_border_width(style.zoom(7)) + button.add(content) + content.show() + + title_label = Gtk.Label(title) + title_label.get_style_context().add_class('create-ai-option-title') + title_label.set_justify(Gtk.Justification.CENTER) + title_label.set_line_wrap(True) + title_label.set_max_width_chars(detail_width) + content.pack_start(title_label, False, False, 0) + title_label.show() + + detail_label = Gtk.Label(detail) + detail_label.get_style_context().add_class('create-ai-option-detail') + detail_label.set_justify(Gtk.Justification.CENTER) + detail_label.set_line_wrap(True) + detail_label.set_max_width_chars(detail_width) + content.pack_start(detail_label, True, True, 0) + detail_label.show() + + self._option_buttons[group_name].append(button) + return button + + def _create_studio_view(self): + workspace = Gtk.EventBox() + workspace.get_style_context().add_class('create-ai-studio-workspace') + workspace.set_size_request(style.zoom(1900), -1) + workspace.set_margin_top(style.zoom(6)) + + studio = Gtk.VBox(spacing=style.zoom(12)) + studio.set_border_width(style.zoom(12)) + workspace.add(studio) + studio.show() + + body = Gtk.HBox(spacing=style.zoom(14)) + body.get_style_context().add_class('create-ai-studio-body') + studio.pack_start(body, True, True, 0) + body.show() + + body.pack_start(self._create_studio_left_panel(), + False, False, 0) + body.pack_start(self._create_studio_preview_panel(), + True, True, 0) + body.pack_start(self._create_learning_sidebar(), + False, False, 0) + + footer = Gtk.HBox(spacing=style.zoom(8)) + footer.get_style_context().add_class('create-ai-studio-footer') + footer.set_halign(Gtk.Align.END) + studio.pack_start(footer, False, False, 0) + footer.show() + + footer.pack_start(self._create_plain_button(_('Back'), + self.__studio_back_cb), + False, False, 0) + footer.pack_start(self._create_plain_button(_('Rebuild'), + self.__studio_rebuild_cb), + False, False, 0) + footer.pack_start(self._create_plain_button( + _('Export XO'), self.__export_xo_cb), + False, False, 0) + footer.pack_start(self._create_primary_button( + _('Install & Open'), self.__install_and_open_cb), + False, False, 0) + + workspace.show() + return workspace + + def _use_centered_layout(self): + self._content_alignment.props.xalign = 0.5 + self._content_alignment.props.yalign = 0.46 + self._content_alignment.props.xscale = 1 + self._content_alignment.props.yscale = 0 + + def _use_studio_layout(self): + self._content_alignment.props.xalign = 0.5 + self._content_alignment.props.yalign = 0 + self._content_alignment.props.xscale = 1 + self._content_alignment.props.yscale = 1 + + def _create_studio_left_panel(self): + panel = Gtk.EventBox() + panel.get_style_context().add_class('create-ai-studio-side') + panel.set_size_request(style.zoom(455), -1) + + box = Gtk.VBox(spacing=style.zoom(11)) + box.set_border_width(style.zoom(14)) + panel.add(box) + box.show() + + prompt_label = Gtk.Label(_('learning activity')) + prompt_label.get_style_context().add_class('create-ai-studio-chip') + prompt_label.set_halign(Gtk.Align.CENTER) + prompt_label.set_justify(Gtk.Justification.CENTER) + box.pack_start(prompt_label, False, False, 0) + prompt_label.show() + self._studio_prompt_labels.append(prompt_label) + + chat_title = Gtk.Label(_('AI co-designer')) + chat_title.get_style_context().add_class('create-ai-chat-heading') + chat_title.set_xalign(0) + box.pack_start(chat_title, False, False, 0) + chat_title.show() + + self._chat_scroll = Gtk.ScrolledWindow() + self._chat_scroll.set_policy(Gtk.PolicyType.NEVER, + Gtk.PolicyType.AUTOMATIC) + self._chat_scroll.get_style_context().add_class( + 'create-ai-chat-scroll') + box.pack_start(self._chat_scroll, True, True, 0) + self._chat_scroll.show() + + self._chat_messages_box = Gtk.VBox(spacing=style.zoom(6)) + self._chat_messages_box.set_border_width(style.zoom(4)) + self._chat_scroll.add_with_viewport(self._chat_messages_box) + self._chat_messages_box.show() + + chat_messages = [ + (_('Ready. Generate an activity, then select a preview part to ' + 'refine it.'), False), + ] + for message, from_user in chat_messages: + self._append_chat_message(message, from_user, scroll=False) + + composer = Gtk.EventBox() + composer.get_style_context().add_class('create-ai-chat-composer') + box.pack_start(composer, False, False, 0) + composer.show() + + composer_box = Gtk.HBox(spacing=style.zoom(10)) + composer_box.set_border_width(style.zoom(11)) + composer.add(composer_box) + composer_box.show() + + self._chat_entry = Gtk.Entry() + self._chat_entry.set_placeholder_text( + _('Type a refinement...')) + self._chat_entry.get_style_context().add_class( + 'create-ai-chat-entry') + self._chat_entry.connect('activate', self.__chat_entry_activate_cb) + composer_box.pack_start(self._chat_entry, True, True, 0) + self._chat_entry.show() + + send_button = Gtk.Button.new_with_label(_('Send')) + send_button.get_style_context().add_class('create-ai-chat-send') + send_button.connect('clicked', self.__chat_send_clicked_cb) + composer_box.pack_start(send_button, False, False, 0) + send_button.show() + + panel.show() + return panel + + def _append_chat_message(self, text, from_user=False, scroll=True): + if self._chat_messages_box is None: + return + + row = Gtk.HBox() + bubble = Gtk.EventBox() + bubble.get_style_context().add_class('create-ai-chat-bubble') + if from_user: + bubble.get_style_context().add_class('create-ai-chat-bubble-user') + else: + bubble.get_style_context().add_class('create-ai-chat-bubble-ai') + + label = Gtk.Label(text) + label.get_style_context().add_class('create-ai-chat-text') + label.set_line_wrap(True) + label.set_max_width_chars(40) + label.set_xalign(0) + label.set_margin_top(style.zoom(6)) + label.set_margin_bottom(style.zoom(6)) + label.set_margin_start(style.zoom(10)) + label.set_margin_end(style.zoom(10)) + bubble.add(label) + label.show() + + spacer = Gtk.Label() + if from_user: + row.pack_start(spacer, True, True, 0) + row.pack_start(bubble, False, False, 0) + else: + row.pack_start(bubble, False, False, 0) + row.pack_start(spacer, True, True, 0) + + self._chat_messages_box.pack_start(row, False, False, 0) + spacer.show() + bubble.show() + row.show() + + if scroll: + GObject.idle_add(self.__scroll_chat_to_bottom) + + def _append_chat_status(self, text, scroll=True): + if self._chat_messages_box is None: + return + + row = Gtk.HBox() + label = Gtk.Label(_('- %s') % text) + label.get_style_context().add_class('create-ai-chat-status') + label.set_xalign(0) + label.set_line_wrap(True) + label.set_max_width_chars(42) + row.pack_start(label, True, True, 0) + self._chat_messages_box.pack_start(row, False, False, 0) + label.show() + row.show() + + if scroll: + GObject.idle_add(self.__scroll_chat_to_bottom) + + def __scroll_chat_to_bottom(self): + if self._chat_scroll is None: + return False + + adjustment = self._chat_scroll.get_vadjustment() + adjustment.set_value(adjustment.get_upper() - + adjustment.get_page_size()) + return False + + def _create_studio_preview_panel(self): + shell = Gtk.EventBox() + shell.get_style_context().add_class('create-ai-preview-shell') + + panel = Gtk.VBox(spacing=style.zoom(8)) + panel.set_border_width(style.zoom(11)) + shell.add(panel) + panel.show() + + top = Gtk.HBox(spacing=style.zoom(8)) + panel.pack_start(top, False, False, 0) + top.show() + + title = Gtk.Label(_('Classroom preview')) + title.get_style_context().add_class('create-ai-studio-section-title') + title.set_xalign(0) + top.pack_start(title, True, True, 0) + title.show() + + top.pack_end(self._create_plain_button( + _('Review and install'), self.__review_and_install_cb), + False, False, 0) + + tabs = Gtk.HBox(spacing=style.zoom(8)) + panel.pack_start(tabs, False, False, 0) + tabs.show() + self._studio_preview_tab = self._create_studio_tab(_('Preview'), True) + self._studio_preview_tab.connect('clicked', + self.__studio_tab_clicked_cb, + 'preview') + tabs.pack_start(self._studio_preview_tab, False, False, 0) + + self._studio_review_tab = self._create_studio_tab(_('Review'), False) + self._studio_review_tab.connect('clicked', + self.__studio_tab_clicked_cb, + 'review') + tabs.pack_start(self._studio_review_tab, False, False, 0) + + self._studio_versions_tab = self._create_studio_tab( + _('Versions'), False) + self._studio_versions_tab.connect('clicked', + self.__studio_tab_clicked_cb, + 'versions') + tabs.pack_start(self._studio_versions_tab, False, False, 0) + + modes = Gtk.HBox(spacing=style.zoom(6)) + panel.pack_start(modes, False, False, 0) + modes.show() + for label in [_('Make'), _('Play'), _('Share')]: + modes.pack_start(self._create_soft_pill(label), False, False, 0) + + self._studio_mode_stack = Gtk.Stack() + self._studio_mode_stack.set_transition_type( + Gtk.StackTransitionType.CROSSFADE) + self._studio_mode_stack.set_transition_duration(140) + panel.pack_start(self._studio_mode_stack, True, True, 0) + self._studio_mode_stack.show() + + preview_page = Gtk.VBox(spacing=style.zoom(9)) + preview_page.pack_start(self._create_preview_frame(), True, True, 0) + preview_page.pack_start(self._create_live_edit_panel(), False, False, + 0) + self._studio_mode_stack.add_named(preview_page, 'preview') + preview_page.show() + + review_page = self._create_review_page() + self._studio_mode_stack.add_named(review_page, 'review') + review_page.show() + + versions_page = self._create_versions_page() + self._studio_mode_stack.add_named(versions_page, 'versions') + versions_page.show() + + self._studio_mode_stack.set_visible_child_name('preview') + + shell.show() + return shell + + def _create_preview_frame(self): + frame = Gtk.EventBox() + frame.get_style_context().add_class('create-ai-preview-frame') + frame.show() + + frame_box = Gtk.VBox(spacing=style.zoom(6)) + frame_box.set_border_width(style.zoom(10)) + frame.add(frame_box) + frame_box.show() + + preview_align = Gtk.Alignment(xalign=0.5, yalign=0.48, xscale=1, + yscale=1) + frame_box.pack_start(preview_align, True, True, 0) + preview_align.show() + preview_align.add(self._create_activity_preview()) + + help_text = Gtk.Label( + _('Click a preview area to select it, then describe the change.')) + help_text.get_style_context().add_class('create-ai-meta-note') + help_text.set_xalign(0) + frame_box.pack_start(help_text, False, False, 0) + help_text.show() + + return frame + + def _create_activity_preview(self): + preview = Gtk.VBox() + preview.get_style_context().add_class('create-ai-activity-preview') + preview.set_border_width(style.zoom(18)) + + empty = Gtk.Alignment(xalign=0.5, yalign=0.5, xscale=1, yscale=1) + preview.pack_start(empty, True, True, 0) + empty.show() + + content = Gtk.VBox(spacing=style.zoom(8)) + content.set_halign(Gtk.Align.FILL) + content.set_valign(Gtk.Align.FILL) + self._preview_content_box = content + empty.add(content) + content.show() + + self._show_empty_activity_preview() + preview.show() + return preview + + def _clear_activity_preview(self): + if self._preview_content_box is None: + return + for child in self._preview_content_box.get_children(): + self._preview_content_box.remove(child) + self._preview_empty_title = None + self._preview_empty_note = None + self._preview_generation_spinner = None + self._preview_generation_progress = None + self._preview_generation_stage = None + self._preview_generation_steps = [] + self._preview_generation_step_boxes = [] + + def _show_empty_activity_preview(self): + self._clear_activity_preview() + + title = Gtk.Label(_('Activity preview')) + self._preview_empty_title = title + title.get_style_context().add_class('create-ai-preview-title') + title.set_justify(Gtk.Justification.CENTER) + self._preview_content_box.pack_start(title, False, False, 0) + title.show() + + note = Gtk.Label(_('Backend output will render here.')) + self._preview_empty_note = note + note.get_style_context().add_class('create-ai-meta-note') + note.set_justify(Gtk.Justification.CENTER) + note.set_line_wrap(True) + note.set_line_wrap_mode(Pango.WrapMode.WORD_CHAR) + note.set_max_width_chars(70) + self._preview_content_box.pack_start(note, False, False, 0) + note.show() + + def _show_generation_activity_preview(self, message=None): + self._clear_activity_preview() + + title = Gtk.Label(_('Building your activity')) + self._preview_empty_title = title + title.get_style_context().add_class('create-ai-preview-title') + title.set_justify(Gtk.Justification.CENTER) + self._preview_content_box.pack_start(title, False, False, 0) + title.show() + + note = Gtk.Label( + message or _('Turning your idea into a real Sugar activity')) + self._preview_empty_note = note + note.get_style_context().add_class('create-ai-meta-note') + note.set_justify(Gtk.Justification.CENTER) + note.set_line_wrap(True) + note.set_line_wrap_mode(Pango.WrapMode.WORD_CHAR) + note.set_max_width_chars(70) + self._preview_content_box.pack_start(note, False, False, 0) + note.show() + + progress = Gtk.ProgressBar() + self._preview_generation_progress = progress + progress.set_size_request(style.zoom(280), style.zoom(8)) + progress.get_style_context().add_class('create-ai-generation-progress') + self._preview_content_box.pack_start( + progress, False, False, style.zoom(6)) + progress.show() + + stage = Gtk.Label() + self._preview_generation_stage = stage + stage.get_style_context().add_class('create-ai-generation-stage') + stage.set_justify(Gtk.Justification.CENTER) + stage.set_line_wrap(True) + stage.set_max_width_chars(60) + self._preview_content_box.pack_start( + stage, False, False, style.zoom(4)) + stage.show() + + steps_box = Gtk.VBox(spacing=style.zoom(8)) + steps_box.set_margin_top(style.zoom(6)) + self._preview_content_box.pack_start( + steps_box, False, False, 0) + steps_box.show() + + step_defs = [ + (_('Think'), _('Reading your prompt and planning the activity'), + 'emblem-default'), + (_('Gather'), _('Finding real Sugar activity examples for context'), + 'system-search'), + (_('Write'), _('Writing the Python code for your activity'), + 'accessories-text-editor'), + (_('Check'), _('Validating code safety and Sugar patterns'), + 'dialog-apply'), + (_('Ready'), _('Packaging your installable activity bundle'), + 'package-x-generic'), + ] + self._preview_generation_steps = [] + self._preview_generation_step_boxes = [] + for label_text, desc_text, icon_name in step_defs: + row = Gtk.HBox(spacing=style.zoom(8)) + row.set_halign(Gtk.Align.START) + row.get_style_context().add_class( + 'create-ai-generation-step-row') + + icon = None + try: + from sugar3.graphics.icon import Icon + icon = Icon(icon_name=icon_name, + pixel_size=style.zoom(16)) + except Exception: + icon = Gtk.Label(label_text[0]) + icon.set_size_request(style.zoom(16), style.zoom(16)) + row.pack_start(icon, False, False, 0) + icon.show() + + text_box = Gtk.VBox(spacing=0) + label = Gtk.Label(label_text) + label.get_style_context().add_class('create-ai-generation-step') + label.set_halign(Gtk.Align.START) + text_box.pack_start(label, False, False, 0) + label.show() + + desc = Gtk.Label(desc_text) + desc.get_style_context().add_class( + 'create-ai-generation-step-desc') + desc.set_halign(Gtk.Align.START) + desc.set_line_wrap(True) + desc.set_max_width_chars(50) + text_box.pack_start(desc, False, False, 0) + desc.show() + + row.pack_start(text_box, True, True, 0) + text_box.show() + + steps_box.pack_start(row, False, False, 0) + row.show() + self._preview_generation_steps.append(label) + self._preview_generation_step_boxes.append(row) + + def _create_review_page(self): + review = Gtk.EventBox() + review.get_style_context().add_class('create-ai-review-shell') + + box = Gtk.HBox(spacing=style.zoom(12)) + box.set_border_width(style.zoom(10)) + review.add(box) + box.show() + + files_panel = Gtk.EventBox() + files_panel.get_style_context().add_class('create-ai-review-files') + files_panel.set_size_request(style.zoom(250), -1) + box.pack_start(files_panel, False, False, 0) + files_panel.show() + + files_box = Gtk.VBox(spacing=style.zoom(4)) + files_box.set_border_width(style.zoom(8)) + files_panel.add(files_box) + files_box.show() + + for filename, file_key in [ + (_('README.md'), 'readme'), + (_('LICENSE'), 'license'), + (_('activity/'), 'activity_dir'), + (_('activity.py'), 'activity_py'), + (_('aod_plan.json'), 'plan_json'), + (_('setup.py'), 'setup_py')]: + files_box.pack_start( + self._create_review_file_button(filename, file_key), + False, False, 0) + + code_panel = Gtk.EventBox() + code_panel.get_style_context().add_class('create-ai-review-code') + box.pack_start(code_panel, True, True, 0) + code_panel.show() + + code_box = Gtk.VBox(spacing=style.zoom(8)) + code_box.set_border_width(style.zoom(12)) + code_panel.add(code_box) + code_box.show() + + header = Gtk.HBox(spacing=style.zoom(8)) + code_box.pack_start(header, False, False, 0) + header.show() + + title_box = Gtk.VBox(spacing=style.zoom(3)) + header.pack_start(title_box, True, True, 0) + title_box.show() + + title = Gtk.Label() + self._review_title_label = title + title.get_style_context().add_class('create-ai-review-title') + title.set_xalign(0) + title_box.pack_start(title, False, False, 0) + title.show() + + summary = Gtk.Label() + self._review_summary_label = summary + summary.get_style_context().add_class('create-ai-review-summary') + summary.set_xalign(0) + summary.set_line_wrap(True) + title_box.pack_start(summary, False, False, 0) + summary.show() + + header.pack_end(self._create_plain_button(_('Explain file'), None), + False, False, 0) + header.pack_end(self._create_plain_button(_('Explain project'), None), + False, False, 0) + + meta = Gtk.Label() + self._review_meta_label = meta + meta.get_style_context().add_class('create-ai-review-meta') + meta.set_xalign(0) + code_box.pack_start(meta, False, False, 0) + meta.show() + + source_frame = Gtk.EventBox() + source_frame.get_style_context().add_class('create-ai-code-frame') + code_box.pack_start(source_frame, True, True, 0) + source_frame.show() + + source_scroll = Gtk.ScrolledWindow() + source_scroll.set_policy(Gtk.PolicyType.AUTOMATIC, + Gtk.PolicyType.AUTOMATIC) + source_frame.add(source_scroll) + source_scroll.show() + + code_label = Gtk.Label() + self._review_code_label = code_label + code_label.get_style_context().add_class('create-ai-code-text') + code_label.set_xalign(0) + code_label.set_yalign(0) + code_label.set_selectable(True) + code_label.set_line_wrap(False) + code_label.set_margin_top(style.zoom(10)) + code_label.set_margin_bottom(style.zoom(10)) + code_label.set_margin_start(style.zoom(12)) + code_label.set_margin_end(style.zoom(12)) + source_scroll.add_with_viewport(code_label) + code_label.show() + + review.show() + self._set_review_file('activity_py') + return review + + def _create_review_file_button(self, filename, file_key): + button = Gtk.Button.new_with_label(filename) + button.set_relief(Gtk.ReliefStyle.NONE) + button.get_style_context().add_class('create-ai-review-file') + if file_key == 'activity_py': + button.get_style_context().add_class('create-ai-review-file-active') + button.connect('clicked', self.__review_file_clicked_cb, file_key) + self._review_file_buttons.append((button, file_key)) + button.show() + return button + + def _get_review_file_data(self, file_key): + if self._generation_result is not None: + return self._get_generated_review_file_data(file_key) + if self._review_generation_context: + return self._get_live_generation_review_file_data(file_key) + + license_info = self._get_selected_license() + license_label = license_info['label'] + license_spdx = license_info['spdx'] + plan_data = { + 'template': self._selected_options['template'], + 'planner': self._selected_options['planner'], + 'policy': self._selected_options['policy'], + 'license': { + 'name': license_label, + 'spdx': license_spdx, + }, + 'checks': [ + 'activity imports allowed', + 'Journal hooks present', + 'license metadata present', + 'preview can render without network', + ], + 'live_edits': [], + } + files = { + 'readme': { + 'title': _('README.md'), + 'summary': _('Introduces the generated activity, learner ' + 'goal, classroom flow, license, and install ' + 'steps.'), + 'meta': _('Source (editable) README.md • Markdown • ' + 'Generated'), + 'language': 'markdown', + 'code': _( + '# Learning Activity\n\n' + 'A Sugar activity generated from the learner prompt.\n\n' + '## Classroom goal\n' + '- Make something playful and visible.\n' + '- Try it in the preview canvas.\n' + '- Share the finished activity as an XO bundle.\n\n' + '## License\n' + 'Selected license: %(license)s (%(spdx)s).\n\n' + '## Next steps\n' + 'Use the live edit prompt to refine copy, layout, ' + 'behavior, and Journal saving.') % { + 'license': license_label, + 'spdx': license_spdx, + }, + }, + 'license': { + 'title': _('LICENSE'), + 'summary': _('Contains the selected license text for the ' + 'generated activity bundle.'), + 'meta': _('Legal file LICENSE • Text • %s') % + license_spdx, + 'language': 'text', + 'code': _( + '%(license)s\n\n' + 'SPDX-License-Identifier: %(spdx)s\n\n' + 'The exported Sugar activity will include the full ' + '%(license)s license text here. Source files and bundle ' + 'metadata will use the same SPDX identifier.') % { + 'license': license_label, + 'spdx': license_spdx, + }, + }, + 'activity_dir': { + 'title': _('activity/'), + 'summary': _('Contains the Sugar bundle metadata, license, ' + 'icon, and localization files used when ' + 'packaging.'), + 'meta': _('Bundle folder activity/ • Sugar metadata • ' + 'Generated'), + 'language': 'tree', + 'code': _( + 'activity/\n' + ' activity.info\n' + ' activity.svg\n' + ' locale/\n\n' + 'activity.info declares the bundle id, title, launcher, ' + 'icon, supported Sugar version, and license: %(spdx)s.') + % {'spdx': license_spdx}, + }, + 'activity_py': { + 'title': _('activity.py'), + 'summary': _('Defines the generated Sugar activity, toolbar, ' + 'canvas placeholder, Journal hooks, and preview ' + 'bridge.'), + 'meta': _('Source (editable) activity.py • Python • ' + 'Generated'), + 'language': 'python', + 'code': ( + '# SPDX-License-Identifier: %s\n' + '\n' + 'from gi.repository import Gtk\n' + '\n' + 'from sugar3.activity import activity\n' + 'from sugar3.activity.widgets import ActivityToolbarButton\n' + 'from sugar3.activity.widgets import StopButton\n' + 'from sugar3.graphics.toolbarbox import ToolbarBox\n' + '\n' + '\n' + 'class GeneratedActivity(activity.Activity):\n' + ' def __init__(self, handle):\n' + ' activity.Activity.__init__(self, handle)\n' + ' self.max_participants = 1\n' + ' self._build_toolbar()\n' + ' self._build_canvas()\n' + '\n' + ' def _build_toolbar(self):\n' + ' toolbar_box = ToolbarBox()\n' + ' toolbar = toolbar_box.toolbar\n' + ' toolbar.insert(ActivityToolbarButton(self), 0)\n' + ' toolbar.insert(StopButton(self), -1)\n' + ' self.set_toolbar_box(toolbar_box)\n' + ' toolbar_box.show_all()\n' + '\n' + ' def _build_canvas(self):\n' + ' canvas = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)\n' + ' canvas.set_border_width(24)\n' + ' title = Gtk.Label(label="Activity preview")\n' + ' title.set_justify(Gtk.Justification.CENTER)\n' + ' canvas.pack_start(title, True, True, 0)\n' + ' self.set_canvas(canvas)\n' + ' canvas.show_all()\n' + '\n' + ' def write_file(self, file_path):\n' + ' """Save learner work into the Journal."""\n' + ' pass\n' + '\n' + ' def read_file(self, file_path):\n' + ' """Restore learner work from the Journal."""\n' + ' pass\n') % license_spdx, + }, + 'plan_json': { + 'title': _('aod_plan.json'), + 'summary': _('Stores the AI plan, safety notes, selected ' + 'template, license, and live edit history.'), + 'meta': _('Plan artifact aod_plan.json • JSON • Generated'), + 'language': 'json', + 'code': json.dumps(plan_data, indent=2) + '\n', + }, + 'setup_py': { + 'title': _('setup.py'), + 'summary': _('Packages the generated project as a Sugar ' + 'activity bundle.'), + 'meta': _('Build script setup.py • Python • Generated'), + 'language': 'python', + 'code': ( + 'from sugar3.activity import bundlebuilder\n' + '\n' + '\n' + 'if __name__ == "__main__":\n' + ' bundlebuilder.start()\n'), + }, + } + return files[file_key] + + def _get_live_generation_review_file_data(self, file_key): + context = self._review_generation_context + stage = context.get('stage', 'queued') + progress = int(context.get('progress', 0.0) * 100) + provider = context.get('provider', _('Provider')) + prompt = context.get('prompt', '') + message = context.get('message', _('Starting generation')) + draft_source = context.get('draft_activity_source', '') + mode = _('Refinement') if context.get('is_refinement') else \ + _('Generation') + checklist = [ + _('Sugar Activity subclass'), + _('Toolbar and Stop button'), + _('Full-window GTK canvas'), + _('Journal read/write hooks'), + _('Prompt-specific interaction checks'), + ] + plan_data = { + 'mode': mode, + 'provider': provider, + 'stage': stage, + 'progress_percent': progress, + 'message': message, + 'prompt': prompt, + 'draft_activity_source_available': bool(draft_source), + 'draft_activity_source_chars': len(draft_source), + 'checks_pending': checklist, + } + common_meta = _('%(mode)s in progress %(provider)s - ' + '%(progress)d%%') % { + 'mode': mode, + 'provider': provider, + 'progress': progress, + } + + activity_summary = _( + 'Live generation status. This will be replaced by the ' + 'model-generated source as soon as it passes validation.') + activity_meta = common_meta + activity_code = ( + '# activity.py is being generated by %(provider)s\n' + '# Stage: %(stage)s\n' + '# Progress: %(progress)d%%\n' + '# Status: %(message)s\n' + '# Prompt: %(prompt)s\n' + '\n' + '# Sugar will replace this temporary scaffold with the\n' + '# generated source after these checks pass:\n' + '# - Sugar Activity subclass\n' + '# - toolbar and canvas lifecycle\n' + '# - Journal persistence\n' + '# - prompt-specific learner interaction\n' + '\n' + 'from sugar3.activity import activity\n' + '\n' + '\n' + 'class GeneratedActivity(activity.Activity):\n' + ' pass # waiting for model-generated code\n') % { + 'provider': provider, + 'stage': stage, + 'progress': progress, + 'message': message, + 'prompt': prompt, + } + if draft_source: + if stage == 'failed': + activity_summary = _( + 'The model wrote this activity.py but it did not pass ' + 'Sugar validation. The reasons are shown in the preview ' + 'panel and the chat. You can read the draft here, then ' + 'try a smaller prompt or a different model.') + activity_meta = _( + 'Draft source %(provider)s - validation failed' + ) % { + 'provider': provider, + } + else: + activity_summary = _( + 'Draft activity.py returned by the model. Sugar is ' + 'checking it before previewing or installing.') + activity_meta = _( + 'Draft source %(provider)s - validating - %(progress)d%%' + ) % { + 'provider': provider, + 'progress': progress, + } + activity_code = draft_source + + files = { + 'activity_py': { + 'title': _('activity.py'), + 'summary': activity_summary, + 'meta': activity_meta, + 'language': 'python', + 'code': activity_code, + }, + 'plan_json': { + 'title': _('aod_plan.json'), + 'summary': _('Live generation plan and validation status.'), + 'meta': common_meta, + 'language': 'json', + 'code': json.dumps(plan_data, indent=2, + sort_keys=True) + '\n', + }, + 'readme': { + 'title': _('README.md'), + 'summary': _('The generated README will describe classroom ' + 'use after validation.'), + 'meta': common_meta, + 'language': 'markdown', + 'code': _( + '# Activity generation in progress\n\n' + '- Provider: %(provider)s\n' + '- Stage: %(stage)s\n' + '- Progress: %(progress)d%%\n' + '- Current status: %(message)s\n\n' + 'Prompt:\n\n' + '> %(prompt)s\n') % { + 'provider': provider, + 'stage': stage, + 'progress': progress, + 'message': message, + 'prompt': prompt, + }, + }, + 'license': { + 'title': _('LICENSE'), + 'summary': _('The selected license will be bundled with the ' + 'generated activity.'), + 'meta': common_meta, + 'language': 'text', + 'code': _('License file will be written after validation.\n'), + }, + 'activity_dir': { + 'title': _('activity/'), + 'summary': _('Sugar bundle metadata will be assembled after ' + 'the generated code passes validation.'), + 'meta': common_meta, + 'language': 'tree', + 'code': _('activity/\n activity.info\n activity.svg\n'), + }, + 'setup_py': { + 'title': _('setup.py'), + 'summary': _('Packaging script will be written after ' + 'validation.'), + 'meta': common_meta, + 'language': 'python', + 'code': _('from sugar3.activity import bundlebuilder\n\n' + '# written after activity.py validates\n'), + }, + } + return files[file_key] + + def _get_generated_review_file_data(self, file_key): + result = self._generation_result + path_by_key = { + 'readme': 'README.md', + 'license': 'LICENSE', + 'activity_py': 'activity.py', + 'plan_json': 'aod_plan.json', + 'setup_py': 'setup.py', + } + metadata = { + 'readme': ( + _('README.md'), + _('Explains the generated learning goal and classroom flow.'), + 'markdown', + ), + 'license': ( + _('LICENSE'), + _('Contains the selected activity license.'), + 'text', + ), + 'activity_py': ( + _('activity.py'), + _('Runnable GTK3 and Sugar activity source.'), + 'python', + ), + 'plan_json': ( + _('aod_plan.json'), + _('Records the normalized plan, provider, and validation.'), + 'json', + ), + 'setup_py': ( + _('setup.py'), + _('Builds the generated project as an XO bundle.'), + 'python', + ), + } + + if file_key == 'activity_dir': + paths = sorted( + path for path in result.files if path.startswith('activity/') + ) + return { + 'title': _('activity/'), + 'summary': _('Sugar bundle metadata and icon files.'), + 'meta': _('Generated directory %s') % result.bundle_id, + 'language': 'tree', + 'code': '\n'.join(paths) + '\n', + } + + relative_path = path_by_key[file_key] + title, summary, language = metadata[file_key] + return { + 'title': title, + 'summary': summary, + 'meta': _('Generated file %s') % relative_path, + 'language': language, + 'code': result.files.get( + relative_path, + _('The generated file is unavailable.'), + ), + } + + def _set_review_file(self, file_key): + if self._review_title_label is None: + return + + data = self._get_review_file_data(file_key) + self._current_review_file = file_key + self._review_title_label.set_text(data['title']) + self._review_summary_label.set_text(data['summary']) + self._review_meta_label.set_text(data['meta']) + self._review_code_label.set_markup( + self._format_code_markup(data['code'], data['language'])) + + for button, key in self._review_file_buttons: + button.get_style_context().remove_class( + 'create-ai-review-file-active') + if key == file_key: + button.get_style_context().add_class( + 'create-ai-review-file-active') + + def _create_versions_page(self): + versions = Gtk.EventBox() + versions.get_style_context().add_class('create-ai-versions-shell') + + box = Gtk.HBox(spacing=style.zoom(12)) + box.set_border_width(style.zoom(10)) + versions.add(box) + box.show() + + history_panel = Gtk.EventBox() + history_panel.get_style_context().add_class( + 'create-ai-version-history') + history_panel.set_size_request(style.zoom(285), -1) + box.pack_start(history_panel, False, False, 0) + history_panel.show() + + history_box = Gtk.VBox(spacing=style.zoom(8)) + history_box.set_border_width(style.zoom(10)) + history_panel.add(history_box) + history_box.show() + + history_title = Gtk.Label(_('VERSION HISTORY')) + history_title.get_style_context().add_class( + 'create-ai-version-heading') + history_title.set_xalign(0) + history_box.pack_start(history_title, False, False, 0) + history_title.show() + + history_scroll = Gtk.ScrolledWindow() + history_scroll.set_policy(Gtk.PolicyType.NEVER, + Gtk.PolicyType.AUTOMATIC) + history_box.pack_start(history_scroll, True, True, 0) + history_scroll.show() + + version_list = Gtk.VBox(spacing=style.zoom(9)) + self._version_history_box = version_list + version_list.set_border_width(style.zoom(2)) + history_scroll.add_with_viewport(version_list) + version_list.show() + + self._refresh_version_history() + + content = Gtk.VBox(spacing=style.zoom(9)) + box.pack_start(content, True, True, 0) + content.show() + + switch_row = Gtk.HBox(spacing=style.zoom(8)) + content.pack_start(switch_row, False, False, 0) + switch_row.show() + + self._version_source_button = self._create_version_switch_button( + _('View Source'), 'source') + switch_row.pack_start(self._version_source_button, False, False, 0) + + self._version_diff_button = self._create_version_switch_button( + _('Diff View'), 'diff') + switch_row.pack_start(self._version_diff_button, False, False, 0) + + compare_row = Gtk.HBox(spacing=style.zoom(8)) + content.pack_start(compare_row, False, False, 0) + compare_row.show() + + compare_label = Gtk.Label(_('Compare')) + compare_label.get_style_context().add_class('create-ai-meta-label') + compare_label.set_xalign(0) + compare_row.pack_start(compare_label, False, False, 0) + compare_label.show() + + compare_row.pack_start(self._create_version_compare_pill(_('v1')), + False, False, 0) + + arrow = Gtk.Label(_('->')) + arrow.get_style_context().add_class('create-ai-meta-note') + compare_row.pack_start(arrow, False, False, 0) + arrow.show() + + compare_row.pack_start(self._create_version_compare_pill(_('v6')), + False, False, 0) + + title = Gtk.Label() + self._version_title_label = title + title.get_style_context().add_class('create-ai-review-title') + title.set_xalign(0) + content.pack_start(title, False, False, 0) + title.show() + + meta = Gtk.Label() + self._version_meta_label = meta + meta.get_style_context().add_class('create-ai-review-meta') + meta.set_xalign(0) + content.pack_start(meta, False, False, 0) + meta.show() + + source_frame = Gtk.EventBox() + source_frame.get_style_context().add_class('create-ai-code-frame') + content.pack_start(source_frame, True, True, 0) + source_frame.show() + + source_scroll = Gtk.ScrolledWindow() + source_scroll.set_policy(Gtk.PolicyType.AUTOMATIC, + Gtk.PolicyType.AUTOMATIC) + source_frame.add(source_scroll) + source_scroll.show() + + code_label = Gtk.Label() + self._version_code_label = code_label + code_label.get_style_context().add_class('create-ai-code-text') + code_label.set_xalign(0) + code_label.set_yalign(0) + code_label.set_selectable(True) + code_label.set_line_wrap(False) + code_label.set_margin_top(style.zoom(10)) + code_label.set_margin_bottom(style.zoom(10)) + code_label.set_margin_start(style.zoom(12)) + code_label.set_margin_end(style.zoom(12)) + source_scroll.add_with_viewport(code_label) + code_label.show() + + versions.show() + self._set_versions_mode('diff') + return versions + + def _refresh_version_history(self): + if self._version_history_box is None: + return + + for child in self._version_history_box.get_children(): + self._version_history_box.remove(child) + self._version_history_buttons = [] + + versions = self._get_version_history() + keys = [version['key'] for version in versions] + if keys and self._selected_version not in keys: + self._selected_version = keys[-1] + + for version in versions: + self._version_history_box.pack_start( + self._create_version_card(version), + False, + False, + 0, + ) + self._version_history_box.show_all() + + if self._version_title_label is not None: + self._set_versions_mode(self._version_mode) + + def _get_version_history(self): + revisions = self._get_session_revisions() + if revisions: + versions = [] + for index, revision in enumerate(revisions, 1): + summary = revision.result_summary or {} + prompt = ' '.join((revision.prompt or '').split()) + if len(prompt) > 72: + prompt = prompt[:69].rstrip() + '...' + activity_name = summary.get('activity_name') or \ + self._get_prompt_text() or _('Generated activity') + provider = summary.get('provider', '') + model = summary.get('model', '') + if model: + provider = '%s / %s' % (provider, model) + detail = prompt or summary.get('template', '') + if provider: + detail = '%s • %s' % (provider, detail) + versions.append({ + 'key': revision.revision_id, + 'label': _('v%d') % index, + 'date': time.strftime( + '%Y-%m-%d %H:%M:%S', + time.localtime(revision.created_at), + ), + 'summary': _('%(name)s\n%(detail)s') % { + 'name': activity_name, + 'detail': detail, + }, + 'revision': revision, + }) + return versions + + return [ + { + 'key': 'v1', + 'label': _('v1'), + 'date': _('2026-06-01 11:42:10'), + 'summary': _('Initial activity scaffold from the first ' + 'learning prompt.'), + }, + { + 'key': 'v2', + 'label': _('v2'), + 'date': _('2026-06-01 11:45:04'), + 'summary': _('Added learner-facing prompt copy and starter ' + 'canvas structure.'), + }, + { + 'key': 'v3', + 'label': _('v3'), + 'date': _('2026-06-01 11:48:31'), + 'summary': _('Connected toolbar actions and preview metadata.'), + }, + { + 'key': 'v4', + 'label': _('v4'), + 'date': _('2026-06-01 11:50:59'), + 'summary': _('Prepared Journal save and restore hooks.'), + }, + { + 'key': 'v5', + 'label': _('v5'), + 'date': _('2026-06-01 11:53:31'), + 'summary': _('Added safety checks and guided edit notes.'), + }, + { + 'key': 'v6', + 'label': _('v6'), + 'date': _('2026-06-01 11:56:57'), + 'summary': _('Latest version with preview bridge and export ' + 'metadata ready.'), + }, + ] + + def _get_session_revisions(self): + if not self._aod_session_id: + return [] + + from jarabe.model.aodservice import get_service + + session = get_service().get_session(self._aod_session_id) + if session is None: + return [] + return list(session.revisions) + + def _create_version_card(self, version): + card = Gtk.EventBox() + card.get_style_context().add_class('create-ai-version-card') + if version['key'] == self._selected_version: + card.get_style_context().add_class('create-ai-version-card-active') + card.add_events(Gdk.EventMask.BUTTON_RELEASE_MASK) + card.connect('button-release-event', + self.__version_card_button_release_cb, version['key']) + self._version_history_buttons.append((card, version['key'])) + + box = Gtk.VBox(spacing=style.zoom(7)) + box.set_border_width(style.zoom(10)) + card.add(box) + box.show() + + header = Gtk.HBox(spacing=style.zoom(8)) + box.pack_start(header, False, False, 0) + header.show() + + chip = Gtk.Label(version['label']) + chip.get_style_context().add_class('create-ai-version-chip') + header.pack_start(chip, False, False, 0) + chip.show() + + date = Gtk.Label(version['date']) + date.get_style_context().add_class('create-ai-version-date') + date.set_xalign(1) + header.pack_start(date, True, True, 0) + date.show() + + summary = Gtk.Label(version['summary']) + summary.get_style_context().add_class('create-ai-studio-note-label') + summary.set_xalign(0) + summary.set_line_wrap(True) + summary.set_max_width_chars(28) + box.pack_start(summary, False, False, 0) + summary.show() + + action = Gtk.Label(_('View Source')) + action.get_style_context().add_class('create-ai-version-card-action') + action.set_justify(Gtk.Justification.CENTER) + box.pack_start(action, False, False, 0) + action.show() + + card.show() + return card + + def _create_version_switch_button(self, label, mode): + button = Gtk.Button.new_with_label(label) + button.set_relief(Gtk.ReliefStyle.NONE) + button.get_style_context().add_class('create-ai-version-switch') + button.connect('clicked', self.__version_switch_clicked_cb, mode) + button.show() + return button + + def _create_version_compare_pill(self, label): + pill = Gtk.Label(label) + pill.get_style_context().add_class('create-ai-version-compare-pill') + pill.show() + return pill + + def _set_versions_mode(self, mode): + self._version_mode = mode + if self._version_source_button is not None: + self._version_source_button.get_style_context().remove_class( + 'create-ai-version-switch-active') + if self._version_diff_button is not None: + self._version_diff_button.get_style_context().remove_class( + 'create-ai-version-switch-active') + + if mode == 'source': + if self._version_source_button is not None: + self._version_source_button.get_style_context().add_class( + 'create-ai-version-switch-active') + self._set_version_source(self._selected_version) + else: + if self._version_diff_button is not None: + self._version_diff_button.get_style_context().add_class( + 'create-ai-version-switch-active') + self._set_version_diff() + + def _set_version_source(self, version_key): + if self._version_title_label is None: + return + + self._selected_version = version_key + for card, key in self._version_history_buttons: + card.get_style_context().remove_class( + 'create-ai-version-card-active') + if key == version_key: + card.get_style_context().add_class( + 'create-ai-version-card-active') + + self._version_title_label.set_text( + _('Source - %s (read-only)') % + self._version_label_for_key(version_key)) + self._version_meta_label.set_text( + _('activity.py • Python • generated version snapshot')) + self._version_code_label.set_markup( + self._format_code_markup( + self._get_version_source(version_key), 'python')) + + def _set_version_diff(self): + if self._version_title_label is None: + return + + self._version_title_label.set_text(_('Diff View')) + before, after = self._get_version_diff_pair() + if before and after: + lines = self._get_version_diff_lines() + added = sum(1 for marker, unused in lines if marker == '+') + removed = sum(1 for marker, unused in lines if marker == '-') + self._version_meta_label.set_text( + _('%(before)s -> %(after)s • +%(added)d / -%(removed)d ' + 'lines • activity.py') % { + 'before': self._version_label_for_key(before), + 'after': self._version_label_for_key(after), + 'added': added, + 'removed': removed, + }) + else: + self._version_meta_label.set_text( + _('v1 -> v6 • +12 / -5 lines • activity.py')) + self._version_code_label.set_markup(self._format_diff_markup()) + + def _get_version_source(self, version_key): + source = self._read_revision_source(version_key) + if source: + return source + + prompt = self._get_prompt_text() or _('learning activity') + if len(prompt) > 42: + prompt = prompt[:39] + '...' + + lines = [ + 'from gi.repository import Gtk', + '', + 'from sugar3.activity import activity', + 'from sugar3.activity.widgets import ActivityToolbarButton', + 'from sugar3.activity.widgets import StopButton', + 'from sugar3.graphics.toolbarbox import ToolbarBox', + '', + '', + 'ACTIVITY_TITLE = "%s"' % prompt, + 'TEMPLATE_NAME = "starter"', + ] + + if version_key in ('v4', 'v5', 'v6'): + lines.extend([ + 'JOURNAL_ENABLED = True', + 'PREVIEW_BRIDGE = "%s"' % + ('ready' if version_key == 'v6' else 'planned'), + ]) + if version_key in ('v5', 'v6'): + lines.append('SAFETY_CHECKS = ["imports", "journal", "offline"]') + + lines.extend([ + '', + '', + 'class GeneratedActivity(activity.Activity):', + ' def __init__(self, handle):', + ' activity.Activity.__init__(self, handle)', + ' self.max_participants = 1', + ' self._build_toolbar()', + ' self._build_canvas()', + '', + ' def _build_toolbar(self):', + ' toolbar_box = ToolbarBox()', + ' toolbar_box.toolbar.insert(ActivityToolbarButton(self), 0)', + ' toolbar_box.toolbar.insert(StopButton(self), -1)', + ' self.set_toolbar_box(toolbar_box)', + ' toolbar_box.show_all()', + '', + ' def _build_canvas(self):', + ' canvas = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)', + ' title = Gtk.Label(label=ACTIVITY_TITLE)', + ' canvas.pack_start(title, True, True, 0)', + ' self.set_canvas(canvas)', + ' canvas.show_all()', + ]) + + if version_key in ('v4', 'v5', 'v6'): + lines.extend([ + '', + ' def write_file(self, file_path):', + ' # Journal save hook will be connected by backend.', + ' pass', + '', + ' def read_file(self, file_path):', + ' # Journal restore hook will be connected by backend.', + ' pass', + ]) + + return '\n'.join(lines) + + def _get_version_diff_lines(self): + before, after = self._get_version_diff_pair() + if before and after: + before_source = self._get_version_source(before).splitlines() + after_source = self._get_version_source(after).splitlines() + lines = [] + for line in difflib.ndiff(before_source, after_source): + if line.startswith('?'): + continue + marker = line[:1] + text = line[2:] + if marker not in ('+', '-'): + marker = ' ' + lines.append((marker, text)) + return lines or [(' ', '# No source changes in this revision.')] + + return [ + (' ', 'from gi.repository import Gtk'), + (' ', ''), + (' ', 'from sugar3.activity import activity'), + (' ', 'from sugar3.activity.widgets import ActivityToolbarButton'), + (' ', 'from sugar3.activity.widgets import StopButton'), + (' ', 'from sugar3.graphics.toolbarbox import ToolbarBox'), + (' ', ''), + ('-', 'ACTIVITY_TITLE = "learning activity"'), + ('+', 'ACTIVITY_TITLE = "%s"' % + (self._get_prompt_text() or _('learning activity'))[:42]), + (' ', 'TEMPLATE_NAME = "starter"'), + ('+', 'JOURNAL_ENABLED = True'), + ('+', 'PREVIEW_BRIDGE = "ready"'), + ('+', 'SAFETY_CHECKS = ["imports", "journal", "offline"]'), + (' ', ''), + (' ', ''), + (' ', 'class GeneratedActivity(activity.Activity):'), + (' ', ' def __init__(self, handle):'), + (' ', ' activity.Activity.__init__(self, handle)'), + (' ', ' self.max_participants = 1'), + (' ', ' self._build_toolbar()'), + ('+', ' self._preview_ready = True'), + (' ', ' self._build_canvas()'), + (' ', ''), + ('-', ' title = Gtk.Label(label="Activity preview")'), + ('+', ' title = Gtk.Label(label=ACTIVITY_TITLE)'), + ('+', ' title.set_justify(Gtk.Justification.CENTER)'), + ('-', ' canvas.pack_start(title, False, False, 0)'), + ('+', ' canvas.pack_start(title, True, True, 0)'), + (' ', ' self.set_canvas(canvas)'), + (' ', ' canvas.show_all()'), + ('+', ''), + ('+', ' def write_file(self, file_path):'), + ('+', ' # Journal save hook will be connected by backend.'), + ('+', ' pass'), + ] + + def _get_version_diff_pair(self): + revisions = self._get_session_revisions() + if len(revisions) < 2: + return '', '' + + revision_ids = [revision.revision_id for revision in revisions] + selected = self._selected_version + if selected not in revision_ids: + selected = revision_ids[-1] + index = revision_ids.index(selected) + if index == 0: + index = 1 + return revision_ids[index - 1], revision_ids[index] + + def _version_label_for_key(self, version_key): + for version in self._get_version_history(): + if version['key'] == version_key: + return version['label'] + return version_key + + def _revision_for_key(self, version_key): + for revision in self._get_session_revisions(): + if revision.revision_id == version_key: + return revision + return None + + def _read_revision_source(self, version_key): + revision = self._revision_for_key(version_key) + if revision is None: + return '' + + if self._generation_result is not None and \ + version_key == self._aod_active_revision_id: + files = getattr(self._generation_result, 'files', {}) + if isinstance(files, dict): + source = files.get('activity.py', '') + if source: + return source + + summary = revision.result_summary or {} + project_path = summary.get('project_path', '') + if not project_path: + return '' + source_path = os.path.join(project_path, 'activity.py') + try: + with open(source_path, encoding='utf-8') as source_file: + return source_file.read() + except OSError: + return '' + + def _format_diff_markup(self): + lines = self._get_version_diff_lines() + width = len(str(len(lines))) + markup_lines = [] + + for number, (marker, line) in enumerate(lines, 1): + line_number = self._span(str(number).rjust(width), + self._CODE_COLORS['line_number']) + if marker == '+': + sign = self._span('+', self._CODE_COLORS['diff_added'], + bold=True) + code = self._highlight_python_line(line) + markup = line_number + self._escape_code_markup(' ') + \ + sign + self._escape_code_markup(' ') + code + \ + self._escape_code_markup( + ' ' * max(8, 92 - len(line))) + markup_lines.append( + self._background(markup, + self._CODE_COLORS['diff_added_bg'])) + elif marker == '-': + sign = self._span('-', self._CODE_COLORS['diff_deleted'], + bold=True) + code = self._highlight_python_line(line) + markup = line_number + self._escape_code_markup(' ') + \ + sign + self._escape_code_markup(' ') + code + \ + self._escape_code_markup( + ' ' * max(8, 92 - len(line))) + markup_lines.append( + self._background(markup, + self._CODE_COLORS['diff_deleted_bg'])) + else: + markup_lines.append( + line_number + self._escape_code_markup(' ') + + self._highlight_python_line(line)) + + return '\n'.join(markup_lines) + + def _format_code_markup(self, code, language): + lines = code.split('\n') + width = len(str(max(1, len(lines)))) + markup_lines = [] + + for number, line in enumerate(lines, 1): + line_number = str(number).rjust(width) + markup_lines.append( + self._span(line_number, self._CODE_COLORS['line_number']) + + self._escape_code_markup(' ') + + self._highlight_code_line(line, language)) + + return '\n'.join(markup_lines) + + def _highlight_code_line(self, line, language): + if language == 'python': + return self._highlight_python_line(line) + if language == 'json': + return self._highlight_json_line(line) + if language == 'markdown': + return self._highlight_markdown_line(line) + return self._escape_code_markup(line) + + def _highlight_python_line(self, line): + code, comment = self._split_python_comment(line) + highlighted = self._highlight_python_code(code) + if comment: + highlighted += self._span(comment, self._CODE_COLORS['comment']) + return highlighted + + def _highlight_python_code(self, code): + token_re = re.compile( + r'(\"[^\"\\]*(?:\\.[^\"\\]*)*\"|' + r'\'[^\'\\]*(?:\\.[^\'\\]*)*\'|' + r'\b\d+(?:\.\d+)?\b|' + r'\b[A-Za-z_][A-Za-z0-9_]*\b)') + parts = [] + cursor = 0 + + for match in token_re.finditer(code): + start, end = match.span() + token = match.group(0) + parts.append(self._escape_code_markup(code[cursor:start])) + prefix = code[:start] + + if token.startswith('"') or token.startswith("'"): + parts.append(self._span(token, self._CODE_COLORS['string'])) + elif token in self._PYTHON_KEYWORDS: + color = self._CODE_COLORS['keyword'] + if token in ('True', 'False', 'None'): + color = self._CODE_COLORS['constant'] + parts.append(self._span(token, color, bold=True)) + elif re.search(r'\bclass\s+$', prefix): + parts.append(self._span(token, self._CODE_COLORS['class_name'], + bold=True)) + elif re.search(r'\bdef\s+$', prefix): + parts.append(self._span(token, self._CODE_COLORS['function'], + bold=True)) + elif token in self._PYTHON_TYPES: + parts.append(self._span(token, self._CODE_COLORS['class_name'])) + elif re.match(r'^\d', token): + parts.append(self._span(token, self._CODE_COLORS['number'])) + elif start > 0 and code[start - 1] == '.': + parts.append(self._span(token, self._CODE_COLORS['property'])) + else: + parts.append(self._escape_code_markup(token)) + + cursor = end + + parts.append(self._escape_code_markup(code[cursor:])) + return ''.join(parts) + + def _highlight_json_line(self, line): + token_re = re.compile( + r'(\"[^\"\\]*(?:\\.[^\"\\]*)*\"|' + r'\b(?:true|false|null)\b|' + r'-?\b\d+(?:\.\d+)?\b)') + parts = [] + cursor = 0 + + for match in token_re.finditer(line): + start, end = match.span() + token = match.group(0) + parts.append(self._escape_code_markup(line[cursor:start])) + if token.startswith('"'): + if line[end:].lstrip().startswith(':'): + parts.append(self._span(token, + self._CODE_COLORS['property'])) + else: + parts.append(self._span(token, + self._CODE_COLORS['string'])) + elif token in ('true', 'false', 'null'): + parts.append(self._span(token, + self._CODE_COLORS['constant'], + bold=True)) + else: + parts.append(self._span(token, self._CODE_COLORS['number'])) + cursor = end + + parts.append(self._escape_code_markup(line[cursor:])) + return ''.join(parts) + + def _highlight_markdown_line(self, line): + stripped = line.lstrip() + if stripped.startswith('#'): + return self._span(line, self._CODE_COLORS['markdown'], bold=True) + if stripped.startswith('-'): + indent = line[:len(line) - len(stripped)] + return self._escape_code_markup(indent) + \ + self._span(stripped[0], self._CODE_COLORS['keyword'], + bold=True) + \ + self._escape_code_markup(stripped[1:]) + return self._escape_code_markup(line) + + def _split_python_comment(self, line): + quote = None + escaped = False + + for index, char in enumerate(line): + if escaped: + escaped = False + continue + if char == '\\': + escaped = True + continue + if quote is not None: + if char == quote: + quote = None + continue + if char in ('"', "'"): + quote = char + continue + if char == '#': + return line[:index], line[index:] + + return line, '' + + def _span(self, text, color, bold=False): + weight = ' weight="bold"' if bold else '' + return '%s' % ( + color, weight, self._escape_code_markup(text)) + + def _background(self, markup, color): + return '%s' % (color, markup) + + def _escape_code_markup(self, text): + return text.replace('&', '&') \ + .replace('<', '<') \ + .replace('>', '>') \ + .replace('\t', '    ') \ + .replace(' ', ' ') + + def _create_live_edit_panel(self): + panel = Gtk.EventBox() + panel.get_style_context().add_class('create-ai-live-edit-panel') + panel.set_size_request(-1, style.zoom(164)) + + box = Gtk.VBox(spacing=style.zoom(8)) + box.set_border_width(style.zoom(12)) + panel.add(box) + box.show() + + header = Gtk.HBox(spacing=style.zoom(8)) + box.pack_start(header, False, False, 0) + header.show() + + title = Gtk.Label(_('Live Edit Mode')) + title.get_style_context().add_class('create-ai-studio-section-title') + title.set_xalign(0) + header.pack_start(title, True, True, 0) + title.show() + + toggle = Gtk.HBox(spacing=0) + toggle.get_style_context().add_class('create-ai-live-toggle-group') + header.pack_start(toggle, False, False, 0) + toggle.show() + + self._live_edit_on_button = self._create_live_toggle_button( + _('On'), True) + self._live_edit_off_button = self._create_live_toggle_button( + _('Off'), False) + toggle.pack_start(self._live_edit_on_button, False, False, 0) + toggle.pack_start(self._live_edit_off_button, False, False, 0) + + description = Gtk.Label( + _('Click a preview part, then describe the change.')) + description.get_style_context().add_class('create-ai-meta-note') + description.set_xalign(0) + description.set_line_wrap(True) + box.pack_start(description, False, False, 0) + description.show() + + self._live_edit_target_label = Gtk.Label() + self._live_edit_target_label.get_style_context().add_class( + 'create-ai-live-target') + self._live_edit_target_label.set_xalign(0) + self._live_edit_target_label.set_size_request(-1, style.zoom(34)) + box.pack_start(self._live_edit_target_label, False, False, 0) + self._live_edit_target_label.show() + self._set_live_edit_target(_('activity canvas')) + + row = Gtk.HBox(spacing=style.zoom(10)) + box.pack_start(row, False, False, 0) + row.show() + + self._live_edit_entry = Gtk.Entry() + self._live_edit_entry.set_placeholder_text( + _('Describe a preview change...')) + self._live_edit_entry.get_style_context().add_class( + 'create-ai-live-entry') + self._live_edit_entry.connect( + 'activate', self.__live_edit_entry_activate_cb) + self._live_edit_entry.set_size_request(-1, style.zoom(44)) + row.pack_start(self._live_edit_entry, True, True, 0) + self._live_edit_entry.show() + + add_button = Gtk.Button.new_with_label(_('Apply Change')) + add_button.get_style_context().add_class('create-ai-preview-change') + add_button.connect('clicked', self.__live_edit_add_clicked_cb) + add_button.set_size_request(style.zoom(170), style.zoom(44)) + row.pack_start(add_button, False, False, 0) + add_button.show() + + self._live_edit_status_label = Gtk.Label(_('Ready for preview edits')) + self._live_edit_status_label.get_style_context().add_class( + 'create-ai-meta-note') + self._live_edit_status_label.set_xalign(0) + box.pack_start(self._live_edit_status_label, False, False, 0) + self._live_edit_status_label.show() + + panel.show() + return panel + + def _create_learning_sidebar(self): + panel = Gtk.EventBox() + panel.get_style_context().add_class('create-ai-learning-sidebar') + panel.set_size_request(style.zoom(380), -1) + + box = Gtk.VBox(spacing=style.zoom(9)) + box.set_border_width(style.zoom(11)) + panel.add(box) + box.show() + + title = Gtk.Label(_('Learning sidebar')) + title.get_style_context().add_class('create-ai-studio-section-title') + title.set_xalign(0) + box.pack_start(title, False, False, 0) + title.show() + + subtitle = Gtk.Label( + _('Challenges, reflections, and annotations stay visible while ' + 'the left chat handles generation and refinements.')) + subtitle.get_style_context().add_class('create-ai-meta-note') + subtitle.set_xalign(0) + subtitle.set_line_wrap(True) + box.pack_start(subtitle, False, False, 0) + subtitle.show() + + guided = Gtk.EventBox() + guided.get_style_context().add_class('create-ai-learning-card') + box.pack_start(guided, False, False, 0) + guided.show() + + guided_box = Gtk.VBox(spacing=style.zoom(4)) + guided_box.set_border_width(style.zoom(10)) + guided.add(guided_box) + guided_box.show() + + guided_header = Gtk.HBox(spacing=style.zoom(8)) + guided_box.pack_start(guided_header, False, False, 0) + guided_header.show() + + guided_title = Gtk.Label(_('Guided code exploration')) + guided_title.get_style_context().add_class( + 'create-ai-studio-note-label') + guided_title.set_xalign(0) + guided_header.pack_start(guided_title, True, True, 0) + guided_title.show() + + guided_counts = Gtk.Label( + _('Challenges 137 Reflections 8 Notes 11')) + guided_counts.get_style_context().add_class( + 'create-ai-learning-counts') + guided_header.pack_end(guided_counts, False, False, 0) + guided_counts.show() + + guided_subtitle = Gtk.Label( + _('Practice edits, reflection, and reading key lines.')) + guided_subtitle.get_style_context().add_class('create-ai-meta-note') + guided_subtitle.set_xalign(0) + guided_box.pack_start(guided_subtitle, False, False, 0) + guided_subtitle.show() + + tabs = Gtk.HBox(spacing=style.zoom(8)) + box.pack_start(tabs, False, False, 0) + tabs.show() + tabs.pack_start(self._create_sidebar_tab(_('Challenges'), True), + True, True, 0) + tabs.pack_start(self._create_sidebar_tab(_('Reflections'), False), + True, True, 0) + tabs.pack_start(self._create_sidebar_tab(_('Annotations'), False), + True, True, 0) + + level = Gtk.Label(_('Level 1 unlocked - 8 starter challenges')) + level.get_style_context().add_class('create-ai-meta-label') + level.set_xalign(0) + box.pack_start(level, False, False, 0) + level.show() + + scroll = Gtk.ScrolledWindow() + scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + box.pack_start(scroll, True, True, 0) + scroll.show() + + challenge_box = Gtk.VBox(spacing=style.zoom(8)) + challenge_box.set_border_width(style.zoom(2)) + scroll.add_with_viewport(challenge_box) + challenge_box.show() + + challenges = [ + _('Rename the activity title in your own words.'), + _('Change one greeting to include the learner name.'), + _('Rewrite the instructions for younger learners.'), + _('Add a teamwork prompt before the first move.'), + _('Find where Journal saving will be connected.'), + _('Describe what this activity teaches.'), + _('Change one color and explain the choice.'), + _('Export when the preview feels ready.'), + ] + for text in challenges: + challenge_box.pack_start(self._create_challenge_card(text), + False, False, 0) + + panel.show() + return panel + + def _create_sidebar_refinement_card(self): + card = Gtk.EventBox() + card.get_style_context().add_class('create-ai-learning-card') + + box = Gtk.VBox(spacing=style.zoom(7)) + box.set_border_width(style.zoom(10)) + card.add(box) + box.show() + + title = Gtk.Label(_('Refine activity')) + title.get_style_context().add_class('create-ai-studio-note-label') + title.set_xalign(0) + box.pack_start(title, False, False, 0) + title.show() + + note = Gtk.Label( + _('After generation, type another prompt to improve the current ' + 'activity.')) + note.get_style_context().add_class('create-ai-meta-note') + note.set_xalign(0) + note.set_line_wrap(True) + box.pack_start(note, False, False, 0) + note.show() + + self._sidebar_chat_scroll = Gtk.ScrolledWindow() + self._sidebar_chat_scroll.set_policy(Gtk.PolicyType.NEVER, + Gtk.PolicyType.AUTOMATIC) + self._sidebar_chat_scroll.get_style_context().add_class( + 'create-ai-chat-scroll') + self._sidebar_chat_scroll.set_size_request(-1, style.zoom(150)) + box.pack_start(self._sidebar_chat_scroll, True, True, 0) + self._sidebar_chat_scroll.show() + + self._sidebar_messages_box = Gtk.VBox(spacing=style.zoom(6)) + self._sidebar_messages_box.set_border_width(style.zoom(2)) + self._sidebar_chat_scroll.add_with_viewport( + self._sidebar_messages_box) + self._sidebar_messages_box.show() + self._append_sidebar_status( + _('Ready for a first prompt or a refinement.'), + scroll=False, + ) + + row = Gtk.HBox(spacing=style.zoom(7)) + box.pack_start(row, False, False, 0) + row.show() + + self._sidebar_refine_entry = Gtk.Entry() + self._sidebar_refine_entry.set_placeholder_text( + _('Ask for a refinement...')) + self._sidebar_refine_entry.get_style_context().add_class( + 'create-ai-chat-entry') + self._sidebar_refine_entry.connect( + 'activate', self.__sidebar_refine_entry_activate_cb) + row.pack_start(self._sidebar_refine_entry, True, True, 0) + self._sidebar_refine_entry.show() + + send = Gtk.Button.new_with_label(_('Refine')) + send.get_style_context().add_class('create-ai-chat-send') + send.connect('clicked', self.__sidebar_refine_send_clicked_cb) + row.pack_start(send, False, False, 0) + send.show() + + self._sidebar_refine_status_label = Gtk.Label( + _('Generate first, then refine here.')) + self._sidebar_refine_status_label.get_style_context().add_class( + 'create-ai-meta-note') + self._sidebar_refine_status_label.set_xalign(0) + self._sidebar_refine_status_label.set_line_wrap(True) + box.pack_start(self._sidebar_refine_status_label, False, False, 0) + self._sidebar_refine_status_label.show() + + card.show() + return card + + def _append_sidebar_message(self, text, from_user=False, scroll=True): + if self._sidebar_messages_box is None: + return + + row = Gtk.HBox() + bubble = Gtk.EventBox() + bubble.get_style_context().add_class('create-ai-chat-bubble') + if from_user: + bubble.get_style_context().add_class('create-ai-chat-bubble-user') + else: + bubble.get_style_context().add_class('create-ai-chat-bubble-ai') + + label = Gtk.Label(text) + label.get_style_context().add_class('create-ai-chat-text') + label.set_line_wrap(True) + label.set_max_width_chars(32) + label.set_xalign(0) + label.set_margin_top(style.zoom(5)) + label.set_margin_bottom(style.zoom(5)) + label.set_margin_start(style.zoom(8)) + label.set_margin_end(style.zoom(8)) + bubble.add(label) + label.show() + + spacer = Gtk.Label() + if from_user: + row.pack_start(spacer, True, True, 0) + row.pack_start(bubble, False, False, 0) + else: + row.pack_start(bubble, False, False, 0) + row.pack_start(spacer, True, True, 0) + + self._sidebar_messages_box.pack_start(row, False, False, 0) + spacer.show() + bubble.show() + row.show() + + if scroll: + GObject.idle_add(self.__scroll_sidebar_chat_to_bottom) + + def _append_sidebar_status(self, text, scroll=True): + if self._sidebar_messages_box is None: + return + + row = Gtk.HBox() + label = Gtk.Label(_('- %s') % text) + label.get_style_context().add_class('create-ai-chat-status') + label.set_xalign(0) + label.set_line_wrap(True) + label.set_max_width_chars(34) + row.pack_start(label, True, True, 0) + self._sidebar_messages_box.pack_start(row, False, False, 0) + label.show() + row.show() + + if scroll: + GObject.idle_add(self.__scroll_sidebar_chat_to_bottom) + + def __scroll_sidebar_chat_to_bottom(self): + if self._sidebar_chat_scroll is None: + return False + + adjustment = self._sidebar_chat_scroll.get_vadjustment() + adjustment.set_value(adjustment.get_upper() - + adjustment.get_page_size()) + return False + + def _create_challenge_card(self, text): + card = Gtk.EventBox() + card.get_style_context().add_class('create-ai-challenge-card') + + box = Gtk.VBox(spacing=style.zoom(6)) + box.set_border_width(style.zoom(10)) + card.add(box) + box.show() + + title = Gtk.Label(_('Level 1 - Cosmetic')) + title.get_style_context().add_class('create-ai-meta-label') + title.set_xalign(0) + box.pack_start(title, False, False, 0) + title.show() + + body = Gtk.Label(text) + body.get_style_context().add_class('create-ai-studio-note-label') + body.set_xalign(0) + body.set_line_wrap(True) + box.pack_start(body, False, False, 0) + body.show() + + row = Gtk.HBox(spacing=style.zoom(6)) + box.pack_start(row, False, False, 0) + row.show() + row.pack_start(self._create_soft_pill(_('Hint')), False, False, 0) + row.pack_start(self._create_soft_pill(_('Done')), False, False, 0) + + card.show() + return card + + def _create_studio_tab(self, label, active): + button = Gtk.Button.new_with_label(label) + button.set_relief(Gtk.ReliefStyle.NONE) + button.get_style_context().add_class('create-ai-studio-tab') + if active: + button.get_style_context().add_class('create-ai-studio-tab-active') + button.show() + return button + + def _create_sidebar_tab(self, label, active): + button = Gtk.Button.new_with_label(label) + button.set_relief(Gtk.ReliefStyle.NONE) + button.get_style_context().add_class('create-ai-sidebar-tab') + if active: + button.get_style_context().add_class( + 'create-ai-sidebar-tab-active') + button.show() + return button + + def _create_soft_pill(self, label): + pill = Gtk.Label(label) + pill.get_style_context().add_class('create-ai-soft-pill') + pill.show() + return pill + + def _create_live_toggle_button(self, label, active): + button = Gtk.Button.new_with_label(label) + button.set_relief(Gtk.ReliefStyle.NONE) + button.get_style_context().add_class('create-ai-live-toggle') + if active: + button.get_style_context().add_class('create-ai-live-toggle-active') + button.connect('clicked', self.__live_toggle_clicked_cb, active) + button.show() + return button + + def _create_plain_button(self, label, callback): + button = Gtk.Button.new_with_label(label) + button.get_style_context().add_class('create-ai-studio-button') + if callback is not None: + button.connect('clicked', callback) + button.show() + return button + + def _create_primary_button(self, label, callback=None): + button = Gtk.Button.new_with_label(label) + button.get_style_context().add_class('create-ai-studio-primary') + if callback is not None: + button.connect('clicked', callback) + button.show() + return button + + def _ensure_css(self): + if _CreateAIActivityPanel._css_loaded: + return + + css_provider = Gtk.CssProvider() + colors = { + 'black': style.COLOR_BLACK.get_html(), + 'button': style.COLOR_BUTTON_GREY.get_html(), + 'highlight': style.COLOR_HIGHLIGHT.get_html(), + 'inactive_fill': style.COLOR_INACTIVE_FILL.get_html(), + 'inactive_stroke': style.COLOR_INACTIVE_STROKE.get_html(), + 'panel': style.COLOR_PANEL_GREY.get_html(), + 'selection': style.COLOR_SELECTION_GREY.get_html(), + 'text_field': style.COLOR_TEXT_FIELD_GREY.get_html(), + 'toolbar': style.COLOR_TOOLBAR_GREY.get_html(), + 'white': style.COLOR_WHITE.get_html(), + 'studio_canvas': '#f2f2f2', + 'studio_surface': '#ffffff', + 'studio_preview': '#fcfcfc', + 'studio_edge': '#cfcfcf', + 'studio_edge_soft': '#e7e7e7', + 'studio_dark': '#2f2f2f', + 'studio_dark_hover': '#414141', + 'studio_dark_text': '#ffffff', + 'studio_lavender': '#e9e9e9', + 'studio_lavender_soft': '#f7f7f7', + 'studio_lavender_faint': '#d8d8d8', + 'studio_lavender_border': '#9a9a9a', + 'studio_lavender_text': '#202020', + 'studio_cream': '#fff4d8', + 'studio_cream_border': '#ddbd73', + 'studio_cream_text': '#322717', + } + css_provider.load_from_data((''' + .create-ai-panel { + background-color: %(studio_canvas)s; + } + .create-ai-title { + color: %(black)s; + } + .create-ai-subtitle { + color: %(inactive_stroke)s; + } + .create-ai-overlay-button { + border-radius: 999px; + border: 1px solid %(studio_edge)s; + background-image: none; + background-color: %(studio_surface)s; + padding: 2px; + min-width: 0; + min-height: 0; + } + .create-ai-overlay-button:hover { + background-color: %(studio_lavender_soft)s; + } + .create-ai-stage-card { + background-color: %(studio_surface)s; + border: 1px solid %(studio_edge)s; + border-radius: 12px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.10); + } + .create-ai-stage-card-hover { + background-color: %(studio_lavender_soft)s; + border: 1px solid %(studio_lavender_border)s; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.14); + } + .create-ai-stage-card-hover label { + color: %(toolbar)s; + } + .create-ai-stage-title { + color: %(toolbar)s; + font-weight: 700; + } + .create-ai-stage-details { + color: %(toolbar)s; + } + .create-ai-stage-footer { + color: %(black)s; + font-weight: 700; + } + .create-ai-builder-title { + color: #202020; + font-weight: 700; + font-size: 24px; + } + .create-ai-builder-subtitle { + color: #686868; + font-size: 13px; + } + .create-ai-meta-label { + color: %(toolbar)s; + font-weight: 700; + font-size: 12px; + } + .create-ai-meta-note { + color: #747474; + font-size: 11px; + } + progressbar.create-ai-generation-progress trough { + min-height: 6px; + border-radius: 999px; + border: 0; + background-color: #ececec; + } + progressbar.create-ai-generation-progress progress { + min-height: 6px; + border-radius: 999px; + background-color: %(studio_lavender_border)s; + } + .create-ai-generation-stage { + color: #888; + font-weight: 400; + font-size: 11px; + } + .create-ai-generation-step { + color: #aaa; + font-size: 11px; + font-weight: 400; + } + .create-ai-generation-step-active { + color: #333; + font-weight: 600; + } + .create-ai-generation-step-desc { + color: #b8b8b8; + font-size: 9px; + } + .create-ai-generation-step-row { + padding: 6px 10px; + border-radius: 8px; + border: 1px solid transparent; + } + .create-ai-generation-step-row-active { + background-color: %(studio_lavender_soft)s; + border: 1px solid %(studio_lavender_border)s; + } + .create-ai-generated-preview { + border-radius: 8px; + border: 0; + background-color: transparent; + box-shadow: none; + } + .create-ai-generated-title { + color: #202020; + font-weight: 700; + font-size: 17px; + } + .create-ai-generated-badge { + border-radius: 999px; + border: 1px solid %(studio_edge)s; + background-color: %(studio_lavender_soft)s; + color: #555555; + padding: 2px 9px; + font-size: 10px; + } + .create-ai-generated-summary { + color: #696969; + font-size: 11px; + } + .create-ai-generated-body { + border-radius: 8px; + border: 1px solid %(studio_edge_soft)s; + background-color: %(studio_preview)s; + padding: 12px; + } + .create-ai-generated-kicker { + color: #6f6f6f; + font-size: 10px; + font-weight: 700; + } + .create-ai-generated-question { + color: #202020; + font-size: 14px; + font-weight: 700; + } + entry.create-ai-generated-entry { + border-radius: 7px; + border: 1px solid %(studio_edge)s; + background-color: %(studio_surface)s; + padding: 7px 9px; + font-size: 11px; + } + button.create-ai-generated-action { + border-radius: 7px; + border: 1px solid %(studio_edge)s; + background-image: none; + background-color: %(studio_surface)s; + color: %(toolbar)s; + padding: 6px 10px; + font-size: 11px; + font-weight: 700; + } + button.create-ai-generated-tile { + border-radius: 7px; + border: 1px solid %(studio_edge)s; + background-image: none; + background-color: %(studio_surface)s; + color: %(toolbar)s; + font-weight: 700; + } + button.create-ai-generated-tile:checked { + background-color: %(studio_cream)s; + border-color: %(studio_cream_border)s; + } + .create-ai-generated-canvas { + border-radius: 8px; + border: 1px solid %(studio_edge)s; + background-color: %(studio_surface)s; + } + .create-ai-generated-log { + border-radius: 8px; + border: 1px solid %(studio_edge)s; + background-color: %(studio_surface)s; + } + button.create-ai-generated-chess-square { + border-radius: 2px; + border: 0; + background-image: none; + background-color: #eeeeee; + color: #202020; + font-weight: 700; + font-size: 20px; + padding: 0; + } + button.create-ai-generated-chess-dark { + background-color: #cfcfcf; + } + button.create-ai-generated-chess-selected { + background-color: #f4d06f; + } + .create-ai-generated-pill { + border-radius: 999px; + border: 1px solid %(studio_edge)s; + background-color: %(studio_lavender_soft)s; + color: #5f5f5f; + padding: 2px 8px; + font-size: 10px; + } + .create-ai-pill-button { + border-radius: 999px; + border: 1px solid %(studio_edge)s; + background-image: none; + background-color: %(studio_surface)s; + color: %(toolbar)s; + padding: 1px 10px; + min-height: 0; + font-size: 11px; + } + .create-ai-pill-button:hover { + background-color: %(studio_lavender_soft)s; + border-color: %(studio_lavender_faint)s; + } + .create-ai-pill-active { + background-color: %(studio_lavender)s; + color: %(studio_lavender_text)s; + border-color: %(studio_lavender_border)s; + } + .create-ai-pill-active label { + color: %(studio_lavender_text)s; + } + .create-ai-meta-button { + border-radius: 999px; + border: 1px solid %(studio_edge)s; + background-image: none; + background-color: %(studio_surface)s; + color: %(toolbar)s; + padding: 2px 12px; + font-size: 11px; + } + .create-ai-meta-button:checked { + background-color: %(studio_lavender_soft)s; + border-color: %(studio_lavender_border)s; + color: %(studio_lavender_text)s; + } + combobox.create-ai-provider-combo button { + border-radius: 7px; + border: 1px solid %(studio_edge)s; + background-image: none; + background-color: %(studio_surface)s; + color: %(toolbar)s; + padding: 7px 10px; + font-size: 11px; + } + entry.create-ai-provider-entry { + border-radius: 7px; + border: 1px solid %(studio_edge)s; + background-color: %(studio_surface)s; + color: %(black)s; + padding: 8px 10px; + font-size: 11px; + } + button.create-ai-provider-button { + border-radius: 7px; + border: 1px solid %(studio_edge)s; + background-image: none; + background-color: %(studio_surface)s; + color: %(toolbar)s; + padding: 7px 10px; + font-size: 11px; + font-weight: 700; + } + button.create-ai-provider-button:hover { + background-color: %(studio_lavender_soft)s; + border-color: %(studio_lavender_border)s; + } + .create-ai-option-heading { + color: %(toolbar)s; + font-weight: 700; + font-size: 12px; + } + button.create-ai-option-card { + border-radius: 6px; + border: 1px solid %(studio_edge)s; + background-image: none; + background-color: %(studio_surface)s; + color: %(toolbar)s; + padding: 0; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06); + } + button.create-ai-option-card:hover { + background-color: %(studio_lavender_soft)s; + border-color: %(studio_lavender_faint)s; + } + button.create-ai-option-card-active { + background-color: %(studio_dark)s; + border-color: %(studio_dark)s; + color: %(studio_dark_text)s; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.13); + } + button.create-ai-option-card-active:hover { + background-color: %(studio_dark_hover)s; + border-color: %(studio_dark_hover)s; + } + button.create-ai-option-card-active:hover label { + color: %(studio_dark_text)s; + } + .create-ai-option-title { + color: %(toolbar)s; + font-weight: 700; + font-size: 12px; + } + .create-ai-option-detail { + color: %(inactive_stroke)s; + font-size: 10px; + } + button.create-ai-option-card-active label { + color: %(studio_dark_text)s; + } + .create-ai-prompt-box { + border-radius: 8px; + border: 1px solid %(studio_edge)s; + background-color: %(studio_surface)s; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.045); + } + textview.create-ai-textview { + color: %(black)s; + background-color: %(studio_surface)s; + border: 1px solid %(studio_edge)s; + border-radius: 7px; + font-size: 12px; + } + textview.create-ai-textview text { + color: %(black)s; + background-color: %(studio_surface)s; + } + textview.create-ai-textview:focus { + border-color: %(studio_lavender_border)s; + background-color: %(studio_surface)s; + box-shadow: none; + } + .create-ai-prompt-actions { + border-radius: 7px; + border: 1px solid %(studio_edge_soft)s; + background-color: %(studio_preview)s; + } + .create-ai-plus { + border-radius: 999px; + border: 1px solid %(studio_edge)s; + background-image: none; + background-color: %(studio_surface)s; + color: %(studio_lavender_text)s; + padding: 0 4px; + min-width: 30px; + min-height: 26px; + } + .create-ai-plus label { + color: %(studio_lavender_text)s; + } + .create-ai-send { + border-radius: 999px; + border: 1px solid %(studio_edge)s; + background-image: none; + background-color: %(studio_surface)s; + color: %(studio_lavender_text)s; + padding: 1px 6px; + min-width: 30px; + min-height: 26px; + } + .create-ai-send label { + color: %(studio_lavender_text)s; + } + .create-ai-send:hover { + background-color: %(studio_lavender_soft)s; + } + .create-ai-studio-workspace { + border-radius: 14px; + border: 1px solid %(studio_edge)s; + background-color: %(studio_canvas)s; + } + .create-ai-studio-side { + border-radius: 12px; + border: 1px solid %(studio_edge)s; + background-color: %(studio_surface)s; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.07); + } + .create-ai-studio-chip { + border-radius: 12px; + border: 1px solid %(studio_lavender_border)s; + background-color: %(studio_lavender)s; + color: %(studio_lavender_text)s; + padding: 8px 16px; + font-weight: 700; + } + .create-ai-studio-chip label { + color: %(studio_lavender_text)s; + } + .create-ai-studio-note { + border-radius: 10px; + border: 1px solid %(studio_cream_border)s; + background-color: %(studio_cream)s; + } + .create-ai-studio-note-label { + color: %(toolbar)s; + font-size: 11px; + } + .create-ai-chat-scroll { + border: 0; + background-color: transparent; + } + .create-ai-chat-heading { + color: %(toolbar)s; + font-weight: 700; + font-size: 12px; + } + .create-ai-chat-bubble { + border-radius: 12px; + border: 1px solid %(studio_edge)s; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); + } + .create-ai-chat-bubble-ai { + background-color: %(studio_cream)s; + border-color: %(studio_cream_border)s; + } + .create-ai-chat-bubble-user { + background-color: %(studio_lavender)s; + border-color: %(studio_lavender_border)s; + } + .create-ai-chat-bubble-user label { + color: %(studio_lavender_text)s; + } + .create-ai-chat-text { + color: %(toolbar)s; + font-size: 12px; + } + .create-ai-chat-status { + color: #5f5f5f; + font-size: 11px; + padding: 2px 4px; + } + .create-ai-chat-composer { + border-radius: 12px; + border: 1px solid %(studio_edge)s; + background-color: %(studio_surface)s; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.07); + } + entry.create-ai-chat-entry { + border-radius: 7px; + border: 1px solid %(studio_edge)s; + background-color: %(studio_surface)s; + color: %(toolbar)s; + padding: 9px; + font-size: 12px; + } + button.create-ai-chat-send { + border-radius: 7px; + border: 1px solid %(studio_lavender_border)s; + background-image: none; + background-color: %(studio_lavender)s; + color: %(studio_lavender_text)s; + padding: 8px 14px; + font-size: 12px; + font-weight: 700; + } + button.create-ai-chat-send:hover { + background-color: %(studio_lavender_soft)s; + border-color: %(studio_lavender_border)s; + color: %(studio_lavender_text)s; + } + button.create-ai-chat-send label { + color: %(studio_lavender_text)s; + } + .create-ai-studio-mini-prompt { + border-radius: 10px; + border: 1px solid %(studio_edge)s; + background-color: %(studio_surface)s; + } + entry.create-ai-studio-entry { + border-radius: 6px; + border: 1px solid %(studio_edge)s; + background-color: %(studio_surface)s; + color: %(inactive_stroke)s; + font-size: 11px; + } + .create-ai-studio-section-title { + color: %(toolbar)s; + font-weight: 700; + font-size: 12px; + } + button.create-ai-studio-button { + border-radius: 999px; + border: 1px solid %(studio_edge)s; + background-image: none; + background-color: %(studio_surface)s; + color: %(toolbar)s; + padding: 5px 13px; + font-size: 11px; + } + button.create-ai-studio-button:hover { + background-color: %(studio_preview)s; + border-color: %(studio_edge)s; + } + button.create-ai-studio-primary { + border-radius: 999px; + border: 1px solid %(studio_lavender_border)s; + background-image: none; + background-color: %(studio_lavender)s; + color: %(studio_lavender_text)s; + padding: 5px 13px; + font-size: 11px; + font-weight: 700; + } + button.create-ai-studio-primary label { + color: %(studio_lavender_text)s; + } + button.create-ai-studio-tab { + border-radius: 8px; + border: 1px solid %(studio_edge)s; + background-image: none; + background-color: %(studio_surface)s; + color: %(toolbar)s; + padding: 7px 16px; + font-weight: 700; + font-size: 11px; + } + button.create-ai-studio-tab:hover { + background-color: %(studio_preview)s; + border-color: %(studio_edge)s; + } + button.create-ai-studio-tab-active { + background-color: %(studio_dark)s; + border-color: %(studio_dark)s; + color: %(studio_dark_text)s; + box-shadow: 0 2px 3px rgba(0, 0, 0, 0.16); + } + button.create-ai-studio-tab-active:hover { + background-color: %(studio_dark_hover)s; + border-color: %(studio_dark_hover)s; + color: %(studio_dark_text)s; + } + button.create-ai-studio-tab-active label { + color: %(studio_dark_text)s; + } + button.create-ai-studio-tab-active:hover label { + color: %(studio_dark_text)s; + } + .create-ai-soft-pill { + border-radius: 999px; + border: 1px solid %(studio_lavender_border)s; + background-color: %(studio_lavender)s; + color: %(studio_lavender_text)s; + padding: 3px 9px; + font-size: 10px; + font-weight: 700; + } + .create-ai-soft-pill:hover { + background-color: %(studio_lavender_soft)s; + border-color: %(studio_lavender_border)s; + } + .create-ai-preview-shell { + border-radius: 12px; + border: 1px solid %(studio_edge)s; + background-color: %(studio_surface)s; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08); + } + .create-ai-preview-frame { + border-radius: 12px; + border: 1px solid %(studio_edge)s; + background-color: %(studio_preview)s; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.75); + } + .create-ai-activity-preview { + background-color: transparent; + } + .create-ai-preview-title { + color: %(toolbar)s; + font-size: 18px; + font-weight: 700; + } + .create-ai-review-shell { + border-radius: 12px; + border: 1px solid %(studio_edge)s; + background-color: %(studio_surface)s; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08); + } + .create-ai-review-files { + border-radius: 10px; + border: 1px solid %(studio_edge)s; + background-color: %(studio_canvas)s; + } + button.create-ai-review-file { + border-radius: 6px; + border: 1px solid transparent; + background-image: none; + background-color: transparent; + color: %(toolbar)s; + padding: 7px 9px; + font-size: 12px; + text-shadow: none; + } + button.create-ai-review-file:hover { + background-color: %(studio_preview)s; + border-color: %(studio_edge)s; + } + button.create-ai-review-file-active { + background-color: %(studio_lavender)s; + border-color: %(studio_lavender_border)s; + color: %(studio_lavender_text)s; + font-weight: 700; + } + button.create-ai-review-file-active:hover { + background-color: %(studio_lavender)s; + border-color: %(studio_lavender_border)s; + color: %(studio_lavender_text)s; + } + button.create-ai-review-file-active label { + color: %(studio_lavender_text)s; + } + button.create-ai-review-file-active:hover label { + color: %(studio_lavender_text)s; + } + .create-ai-review-code { + border-radius: 10px; + border: 1px solid %(studio_edge)s; + background-color: %(studio_surface)s; + } + .create-ai-review-title { + color: %(toolbar)s; + font-size: 16px; + font-weight: 700; + } + .create-ai-review-summary { + color: %(toolbar)s; + font-size: 12px; + } + .create-ai-review-meta { + color: %(inactive_stroke)s; + font-size: 11px; + font-weight: 700; + } + .create-ai-code-frame { + border-radius: 8px; + border: 1px solid %(studio_edge)s; + background-color: %(studio_surface)s; + } + .create-ai-code-text { + color: %(black)s; + font-family: monospace; + font-size: 13px; + } + .create-ai-versions-shell { + border-radius: 12px; + border: 1px solid %(studio_edge)s; + background-color: %(studio_surface)s; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08); + } + .create-ai-version-history { + border-radius: 10px; + border: 1px solid %(studio_edge)s; + background-color: %(studio_canvas)s; + } + .create-ai-version-heading { + color: %(toolbar)s; + font-size: 11px; + font-weight: 700; + } + .create-ai-version-card { + border-radius: 10px; + border: 1px solid %(studio_edge)s; + background-color: %(studio_surface)s; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); + } + .create-ai-version-card-active { + border-color: %(studio_lavender_border)s; + background-color: %(studio_lavender_soft)s; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08); + } + .create-ai-version-chip { + border-radius: 999px; + border: 1px solid %(studio_lavender_border)s; + background-color: %(studio_lavender)s; + color: %(studio_lavender_text)s; + padding: 3px 8px; + font-size: 10px; + font-weight: 700; + } + .create-ai-version-date { + color: %(inactive_stroke)s; + font-size: 9px; + font-weight: 700; + } + .create-ai-version-card-action { + border-radius: 7px; + border: 1px solid %(studio_lavender_border)s; + background-color: %(studio_lavender)s; + color: %(studio_lavender_text)s; + padding: 5px 10px; + font-size: 10px; + font-weight: 700; + } + .create-ai-version-card-action label { + color: %(studio_lavender_text)s; + } + button.create-ai-version-switch { + border-radius: 8px; + border: 1px solid %(studio_edge)s; + background-image: none; + background-color: %(studio_surface)s; + color: %(toolbar)s; + padding: 7px 18px; + font-weight: 700; + font-size: 11px; + } + button.create-ai-version-switch:hover { + background-color: %(studio_preview)s; + border-color: %(studio_edge)s; + } + button.create-ai-version-switch-active { + background-color: %(studio_lavender)s; + border-color: %(studio_lavender_border)s; + color: %(studio_lavender_text)s; + box-shadow: 0 2px 3px rgba(0, 0, 0, 0.09); + } + button.create-ai-version-switch-active:hover { + background-color: %(studio_lavender)s; + border-color: %(studio_lavender_border)s; + color: %(studio_lavender_text)s; + } + button.create-ai-version-switch-active label { + color: %(studio_lavender_text)s; + } + button.create-ai-version-switch-active:hover label { + color: %(studio_lavender_text)s; + } + .create-ai-version-compare-pill { + border-radius: 999px; + border: 1px solid %(studio_edge)s; + background-color: %(studio_surface)s; + color: %(toolbar)s; + padding: 4px 12px; + font-size: 10px; + font-weight: 700; + } + .create-ai-live-edit-panel { + border-radius: 10px; + border: 1px solid %(studio_edge)s; + background-color: %(studio_surface)s; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.07); + } + .create-ai-live-toggle-group { + border-radius: 999px; + border: 1px solid %(studio_edge)s; + background-color: %(studio_canvas)s; + } + button.create-ai-live-toggle { + border-radius: 999px; + border: 1px solid transparent; + background-image: none; + background-color: transparent; + color: %(inactive_stroke)s; + padding: 3px 12px; + font-size: 10px; + font-weight: 700; + min-height: 0; + } + button.create-ai-live-toggle:hover { + background-color: %(studio_lavender_soft)s; + } + button.create-ai-live-toggle-active { + background-color: %(studio_cream)s; + border-color: %(studio_cream_border)s; + color: %(studio_cream_text)s; + } + button.create-ai-live-toggle-active label { + color: %(studio_cream_text)s; + } + .create-ai-live-target { + border-radius: 7px; + border: 1px solid %(studio_lavender_border)s; + background-color: %(studio_lavender_soft)s; + color: %(toolbar)s; + padding: 11px 12px; + font-size: 11px; + } + entry.create-ai-live-entry { + border-radius: 7px; + border: 1px solid %(studio_edge)s; + background-color: %(studio_surface)s; + color: %(black)s; + padding: 12px; + font-size: 12px; + } + button.create-ai-preview-change { + border-radius: 8px; + border: 1px solid %(studio_lavender_border)s; + background-image: none; + background-color: %(studio_lavender)s; + color: %(studio_lavender_text)s; + padding: 12px 18px; + font-size: 12px; + font-weight: 700; + } + button.create-ai-preview-change:hover { + background-color: %(studio_lavender_soft)s; + border-color: %(studio_lavender_border)s; + color: %(studio_lavender_text)s; + } + button.create-ai-preview-change label { + color: %(studio_lavender_text)s; + } + .create-ai-learning-sidebar { + border-radius: 12px; + border: 1px solid %(studio_edge)s; + background-color: %(studio_surface)s; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08); + } + .create-ai-learning-card { + border-radius: 10px; + border: 1px solid %(studio_edge)s; + background-color: %(studio_surface)s; + } + .create-ai-learning-counts { + border-radius: 999px; + border: 1px solid %(studio_edge)s; + background-color: %(studio_surface)s; + color: %(inactive_stroke)s; + padding: 3px 9px; + font-size: 9px; + font-weight: 700; + } + button.create-ai-sidebar-tab { + border-radius: 8px; + border: 1px solid %(studio_edge)s; + background-image: none; + background-color: %(studio_surface)s; + color: %(toolbar)s; + padding: 7px 12px; + font-weight: 700; + font-size: 11px; + } + button.create-ai-sidebar-tab:hover { + background-color: %(studio_preview)s; + border-color: %(studio_edge)s; + } + button.create-ai-sidebar-tab-active { + background-color: %(studio_lavender)s; + border-color: %(studio_lavender_border)s; + color: %(studio_lavender_text)s; + box-shadow: 0 2px 3px rgba(0, 0, 0, 0.09); + } + button.create-ai-sidebar-tab-active:hover { + background-color: %(studio_lavender)s; + border-color: %(studio_lavender_border)s; + color: %(studio_lavender_text)s; + } + button.create-ai-sidebar-tab-active label { + color: %(studio_lavender_text)s; + } + button.create-ai-sidebar-tab-active:hover label { + color: %(studio_lavender_text)s; + } + .create-ai-challenge-card { + border-radius: 10px; + border: 1px solid %(studio_edge_soft)s; + background-color: %(studio_surface)s; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); + } + ''' % colors).encode('utf-8')) + Gtk.StyleContext.add_provider_for_screen( + Gdk.Screen.get_default(), css_provider, + Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) + _CreateAIActivityPanel._css_loaded = True + + def _create_chrome_button(self, icon_name): + button = Gtk.Button() + button.set_relief(Gtk.ReliefStyle.NONE) + button.get_style_context().add_class('create-ai-overlay-button') + button.set_image(Icon(icon_name=icon_name, + pixel_size=style.SMALL_ICON_SIZE)) + return button + + def _create_stage_card(self, title, details, footer, on_click=None): + column = Gtk.VBox(spacing=style.zoom(16)) + + card = Gtk.EventBox() + card.get_style_context().add_class('create-ai-stage-card') + card.set_visible_window(True) + card.set_above_child(True) + card.add_events(Gdk.EventMask.ENTER_NOTIFY_MASK | + Gdk.EventMask.LEAVE_NOTIFY_MASK | + Gdk.EventMask.BUTTON_RELEASE_MASK) + card.connect('enter-notify-event', self.__stage_card_enter_notify_cb) + card.connect('leave-notify-event', self.__stage_card_leave_notify_cb) + if on_click is not None: + card.connect('button-release-event', + self.__stage_card_button_release_cb, on_click) + card.set_size_request(style.zoom(350), style.zoom(285)) + column.pack_start(card, False, False, 0) + card.show() + + card_box = Gtk.VBox(spacing=style.DEFAULT_PADDING) + card_box.set_border_width(style.zoom(24)) + card.add(card_box) + card_box.show() + + title_label = Gtk.Label() + title_label.get_style_context().add_class('create-ai-stage-title') + stage_text = style.COLOR_TOOLBAR_GREY.get_html() + title_label.set_markup( + '%s' + % (stage_text, title)) + title_label.set_justify(Gtk.Justification.CENTER) + card_box.pack_start(title_label, False, False, style.zoom(4)) + title_label.show() + + details_label = Gtk.Label() + details_label.get_style_context().add_class('create-ai-stage-details') + details_label.set_markup('%s' % + (stage_text, details)) + details_label.set_justify(Gtk.Justification.CENTER) + details_label.set_max_width_chars(24) + details_label.set_line_wrap(True) + card_box.pack_start(details_label, True, True, 0) + details_label.show() + + footer_label = Gtk.Label(footer) + footer_label.get_style_context().add_class('create-ai-stage-footer') + footer_label.set_justify(Gtk.Justification.CENTER) + column.pack_start(footer_label, False, False, style.zoom(6)) + footer_label.show() + + column.show() + return column + + def _reset_prompt(self): + if self._prompt_text is not None: + self._set_prompt_placeholder() + if self._prompt_status_label is not None: + self._prompt_status_label.set_text(_('Thinking')) + + def _set_prompt_placeholder(self): + if self._prompt_text is None: + return + + self._prompt_is_placeholder = False + self._prompt_text.get_buffer().set_text('') + if self._prompt_char_label is not None: + self._prompt_char_label.set_text(_('0 chars')) + + def _clear_prompt_placeholder(self): + self._prompt_is_placeholder = False + + def _get_prompt_text(self): + if self._prompt_text is None: + return '' + + text_buffer = self._prompt_text.get_buffer() + start, end = text_buffer.get_bounds() + return text_buffer.get_text(start, end, True).strip() + + def _set_prompt_text(self, text): + if self._prompt_text is None: + return + + self._prompt_is_placeholder = False + text_buffer = self._prompt_text.get_buffer() + text_buffer.set_text(text) + end = text_buffer.get_end_iter() + text_buffer.place_cursor(end) + + def append_prompt_text(self, text): + if self._prompt_text is None or not text: + return + + current = self._get_prompt_text() + if current: + text = current + text + self._set_prompt_text(text) + self.focus_prompt() + if self._prompt_status_label is not None: + self._prompt_status_label.set_text(_('Thinking')) + + def focus_prompt(self): + GObject.idle_add(self.__focus_prompt_text) + + def _set_studio_prompt(self, prompt): + prompt = prompt.strip() + if not prompt: + prompt = _('learning activity') + + if len(prompt) > 36: + prompt = prompt[:33] + '...' + for label in self._studio_prompt_labels: + label.set_text(prompt) + + def _set_live_edit_target(self, target): + target = target.strip() if isinstance(target, str) else '' + if not target: + target = _('activity canvas') + self._live_edit_target = target + if self._live_edit_target_label is not None: + self._live_edit_target_label.set_text( + _('Preview target: %s') % target) + if self._live_edit_status_label is not None: + self._live_edit_status_label.set_text( + _('Target selected: %s') % target) + + def __preview_target_button_press_event_cb(self, widget, event, target): + if self._live_edit_enabled: + self._set_live_edit_target(target) + return False + + def _update_planner_hint(self): + if self._planner_hint is None: + return + + planner_notes = { + 'rag': _('RAG over Sugar activity examples'), + 'validate': _('local template planning plus AST validation'), + } + policy_notes = { + 'standard': _('configured provider, no template fallback'), + 'local': _('Ollama when configured, otherwise local templates'), + 'strict': _('fixed local templates without model calls'), + } + planner = planner_notes[self._selected_options['planner']] + policy = policy_notes[self._selected_options['policy']] + provider = self._get_provider_label( + self._selected_options['provider']) + self._planner_hint.set_text( + _('Planner mode: %s. Policy: %s. Provider: %s. Generated code ' + 'will still be checked before it becomes an activity.') + % (planner, policy, provider)) + + def _update_provider_controls(self): + if self._provider_key_entry is None: + return + + provider_name = self._selected_options['provider'] + cloud_provider = provider_name in ( + 'freemodel', 'openrouter', 'gemini', 'openai', 'deepseek', + 'qwen', 'moonshot', 'opencode', 'opencode-go', 'claude') + model_provider = cloud_provider or provider_name == 'ollama' + + self._provider_key_entry.set_sensitive(cloud_provider) + self._provider_paste_button.set_sensitive(cloud_provider) + self._provider_model_entry.set_sensitive(model_provider) + self._provider_endpoint_entry.set_sensitive(model_provider) + self._provider_remove_button.set_sensitive(False) + if self._provider_model_switch_row is not None: + if provider_name == 'opencode-go': + self._provider_model_switch_row.show() + else: + self._provider_model_switch_row.hide() + + if not cloud_provider: + self._provider_key_entry.set_visibility(False) + + if provider_name == 'default': + self._provider_status_label.set_text( + _('Automatic uses the last saved provider for RAG generation. ' + 'Save an API key before generating.')) + return + if provider_name == 'local-template': + self._provider_status_label.set_text( + _('Local templates work offline and do not require an API ' + 'key.')) + return + + from jarabe.model.aodservice import get_service + + try: + status = get_service().provider_credential_status(provider_name) + except Exception as error: + logging.exception('Could not read saved provider settings') + self._provider_status_label.set_text(str(error)) + return + self._provider_model_entry.set_text(status['model']) + self._provider_endpoint_entry.set_text(status['endpoint']) + self._provider_remove_button.set_sensitive( + cloud_provider and status['has_api_key']) + + if provider_name == 'ollama': + self._provider_status_label.set_text( + _('Ollama runs locally. Model and endpoint preferences are ' + 'saved in the private Sugar profile.')) + elif status['storage'] == 'keyring': + self._provider_status_label.set_text( + _('API key saved in the system keyring. Enter a new key only ' + 'to replace it.')) + elif status['storage'] == 'profile-file': + self._provider_status_label.set_text( + _('API key saved in the private Sugar profile file with ' + 'owner-only permissions.')) + else: + self._provider_status_label.set_text( + _('Enter an API key. It stays masked and is never added to ' + 'the generated activity.')) + + def _configure_selected_provider(self, persist=True): + provider_name = self._selected_options['provider'] + if provider_name == 'default': + self._provider_status_label.set_text( + _('Automatic provider selection is ready.')) + return True + if provider_name == 'local-template': + self._provider_status_label.set_text( + _('Local template generation is ready.')) + return True + + from jarabe.model.aodcredentials import CredentialStoreError + from jarabe.model.aodllm import ProviderError + from jarabe.model.aodservice import get_service + + api_key = self._provider_key_entry.get_text().strip() + model = self._provider_model_entry.get_text().strip() + endpoint = self._provider_endpoint_entry.get_text().strip() + try: + provider = get_service().configure_provider( + provider_name, + api_key=api_key or None, + model=model or None, + endpoint=endpoint or None, + persist=persist, + ) + except (CredentialStoreError, ProviderError, + TypeError, ValueError) as error: + self._provider_status_label.set_text(str(error)) + if self._prompt_status_label is not None: + self._prompt_status_label.set_text(_('Provider setup needed')) + return False + + self._provider_key_entry.set_text('') + self._update_provider_controls() + self._provider_status_label.set_text( + _('%s is ready with model %s. The API key is not shown.') + % (provider.label, provider.model)) + return provider + + def _start_provider_test(self, provider): + if self._provider_test_running: + self._provider_status_label.set_text( + _('Model test already running.')) + return + + self._provider_test_running = True + self._provider_status_label.set_text( + _('Testing %s / %s...') % (provider.label, provider.model)) + if self._prompt_status_label is not None: + self._prompt_status_label.set_text(_('Testing model')) + + worker = threading.Thread( + target=self._provider_test_worker, + args=(provider,), + ) + worker.daemon = True + worker.start() + + def _provider_test_worker(self, provider): + try: + response = provider.generate_plan( + 'Return exactly one JSON object and no Markdown. ' + 'Use this schema: {"ok": true, "message": "ready"}.', + 'Reply with {"ok": true, "message": "ready"} if this model ' + 'can answer Sugar Activity on Demand requests.', + timeout=60, + ) + if not isinstance(response, dict): + raise ValueError('Model test did not return JSON.') + if not response.get('ok', True): + raise ValueError( + response.get('message') or + 'Model test returned ok=false.' + ) + message = response.get('message') or 'ready' + GObject.idle_add( + self._provider_test_finished_cb, + True, + provider.label, + provider.model, + message, + ) + except Exception as error: + GObject.idle_add( + self._provider_test_finished_cb, + False, + provider.label, + provider.model, + self._redact_provider_error_text(error, provider), + ) + + def _provider_test_finished_cb(self, passed, label, model, message): + self._provider_test_running = False + if self._generation_job_id is not None: + return False + if passed: + self._provider_status_label.set_text( + _('Model test passed: %s / %s answered.') % + (label, model)) + if self._prompt_status_label is not None: + self._prompt_status_label.set_text(_('Model ready')) + else: + self._provider_status_label.set_text( + _('Model test failed: %s') % message) + if self._prompt_status_label is not None: + self._prompt_status_label.set_text(_('Model failed')) + return False + + def _redact_provider_error_text(self, error, provider): + text = str(error) + api_key = getattr(provider, '_api_key', '') + if api_key: + text = text.replace(api_key, '[redacted]') + return text + + def __provider_model_switch_clicked_cb(self, button, model): + if self._provider_model_entry is None: + return + + self._provider_model_entry.set_text(model) + if self._provider_status_label is not None: + self._provider_status_label.set_text( + _('Model set to %s. Press Save & use to keep it.') % model) + + def _paste_provider_key_from_clipboard(self): + if self._provider_key_entry is None: + return False + if not self._provider_key_entry.get_sensitive(): + if self._provider_status_label is not None: + self._provider_status_label.set_text( + _('Choose a cloud provider before pasting a key.')) + return True + + self._provider_key_entry.grab_focus() + if self._provider_status_label is not None: + self._provider_status_label.set_text( + _('Reading clipboard...')) + Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD).request_text( + self.__provider_clipboard_text_received_cb, + False, + ) + return True + + def __provider_clipboard_text_received_cb(self, clipboard, text, + tried_primary): + if not text: + if not tried_primary: + Gtk.Clipboard.get(Gdk.SELECTION_PRIMARY).request_text( + self.__provider_clipboard_text_received_cb, + True, + ) + return + previous_text = '' + if self._provider_key_entry is not None: + previous_text = self._provider_key_entry.get_text() + self._provider_key_entry.paste_clipboard() + GObject.timeout_add( + 180, + self.__provider_key_default_paste_checked_cb, + previous_text, + ) + if self._provider_status_label is not None: + self._provider_status_label.set_text( + _('Trying the system paste shortcut...')) + return + + text = self._clean_pasted_api_key(text) + if not text: + if self._provider_status_label is not None: + self._provider_status_label.set_text( + _('Clipboard does not contain an API key.')) + return + + self._set_provider_key_text(text) + if self._provider_status_label is not None: + self._provider_status_label.set_text( + _('API key pasted. Send your prompt to start generation.')) + + def _clean_pasted_api_key(self, text): + text = text.strip() + if not text: + return '' + + lines = [ + line.strip() for line in text.splitlines() + if line.strip() + ] + if len(lines) == 1: + text = lines[0] + + if '=' in text and not any(char.isspace() for char in text): + text = text.split('=', 1)[1] + + return text.strip().strip('\'"') + + def _set_provider_key_text(self, text): + entry = self._provider_key_entry + if entry is None: + return + entry.set_text(text) + entry.set_position(len(text)) + + def __provider_key_entry_paste_clipboard_cb(self, entry): + if self._provider_status_label is not None: + self._provider_status_label.set_text(_('Pasting API key...')) + GObject.idle_add(self.__provider_key_entry_paste_finished_cb) + + def __provider_key_entry_paste_finished_cb(self): + if self._provider_key_entry is None: + return False + + text = self._provider_key_entry.get_text() + cleaned = self._clean_pasted_api_key(text) + if cleaned and cleaned != text: + self._set_provider_key_text(cleaned) + + if cleaned and self._provider_status_label is not None: + self._provider_status_label.set_text( + _('API key pasted. Press Save & use or send your prompt.')) + return False + + def __provider_key_default_paste_checked_cb(self, previous_text): + if self._provider_key_entry is None: + return False + + text = self._provider_key_entry.get_text() + if text and text != previous_text: + if self._provider_status_label is not None: + self._provider_status_label.set_text( + _('API key pasted. Send your prompt to start generation.')) + return False + + external_text = self._read_external_clipboard_text() + if external_text: + self._set_provider_key_text(external_text) + if self._provider_status_label is not None: + self._provider_status_label.set_text( + _('API key pasted from the desktop clipboard. Send your ' + 'prompt to start generation.')) + return False + + if self._provider_status_label is not None: + self._provider_status_label.set_text( + _('Paste did not add text. Copy the key again, then press ' + 'Ctrl+V or the Paste button.')) + return False + + def _read_external_clipboard_text(self): + display_names = [] + requested = os.environ.get('SUGAR_AOD_CLIPBOARD_DISPLAY', '') + if requested: + display_names.append(requested) + for display_name in (':1', ':0'): + if display_name not in display_names and \ + display_name != os.environ.get('DISPLAY'): + display_names.append(display_name) + + for display_name in display_names: + for xauthority in self._get_external_xauthority_paths(): + text = self._read_external_clipboard_with_helper( + display_name, xauthority) + if text: + return text + return '' + + def _read_external_clipboard_with_helper(self, display_name, xauthority): + script = ''' +import sys + +import gi +gi.require_version('Gdk', '3.0') +gi.require_version('Gtk', '3.0') +from gi.repository import Gdk, Gtk + +clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) +if clipboard.wait_is_text_available(): + sys.stdout.write(clipboard.wait_for_text() or '') +''' + env = { + 'DISPLAY': display_name, + 'GDK_BACKEND': 'x11', + 'HOME': os.path.expanduser('~'), + 'NO_AT_BRIDGE': '1', + 'PATH': '/usr/bin:/bin', + } + runtime_dir = os.environ.get('XDG_RUNTIME_DIR') + if runtime_dir: + env['XDG_RUNTIME_DIR'] = runtime_dir + if xauthority: + env['XAUTHORITY'] = xauthority + + try: + output = subprocess.check_output( + ['/usr/bin/python3', '-c', script], + env=env, + stderr=subprocess.DEVNULL, + timeout=2, + ) + except (OSError, subprocess.SubprocessError): + logging.debug( + 'Could not read external clipboard with helper from %s', + display_name, + exc_info=True, + ) + return '' + + return self._clean_pasted_api_key( + output.decode('utf-8', 'replace')) + + def _get_external_xauthority_paths(self): + paths = [] + requested = os.environ.get('SUGAR_AOD_CLIPBOARD_XAUTHORITY', '') + if requested: + paths.append(requested) + current = os.environ.get('XAUTHORITY', '') + if current: + paths.append(current) + + runtime_dir = os.environ.get('XDG_RUNTIME_DIR', + '/run/user/%d' % os.getuid()) + mutter_paths = glob.glob(os.path.join(runtime_dir, + '.mutter-Xwaylandauth.*')) + mutter_paths.sort( + key=lambda path: os.path.getmtime(path) + if os.path.exists(path) else 0, + reverse=True) + paths.extend(mutter_paths) + paths.append(os.path.expanduser('~/.Xauthority')) + paths.append('') + + seen = set() + existing_paths = [] + for path in paths: + if path in seen: + continue + seen.add(path) + if not path or os.path.exists(path): + existing_paths.append(path) + return existing_paths + + def _resolve_generation_provider_name(self, service): + planner = self._selected_options['planner'] + policy = self._selected_options['policy'] + selected = self._selected_options['provider'] + + if planner == 'validate' or policy == 'strict': + return 'local-template' + if policy == 'local': + if selected == 'ollama': + return 'ollama' + return service.preferred_local_provider_name() + if selected == 'default': + preferred = service.preferred_provider_name() + if preferred == 'local-template': + return 'default' + return preferred + return selected + + def _update_template_hint(self): + if self._template_hint is None: + return + + notes = { + 'logic_math': _('Learning area: logic and math activities for ' + 'puzzles, patterns, and reasoning.'), + 'tools_utils': _('Learning area: tools and utilities that help ' + 'learners make, measure, organize, or explore.'), + 'games': _('Learning area: games with play loops, goals, ' + 'scoring, and feedback.'), + 'creation': _('Learning area: creation activities for drawing, ' + 'writing, building, and expression.'), + } + self._template_hint.set_text( + notes[self._selected_options['template']]) + + def _update_license_hint(self): + if self._license_hint is None: + return + + license_info = self._get_selected_license() + self._license_hint.set_text( + _('%s. Adds LICENSE, SPDX headers, and bundle metadata. SPDX: %s') + % (license_info['description'], license_info['spdx'])) + + def _refresh_generated_context(self): + self._update_preview_license_summary() + if self._review_title_label is not None: + self._set_review_file(self._current_review_file) + + def _update_preview_license_summary(self): + if self._preview_empty_note is None: + return + + license_info = self._get_selected_license() + self._preview_empty_note.set_text( + _('Generation uses %s, RAG examples, the configured model, and ' + 'validation before packaging.') + % license_info['label']) + + def _start_generation_animation(self, message=None): + if self._generation_animation_hide_id: + GLib.source_remove(self._generation_animation_hide_id) + self._generation_animation_hide_id = 0 + self._show_generation_activity_preview(message) + if self._preview_generation_progress is not None: + self._preview_generation_progress.set_fraction(0.0) + self._preview_generation_progress.show() + if self._preview_generation_stage is not None: + self._preview_generation_stage.set_text( + message or _('Starting activity generation...')) + self._preview_generation_stage.show() + self._set_generation_step_active(0) + if not self._generation_animation_id: + self._generation_animation_id = GLib.timeout_add( + 400, self._pulse_generation_progress) + + def _stop_generation_animation(self): + if self._generation_animation_id: + GLib.source_remove(self._generation_animation_id) + self._generation_animation_id = 0 + if self._generation_animation_hide_id: + GLib.source_remove(self._generation_animation_hide_id) + self._generation_animation_hide_id = 0 + if self._preview_generation_progress is not None: + self._preview_generation_progress.hide() + if self._preview_generation_stage is not None: + self._preview_generation_stage.hide() + + def _pulse_generation_progress(self): + if self._preview_generation_progress is not None: + self._preview_generation_progress.pulse() + return True + + def _update_generation_animation(self, stage, fraction, message): + if self._preview_generation_progress is not None: + self._preview_generation_progress.show() + if fraction > 0: + if self._generation_animation_id: + GLib.source_remove(self._generation_animation_id) + self._generation_animation_id = 0 + self._preview_generation_progress.set_fraction( + max(0.0, min(0.98, float(fraction)))) + if self._preview_generation_stage is not None: + self._preview_generation_stage.set_text(message) + self._preview_generation_stage.show() + self._set_generation_step_active( + self._generation_step_index_for_stage(stage)) + + def _complete_generation_animation(self, result=None): + if self._generation_animation_id: + GLib.source_remove(self._generation_animation_id) + self._generation_animation_id = 0 + if self._preview_generation_progress is not None: + self._preview_generation_progress.set_fraction(1.0) + self._preview_generation_progress.show() + if self._preview_generation_stage is not None: + self._preview_generation_stage.set_text(_('Activity is ready')) + self._preview_generation_stage.show() + self._set_generation_step_active(4) + if self._generation_animation_hide_id: + GLib.source_remove(self._generation_animation_hide_id) + self._generation_animation_hide_id = GLib.timeout_add( + 700, self._show_generated_activity_preview, result) + + def _show_generated_activity_preview(self, result): + self._generation_animation_hide_id = 0 + self._stop_generation_animation() + if result is not None: + self._render_generated_activity_preview(result) + return False + + def _render_generated_activity_preview(self, result): + self._clear_activity_preview() + plan = result.plan if isinstance(result.plan, dict) else {} + template = plan.get('template', 'utility') + + card = Gtk.EventBox() + card.get_style_context().add_class('create-ai-generated-preview') + card.set_hexpand(True) + card.set_vexpand(True) + card.add_events(Gdk.EventMask.BUTTON_PRESS_MASK) + card.connect('button-press-event', + self.__preview_target_button_press_event_cb, + _('activity canvas')) + self._preview_content_box.pack_start(card, True, True, 0) + + box = Gtk.VBox(spacing=style.zoom(10)) + box.set_border_width(style.zoom(14)) + card.add(box) + + header = Gtk.HBox(spacing=style.zoom(8)) + box.pack_start(header, False, False, 0) + + title = Gtk.Label(result.spec.name) + title.get_style_context().add_class('create-ai-generated-title') + title.set_xalign(0) + title.set_ellipsize(Pango.EllipsizeMode.END) + header.pack_start(title, True, True, 0) + + badge = Gtk.Label(template.capitalize()) + badge.get_style_context().add_class('create-ai-generated-badge') + header.pack_end(badge, False, False, 0) + + summary = Gtk.Label( + plan.get('summary') or plan.get('learner_goal') or + result.spec.prompt) + summary.get_style_context().add_class('create-ai-generated-summary') + summary.set_xalign(0) + summary.set_line_wrap(True) + summary.set_max_width_chars(120) + box.pack_start(summary, False, False, 0) + + body = self._create_generated_preview_body(result, template) + box.pack_start(body, True, True, 0) + + footer = Gtk.HBox(spacing=style.zoom(6)) + box.pack_start(footer, False, False, 0) + for label in [_('Journal ready'), _('Project validated'), + _('XO on install/export')]: + pill = Gtk.Label(label) + pill.get_style_context().add_class('create-ai-generated-pill') + footer.pack_start(pill, False, False, 0) + + card.show_all() + + def _create_generated_preview_body(self, result, template): + plan = result.plan if isinstance(result.plan, dict) else {} + source = self._get_generated_activity_source(result) + if self._source_mentions_turn_drawing_canvas(source): + return self._create_turn_drawing_activity_preview(plan, source) + if self._source_mentions_paired_canvas(source): + return self._create_paired_canvas_activity_preview(plan, source) + if self._source_mentions_canvas(source) and template == 'utility': + return self._create_canvas_activity_preview(plan, source) + if template == 'quiz': + return self._create_quiz_activity_preview(plan) + if template == 'carrom': + return self._create_carrom_activity_preview(plan) + if template == 'chess': + return self._create_chess_activity_preview(plan) + if template == 'grid': + return self._create_grid_activity_preview(plan) + if template == 'canvas': + if (self._is_paired_canvas_activity(plan) or + self._source_mentions_paired_canvas(source)): + return self._create_paired_canvas_activity_preview( + plan, source) + return self._create_canvas_activity_preview(plan, source) + if template == 'narrative': + return self._create_narrative_activity_preview(plan, result) + return self._create_utility_activity_preview(plan, result) + + def _create_quiz_activity_preview(self, plan): + box = Gtk.VBox(spacing=style.zoom(8)) + box.get_style_context().add_class('create-ai-generated-body') + + questions = plan.get('questions') or [] + first = questions[0] if questions else {} + question_text = first.get( + 'question', _('What is one idea you can explain?')) + + label = Gtk.Label(_('Question 1')) + label.get_style_context().add_class('create-ai-generated-kicker') + label.set_xalign(0) + box.pack_start(label, False, False, 0) + + question = Gtk.Label(question_text) + question.get_style_context().add_class('create-ai-generated-question') + question.set_xalign(0) + question.set_line_wrap(True) + box.pack_start(question, True, True, 0) + + entry = Gtk.Entry() + entry.set_placeholder_text(_('Type your answer here')) + entry.get_style_context().add_class('create-ai-generated-entry') + entry.connect('button-press-event', + self.__preview_target_button_press_event_cb, + _('quiz answer input')) + box.pack_start(entry, False, False, 0) + + row = Gtk.HBox(spacing=style.zoom(6)) + box.pack_start(row, False, False, 0) + button = Gtk.Button.new_with_label(_('Check answer')) + button.get_style_context().add_class('create-ai-generated-action') + row.pack_start(button, False, False, 0) + feedback = Gtk.Label(_('Score: 0')) + feedback.get_style_context().add_class('create-ai-generated-summary') + row.pack_start(feedback, False, False, 0) + return box + + def _create_chess_activity_preview(self, plan): + box = Gtk.VBox(spacing=style.zoom(8)) + box.get_style_context().add_class('create-ai-generated-body') + box.set_hexpand(True) + box.set_vexpand(True) + show_move_log = plan.get('chess_show_move_log', True) + + status = Gtk.Label(_('White to move. Select a piece.')) + status.get_style_context().add_class('create-ai-generated-summary') + status.set_justify(Gtk.Justification.CENTER) + box.pack_start(status, False, False, 0) + + play_area = Gtk.HBox(spacing=style.zoom(16)) + play_area.set_halign(Gtk.Align.FILL) + play_area.set_valign(Gtk.Align.FILL) + box.pack_start(play_area, True, True, 0) + + board_frame = Gtk.Alignment(xalign=0.5, yalign=0.5, xscale=0, + yscale=0) + play_area.pack_start(board_frame, True, True, 0) + + board_box = Gtk.VBox(spacing=style.zoom(4)) + board_frame.add(board_box) + + files = Gtk.HBox(spacing=style.zoom(2)) + files.set_halign(Gtk.Align.CENTER) + files.pack_start(Gtk.Label(label=' '), False, False, 0) + for file_name in 'abcdefgh': + label = Gtk.Label(label=file_name) + label.get_style_context().add_class('create-ai-generated-summary') + label.set_size_request(style.zoom(62), style.zoom(16)) + files.pack_start(label, False, False, 0) + board_box.pack_start(files, False, False, 0) + + board_row = Gtk.HBox(spacing=style.zoom(6)) + board_box.pack_start(board_row, False, False, 0) + + ranks = Gtk.VBox(spacing=style.zoom(2)) + board_row.pack_start(ranks, False, False, 0) + + grid = Gtk.Grid(row_spacing=style.zoom(2), column_spacing=style.zoom(2)) + grid.set_halign(Gtk.Align.CENTER) + grid.set_valign(Gtk.Align.CENTER) + board_row.pack_start(grid, False, False, 0) + + side = Gtk.VBox(spacing=style.zoom(8)) + side.set_size_request(style.zoom(320), -1) + side.set_valign(Gtk.Align.FILL) + play_area.pack_start(side, False, False, 0) + + hint = Gtk.Label( + _('Use the preview like the activity: pick a piece, then choose ' + 'where it should move. The generated activity saves this work ' + 'in the Journal.')) + hint.get_style_context().add_class('create-ai-generated-summary') + hint.set_xalign(0) + hint.set_line_wrap(True) + side.pack_start(hint, False, False, 0) + + log = None + if show_move_log: + log_frame = Gtk.EventBox() + log_frame.get_style_context().add_class('create-ai-generated-log') + log_frame.add_events(Gdk.EventMask.BUTTON_PRESS_MASK) + log_frame.connect('button-press-event', + self.__preview_target_button_press_event_cb, + _('move log panel')) + side.pack_start(log_frame, True, True, 0) + + log_box = Gtk.VBox(spacing=style.zoom(5)) + log_box.set_border_width(style.zoom(9)) + log_frame.add(log_box) + + log_title = Gtk.Label(_('Move log')) + log_title.get_style_context().add_class( + 'create-ai-generated-kicker') + log_title.set_xalign(0) + log_box.pack_start(log_title, False, False, 0) + + log = Gtk.Label() + log.get_style_context().add_class('create-ai-generated-summary') + log.set_xalign(0) + log.set_yalign(0) + log.set_line_wrap(True) + log_box.pack_start(log, True, True, 0) + else: + clean_note = Gtk.Label( + _('Clean board mode: move history is hidden.')) + clean_note.get_style_context().add_class( + 'create-ai-generated-summary') + clean_note.set_xalign(0) + clean_note.set_line_wrap(True) + side.pack_start(clean_note, False, False, 0) + + reset = Gtk.Button.new_with_label(_('Reset preview')) + reset.get_style_context().add_class('create-ai-generated-action') + reset.connect('button-press-event', + self.__preview_target_button_press_event_cb, + _('reset control')) + side.pack_start(reset, False, False, 0) + + pieces = [ + ['♜', '♞', '♝', '♛', '♚', '♝', '♞', '♜'], + ['♟', '♟', '♟', '♟', '♟', '♟', '♟', '♟'], + ['', '', '', '', '', '', '', ''], + ['', '', '', '', '', '', '', ''], + ['', '', '', '', '', '', '', ''], + ['', '', '', '', '', '', '', ''], + ['♙', '♙', '♙', '♙', '♙', '♙', '♙', '♙'], + ['♖', '♘', '♗', '♕', '♔', '♗', '♘', '♖'], + ] + state = { + 'board': [row[:] for row in pieces], + 'turn': 'white', + 'selected': None, + 'moves': [], + 'show_move_log': show_move_log, + } + buttons = [] + for row in range(8): + rank = Gtk.Label(label=str(8 - row)) + rank.get_style_context().add_class('create-ai-generated-summary') + rank.set_size_request(style.zoom(14), style.zoom(58)) + ranks.pack_start(rank, False, False, 0) + button_row = [] + for col in range(8): + label = pieces[row][col] or ' ' + button = Gtk.Button.new_with_label(label) + button.get_style_context().add_class( + 'create-ai-generated-chess-square') + if (row + col) % 2: + button.get_style_context().add_class( + 'create-ai-generated-chess-dark') + button.set_size_request(style.zoom(62), style.zoom(58)) + button.connect('clicked', + self.__generated_chess_square_clicked_cb, + status, state, buttons, log, row, col) + grid.attach(button, col, row, 1, 1) + button_row.append(button) + buttons.append(button_row) + reset.connect('clicked', self.__generated_chess_reset_clicked_cb, + status, state, buttons, log, pieces) + self._refresh_generated_chess_preview(status, state, buttons, log) + return box + + def _create_carrom_activity_preview(self, plan): + box = Gtk.VBox(spacing=style.zoom(8)) + box.get_style_context().add_class('create-ai-generated-body') + box.set_hexpand(True) + box.set_vexpand(True) + + status = Gtk.Label( + _('Student A to shoot. Click the board to place the striker aim.')) + status.get_style_context().add_class('create-ai-generated-summary') + status.set_line_wrap(True) + status.set_xalign(0) + box.pack_start(status, False, False, 0) + + play_area = Gtk.HBox(spacing=style.zoom(16)) + play_area.set_halign(Gtk.Align.FILL) + play_area.set_valign(Gtk.Align.FILL) + box.pack_start(play_area, True, True, 0) + + drawing = Gtk.DrawingArea() + drawing.set_size_request(style.zoom(560), style.zoom(420)) + drawing.get_style_context().add_class('create-ai-generated-canvas') + drawing.add_events(Gdk.EventMask.BUTTON_PRESS_MASK) + play_area.pack_start(drawing, True, True, 0) + + side = Gtk.VBox(spacing=style.zoom(8)) + side.set_size_request(style.zoom(320), -1) + side.set_valign(Gtk.Align.FILL) + play_area.pack_start(side, False, False, 0) + + score = Gtk.Label() + score.get_style_context().add_class('create-ai-generated-summary') + score.set_xalign(0) + score.set_line_wrap(True) + side.pack_start(score, False, False, 0) + + buttons = Gtk.Grid(row_spacing=style.zoom(5), + column_spacing=style.zoom(5)) + side.pack_start(buttons, False, False, 0) + + log_frame = Gtk.EventBox() + log_frame.get_style_context().add_class('create-ai-generated-log') + log_frame.add_events(Gdk.EventMask.BUTTON_PRESS_MASK) + log_frame.connect('button-press-event', + self.__preview_target_button_press_event_cb, + _('carrom shot log')) + side.pack_start(log_frame, True, True, 0) + + log_box = Gtk.VBox(spacing=style.zoom(5)) + log_box.set_border_width(style.zoom(8)) + log_frame.add(log_box) + + log_title = Gtk.Label(_('Shot log')) + log_title.get_style_context().add_class('create-ai-generated-kicker') + log_title.set_xalign(0) + log_box.pack_start(log_title, False, False, 0) + + log = Gtk.Label() + log.get_style_context().add_class('create-ai-generated-summary') + log.set_xalign(0) + log.set_yalign(0) + log.set_line_wrap(True) + log_box.pack_start(log, True, True, 0) + + state = { + 'turn': 'A', + 'scores': {'A': 0, 'B': 0}, + 'fouls': {'A': 0, 'B': 0}, + 'coins': {'white': 9, 'black': 9, 'queen': 1}, + 'aim': [0.5, 0.82], + 'log': [], + } + actions = ( + (_('Pocket white'), 'white'), + (_('Pocket black'), 'black'), + (_('Pocket queen'), 'queen'), + (_('Foul'), 'foul'), + (_('Switch turn'), 'switch'), + (_('Reset'), 'reset'), + ) + for index, item in enumerate(actions): + label, action = item + button = Gtk.Button.new_with_label(label) + button.get_style_context().add_class('create-ai-generated-action') + button.connect('clicked', + self.__generated_carrom_action_clicked_cb, + action, state, status, score, log, drawing) + buttons.attach(button, index % 2, index // 2, 1, 1) + + drawing.connect('draw', self.__generated_carrom_draw_cb, state) + drawing.connect('button-press-event', + self.__generated_carrom_board_press_cb, + state, status, score, log) + self._refresh_generated_carrom_preview( + status, score, log, drawing, state) + return box + + def _create_grid_activity_preview(self, plan): + box = Gtk.VBox(spacing=style.zoom(8)) + box.get_style_context().add_class('create-ai-generated-body') + + grid = Gtk.Grid(row_spacing=style.zoom(6), column_spacing=style.zoom(6)) + grid.set_halign(Gtk.Align.CENTER) + box.pack_start(grid, True, True, 0) + for index in range(16): + button = Gtk.ToggleButton.new_with_label(str(index + 1)) + button.get_style_context().add_class('create-ai-generated-tile') + button.set_size_request(style.zoom(48), style.zoom(40)) + button.connect('clicked', + self.__generated_grid_tile_clicked_cb, + index) + if index in (1, 4, 6, 9, 11, 14): + button.set_active(True) + grid.attach(button, index % 4, index // 4, 1, 1) + + status = Gtk.Label(_('6 squares are part of your pattern.')) + status.get_style_context().add_class('create-ai-generated-summary') + status.set_justify(Gtk.Justification.CENTER) + box.pack_start(status, False, False, 0) + return box + + def _create_canvas_activity_preview(self, plan, source=''): + box = Gtk.VBox(spacing=style.zoom(8)) + box.get_style_context().add_class('create-ai-generated-body') + box.set_hexpand(True) + box.set_vexpand(True) + + drawing = Gtk.DrawingArea() + drawing.set_size_request(style.zoom(720), style.zoom(320)) + drawing.get_style_context().add_class('create-ai-generated-canvas') + drawing.add_events(Gdk.EventMask.BUTTON_PRESS_MASK) + drawing.connect('button-press-event', + self.__preview_target_button_press_event_cb, + _('drawing canvas')) + drawing.connect('draw', self.__generated_canvas_draw_cb) + box.pack_start(drawing, True, True, 0) + + row = Gtk.HBox(spacing=style.zoom(6)) + row.set_halign(Gtk.Align.CENTER) + box.pack_start(row, False, False, 0) + labels = self._activity_source_canvas_actions(source) + if not labels: + labels = [_('Draw'), _('Clear drawing')] + for label in labels[:6]: + button = Gtk.Button.new_with_label(label) + button.get_style_context().add_class('create-ai-generated-action') + row.pack_start(button, False, False, 0) + return box + + def _get_generated_activity_source(self, result): + files = getattr(result, 'files', None) + if not isinstance(files, dict): + return '' + return files.get('activity.py') or files.get('./activity.py') or '' + + def _source_mentions_paired_canvas(self, source): + text = (source or '').lower() + if not self._source_mentions_canvas(source): + return False + pair_hits = sum(phrase in text for phrase in ( + 'student a', + 'student b', + 'switch turn', + 'two student', + 'two learner', + 'partner', + 'shared drawing', + 'take turns', + 'together', + )) + tool_hits = len(self._activity_source_canvas_tools(source)) + if self._source_has_label(source, _('Undo last mark')): + tool_hits += 1 + if self._source_has_label(source, _('Clear drawing')): + tool_hits += 1 + return pair_hits >= 2 and tool_hits >= 2 + + def _source_mentions_canvas(self, source): + text = (source or '').lower() + return ( + ('drawingarea' in text or 'drawing area' in text) and + any(phrase in text for phrase in ( + 'canvas', 'draw', 'drawing', 'paint', 'sketch', 'stroke')) + ) + + def _source_mentions_turn_drawing_canvas(self, source): + text = (source or '').lower() + if not self._source_mentions_canvas(source): + return False + return ( + 'switch turn' in text and + ('label mode' in text or 'label_entry' in text) and + ('brush_spin' in text or 'brush size' in text or 'size:' in text) + ) + + def _source_has_label(self, source, label): + if not source or not label: + return False + return ("'%s'" % label) in source or ('"%s"' % label) in source + + def _activity_source_canvas_actions(self, source): + candidates = ( + _('Draw'), + _('Free Draw'), + _('Line'), + _('Rectangle'), + _('Circle'), + _('Eraser'), + _('Undo last mark'), + _('Clear drawing'), + ) + labels = [] + for label in candidates: + if self._source_has_label(source, label): + labels.append(label) + return labels + + def _is_paired_canvas_activity(self, plan): + fields = [ + plan.get('activity_kind', ''), + plan.get('summary', ''), + plan.get('interaction_model', ''), + plan.get('learner_goal', ''), + plan.get('state_schema', ''), + ] + for key in ('ui_regions', 'features', 'learner_steps', + 'classroom_flow'): + value = plan.get(key) + if isinstance(value, list): + fields.extend(str(item) for item in value) + text = ' '.join(fields).lower() + return any(phrase in text for phrase in ( + 'student a', + 'student b', + 'two student', + 'two learners', + 'partner', + 'paired', + 'shared drawing', + 'take turns', + 'switch turns', + 'together', + )) + + def _create_turn_drawing_activity_preview(self, plan, source=''): + box = Gtk.VBox(spacing=style.zoom(8)) + box.get_style_context().add_class('create-ai-generated-body') + box.set_hexpand(True) + box.set_vexpand(True) + + state = { + 'current_turn': 1, + 'turn_counts': {1: 0, 2: 0}, + 'student_colors': { + 1: (0.9, 0.2, 0.2, 1.0), + 2: (0.2, 0.4, 0.9, 1.0), + }, + 'brush_size': 3, + 'strokes': [], + 'labels': [], + 'current_stroke': None, + } + + controls = Gtk.HBox(spacing=style.zoom(8)) + controls.set_halign(Gtk.Align.FILL) + box.pack_start(controls, False, False, 0) + + status = Gtk.Label() + status.get_style_context().add_class('create-ai-generated-summary') + status.set_xalign(0) + controls.pack_start(status, False, False, 0) + + palette = Gtk.HBox(spacing=style.zoom(2)) + controls.pack_start(palette, False, False, style.zoom(4)) + colors = [ + ('#e74c3c', (0.9, 0.2, 0.2, 1.0)), + ('#2ecc71', (0.2, 0.7, 0.2, 1.0)), + ('#3498db', (0.2, 0.4, 0.9, 1.0)), + ('#f1c40f', (0.9, 0.8, 0.2, 1.0)), + ('#9b59b6', (0.7, 0.2, 0.7, 1.0)), + ('#000000', (0.1, 0.1, 0.1, 1.0)), + ] + for hex_color, rgba in colors: + button = Gtk.Button() + button.get_style_context().add_class('create-ai-generated-action') + label = Gtk.Label() + label.set_markup( + '' % hex_color) + button.add(label) + button.connect('clicked', + self.__turn_preview_color_clicked_cb, + state, rgba) + palette.pack_start(button, False, False, 0) + + size_row = Gtk.HBox(spacing=style.zoom(3)) + size_row.pack_start(Gtk.Label(label=_('Size:')), False, False, 0) + adjustment = Gtk.Adjustment(value=3, lower=1, upper=20, + step_increment=1) + brush_spin = Gtk.SpinButton() + brush_spin.set_adjustment(adjustment) + brush_spin.set_numeric(True) + brush_spin.connect('value-changed', + self.__turn_preview_brush_changed_cb, + state) + size_row.pack_start(brush_spin, False, False, 0) + controls.pack_start(size_row, False, False, style.zoom(4)) + + label_entry = Gtk.Entry() + label_entry.set_placeholder_text(_('Type label...')) + label_entry.set_width_chars(14) + controls.pack_start(label_entry, False, False, 0) + + label_toggle = Gtk.ToggleButton.new_with_label(_('Label Mode')) + label_toggle.get_style_context().add_class( + 'create-ai-generated-action') + controls.pack_start(label_toggle, False, False, 0) + + switch = Gtk.Button.new_with_label(_('Switch Turn ->')) + switch.get_style_context().add_class('create-ai-generated-action') + switch.connect('clicked', + self.__turn_preview_switch_clicked_cb, + state, status) + controls.pack_end(switch, False, False, 0) + + drawing = Gtk.DrawingArea() + drawing.set_size_request(style.zoom(900), style.zoom(420)) + drawing.get_style_context().add_class('create-ai-generated-canvas') + drawing.add_events( + Gdk.EventMask.BUTTON_PRESS_MASK | + Gdk.EventMask.BUTTON_RELEASE_MASK | + Gdk.EventMask.POINTER_MOTION_MASK + ) + drawing.connect('draw', self.__turn_preview_draw_cb, state) + drawing.connect('button-press-event', + self.__turn_preview_press_cb, + state, label_entry, label_toggle) + drawing.connect('motion-notify-event', + self.__turn_preview_motion_cb, state) + drawing.connect('button-release-event', + self.__turn_preview_release_cb, state) + box.pack_start(drawing, True, True, 0) + + note = Gtk.Label( + _('Preview mirrors the generated activity.py controls. Install ' + 'and open to run the exact Sugar activity.')) + note.get_style_context().add_class('create-ai-generated-summary') + note.set_xalign(0) + note.set_line_wrap(True) + box.pack_start(note, False, False, 0) + + self._refresh_turn_preview_status(status, state) + return box + + def _create_paired_canvas_activity_preview(self, plan, source=''): + box = Gtk.HBox(spacing=style.zoom(10)) + box.get_style_context().add_class('create-ai-generated-body') + box.set_hexpand(True) + box.set_vexpand(True) + + state = { + 'active': 'A', + 'tool': _('Free Draw'), + 'marks_a': 1, + 'marks_b': 1, + } + + side = Gtk.VBox(spacing=style.zoom(7)) + side.set_size_request(style.zoom(235), -1) + box.pack_start(side, False, False, 0) + + partner_label = Gtk.Label(_('Partner controls')) + partner_label.get_style_context().add_class('create-ai-generated-kicker') + partner_label.set_xalign(0) + side.pack_start(partner_label, False, False, 0) + + status = Gtk.Label() + status.get_style_context().add_class('create-ai-generated-summary') + status.set_xalign(0) + status.set_line_wrap(True) + side.pack_start(status, False, False, 0) + + student_row = Gtk.HBox(spacing=style.zoom(5)) + side.pack_start(student_row, False, False, 0) + for student, label in (('A', _('Student A')), ('B', _('Student B'))): + button = Gtk.Button.new_with_label(label) + button.get_style_context().add_class('create-ai-generated-action') + button.connect('clicked', + self.__paired_preview_student_clicked_cb, + state, status, student) + student_row.pack_start(button, True, True, 0) + + switch = Gtk.Button.new_with_label(_('Switch Turn')) + switch.get_style_context().add_class('create-ai-generated-action') + switch.connect('clicked', self.__paired_preview_switch_clicked_cb, + state, status) + side.pack_start(switch, False, False, 0) + + tools_label = Gtk.Label(_('Drawing tools')) + tools_label.get_style_context().add_class('create-ai-generated-kicker') + tools_label.set_xalign(0) + side.pack_start(tools_label, False, False, 0) + + tool_grid = Gtk.Grid(row_spacing=style.zoom(4), + column_spacing=style.zoom(4)) + side.pack_start(tool_grid, False, False, 0) + tools = self._activity_source_canvas_tools(source) + if not tools: + tools = [ + _('Free Draw'), _('Line'), _('Rectangle'), + _('Circle'), _('Point'), _('Eraser'), + ] + for index, tool in enumerate(tools): + button = Gtk.Button.new_with_label(tool) + button.get_style_context().add_class('create-ai-generated-action') + button.connect('clicked', + self.__paired_preview_tool_clicked_cb, + state, status, tool) + tool_grid.attach(button, index % 2, index // 2, 1, 1) + + sample = Gtk.Button.new_with_label(_('Add sample mark')) + sample.get_style_context().add_class('create-ai-generated-action') + sample.connect('clicked', self.__paired_preview_mark_clicked_cb, + state, status) + side.pack_start(sample, False, False, 0) + + clear = Gtk.Button.new_with_label(_('Clear preview')) + clear.get_style_context().add_class('create-ai-generated-action') + clear.connect('clicked', self.__paired_preview_clear_clicked_cb, + state, status) + side.pack_start(clear, False, False, 0) + + if 'undo' in (source or '').lower(): + undo = Gtk.Button.new_with_label(_('Undo last mark')) + undo.get_style_context().add_class('create-ai-generated-action') + undo.connect('button-press-event', + self.__preview_target_button_press_event_cb, + _('undo control')) + side.pack_start(undo, False, False, 0) + + words = Gtk.Label(_('Word bank: pattern, symmetry, turn, shape, ' + 'coordinate, repeat, rule')) + words.get_style_context().add_class('create-ai-generated-summary') + words.set_xalign(0) + words.set_line_wrap(True) + side.pack_start(words, False, False, 0) + + right = Gtk.VBox(spacing=style.zoom(7)) + box.pack_start(right, True, True, 0) + + drawing = Gtk.DrawingArea() + drawing.set_size_request(style.zoom(760), style.zoom(360)) + drawing.get_style_context().add_class('create-ai-generated-canvas') + drawing.add_events(Gdk.EventMask.BUTTON_PRESS_MASK) + drawing.connect('button-press-event', + self.__preview_target_button_press_event_cb, + _('shared drawing canvas')) + drawing.connect('draw', self.__paired_canvas_draw_cb, state) + right.pack_start(drawing, True, True, 0) + + explain = Gtk.TextView() + explain.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) + explain.get_buffer().set_text( + _('We used a repeating shape rule and took turns adding marks.')) + explain.connect('button-press-event', + self.__preview_target_button_press_event_cb, + _('partner explanation box')) + scroll = Gtk.ScrolledWindow() + scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) + scroll.set_size_request(-1, style.zoom(88)) + scroll.add(explain) + right.pack_start(scroll, False, True, 0) + + self._refresh_paired_preview_status(status, state) + return box + + def _activity_source_canvas_tools(self, source): + candidates = ( + _('Free Draw'), + _('Line'), + _('Rectangle'), + _('Circle'), + _('Point'), + _('Eraser'), + ) + labels = [] + for label in candidates: + if self._source_has_label(source, label): + labels.append(label) + return labels + + def _create_narrative_activity_preview(self, plan, result): + box = Gtk.VBox(spacing=style.zoom(8)) + box.get_style_context().add_class('create-ai-generated-body') + + scroll = Gtk.ScrolledWindow() + scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) + scroll.set_size_request(style.zoom(450), style.zoom(190)) + text = Gtk.TextView() + text.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) + text.get_buffer().set_text( + plan.get('starter_text') or result.spec.prompt) + text.connect('button-press-event', + self.__preview_target_button_press_event_cb, + _('writing area')) + scroll.add(text) + box.pack_start(scroll, True, True, 0) + return box + + def _create_utility_activity_preview(self, plan, result): + mode = plan.get('utility_mode', 'word_counter') + if mode == 'counter': + return self._create_counter_activity_preview() + if mode == 'timer': + return self._create_timer_activity_preview() + + box = Gtk.VBox(spacing=style.zoom(8)) + box.get_style_context().add_class('create-ai-generated-body') + + scroll = Gtk.ScrolledWindow() + scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) + scroll.set_size_request(style.zoom(450), style.zoom(160)) + text = Gtk.TextView() + text.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) + text.get_buffer().set_text(result.spec.prompt) + text.connect('button-press-event', + self.__preview_target_button_press_event_cb, + _('utility text area')) + scroll.add(text) + box.pack_start(scroll, True, True, 0) + + count = len(result.spec.prompt.split()) + label = Gtk.Label(_('%d words, %d characters') % + (count, len(result.spec.prompt))) + label.get_style_context().add_class('create-ai-generated-summary') + label.set_xalign(0) + box.pack_start(label, False, False, 0) + return box + + def _create_counter_activity_preview(self): + box = Gtk.VBox(spacing=style.zoom(10)) + box.get_style_context().add_class('create-ai-generated-body') + box.set_halign(Gtk.Align.CENTER) + + count = Gtk.Label('0') + count.get_style_context().add_class('create-ai-generated-title') + count.set_margin_top(style.zoom(24)) + count.set_margin_bottom(style.zoom(12)) + count.add_events(Gdk.EventMask.BUTTON_PRESS_MASK) + count.connect('button-press-event', + self.__preview_target_button_press_event_cb, + _('counter display')) + box.pack_start(count, True, True, 0) + + state = {'count': 0} + row = Gtk.HBox(spacing=style.zoom(8)) + row.set_halign(Gtk.Align.CENTER) + box.pack_start(row, False, False, 0) + for label, amount in ((_('-1'), -1), (_('+1'), 1)): + button = Gtk.Button.new_with_label(label) + button.get_style_context().add_class('create-ai-generated-action') + button.connect('clicked', + self.__generated_counter_clicked_cb, + count, state, amount) + row.pack_start(button, False, False, 0) + + reset = Gtk.Button.new_with_label(_('Reset')) + reset.get_style_context().add_class('create-ai-generated-action') + reset.connect('clicked', + self.__generated_counter_reset_clicked_cb, + count, state) + row.pack_start(reset, False, False, 0) + + note = Gtk.Label(_('Use the count, then explain what it means.')) + note.get_style_context().add_class('create-ai-generated-summary') + note.set_justify(Gtk.Justification.CENTER) + box.pack_start(note, False, False, 0) + return box + + def _create_timer_activity_preview(self): + box = Gtk.VBox(spacing=style.zoom(10)) + box.get_style_context().add_class('create-ai-generated-body') + box.set_halign(Gtk.Align.CENTER) + + timer = Gtk.Label('00:00') + timer.get_style_context().add_class('create-ai-generated-title') + timer.set_margin_top(style.zoom(24)) + timer.set_margin_bottom(style.zoom(12)) + timer.add_events(Gdk.EventMask.BUTTON_PRESS_MASK) + timer.connect('button-press-event', + self.__preview_target_button_press_event_cb, + _('timer display')) + box.pack_start(timer, True, True, 0) + + row = Gtk.HBox(spacing=style.zoom(8)) + row.set_halign(Gtk.Align.CENTER) + box.pack_start(row, False, False, 0) + for label in (_('Start'), _('Reset')): + button = Gtk.Button.new_with_label(label) + button.get_style_context().add_class('create-ai-generated-action') + row.pack_start(button, False, False, 0) + + note = Gtk.Label(_('Generated activity includes a working timer.')) + note.get_style_context().add_class('create-ai-generated-summary') + note.set_justify(Gtk.Justification.CENTER) + box.pack_start(note, False, False, 0) + return box + + def __turn_preview_color_clicked_cb(self, button, state, rgba): + state['student_colors'][state['current_turn']] = rgba + + def __turn_preview_brush_changed_cb(self, spin, state): + state['brush_size'] = spin.get_value_as_int() + + def __turn_preview_switch_clicked_cb(self, button, state, status): + current = state['current_turn'] + state['turn_counts'][current] = \ + state['turn_counts'].get(current, 0) + 1 + state['current_turn'] = 2 if current == 1 else 1 + self._refresh_turn_preview_status(status, state) + + def __turn_preview_press_cb(self, widget, event, state, label_entry, + label_toggle): + if label_toggle.get_active(): + text = label_entry.get_text().strip() + if text: + state['labels'].append({ + 'text': text, + 'x': event.x, + 'y': event.y, + 'student_id': state['current_turn'], + }) + label_entry.set_text('') + widget.queue_draw() + return True + + state['current_stroke'] = { + 'points': [(event.x, event.y)], + 'rgba': state['student_colors'][state['current_turn']], + 'width': state['brush_size'], + 'student_id': state['current_turn'], + } + return True + + def __turn_preview_motion_cb(self, widget, event, state): + stroke = state.get('current_stroke') + if stroke is not None: + stroke['points'].append((event.x, event.y)) + widget.queue_draw() + return True + + def __turn_preview_release_cb(self, widget, event, state): + stroke = state.get('current_stroke') + if stroke is not None: + stroke['points'].append((event.x, event.y)) + state['strokes'].append(stroke) + state['current_stroke'] = None + widget.queue_draw() + return True + + def _refresh_turn_preview_status(self, status, state): + status.set_markup( + _('Student %(student)d Turn ' + '(Turns taken: S1=%(s1)d, S2=%(s2)d)') % { + 'student': state['current_turn'], + 's1': state['turn_counts'].get(1, 0), + 's2': state['turn_counts'].get(2, 0), + } + ) + + def __turn_preview_draw_cb(self, widget, context, state): + allocation = widget.get_allocation() + width = allocation.width + height = allocation.height + context.set_source_rgb(1, 1, 1) + context.rectangle(0, 0, width, height) + context.fill() + + for stroke in state['strokes']: + self._draw_turn_preview_stroke(context, stroke) + if state.get('current_stroke'): + self._draw_turn_preview_stroke(context, state['current_stroke']) + + for label in state['labels']: + student_id = label.get('student_id', 1) + rgba = state['student_colors'].get( + student_id, + (0.1, 0.1, 0.1, 1.0), + ) + context.set_source_rgba(*rgba) + context.arc(label['x'], label['y'], 5, 0, 2 * math.pi) + context.fill() + context.set_source_rgb(0, 0, 0) + context.set_font_size(14) + context.move_to(label['x'] + 8, label['y'] + 4) + context.show_text(label.get('text', '')) + return False + + def _draw_turn_preview_stroke(self, context, stroke): + points = stroke.get('points', []) + if len(points) < 2: + return + context.set_source_rgba(*stroke.get('rgba', (0.1, 0.1, 0.1, 1.0))) + context.set_line_width(stroke.get('width', 3)) + context.move_to(points[0][0], points[0][1]) + for x, y in points[1:]: + context.line_to(x, y) + context.stroke() + + def __paired_preview_student_clicked_cb(self, button, state, status, + student): + state['active'] = student + self._refresh_paired_preview_status(status, state) + + def __paired_preview_switch_clicked_cb(self, button, state, status): + state['active'] = 'B' if state.get('active') == 'A' else 'A' + self._refresh_paired_preview_status(status, state) + + def __paired_preview_tool_clicked_cb(self, button, state, status, tool): + state['tool'] = tool + self._refresh_paired_preview_status(status, state) + + def __paired_preview_mark_clicked_cb(self, button, state, status): + if state.get('active') == 'A': + state['marks_a'] = state.get('marks_a', 0) + 1 + state['active'] = 'B' + else: + state['marks_b'] = state.get('marks_b', 0) + 1 + state['active'] = 'A' + self._refresh_paired_preview_status(status, state) + + def __paired_preview_clear_clicked_cb(self, button, state, status): + state['marks_a'] = 0 + state['marks_b'] = 0 + state['active'] = 'A' + self._refresh_paired_preview_status(status, state) + + def _refresh_paired_preview_status(self, status, state): + active = _('Student A') if state.get('active') == 'A' else \ + _('Student B') + status.set_text( + _('Active: %(active)s\nTool: %(tool)s\nMarks: A=%(a)d B=%(b)d\n' + 'Partners take turns, then explain the pattern or rule.') % { + 'active': active, + 'tool': state.get('tool', _('Free Draw')), + 'a': state.get('marks_a', 0), + 'b': state.get('marks_b', 0), + } + ) + + def __paired_canvas_draw_cb(self, widget, context, state): + allocation = widget.get_allocation() + width = allocation.width + height = allocation.height + context.set_source_rgb(1, 1, 1) + context.rectangle(0, 0, width, height) + context.fill() + + context.set_line_width(1) + context.set_source_rgb(0.88, 0.88, 0.88) + step = style.zoom(28) + for x in range(0, max(step, width), step): + context.move_to(x, 0) + context.line_to(x, height) + for y in range(0, max(step, height), step): + context.move_to(0, y) + context.line_to(width, y) + context.stroke() + + context.set_line_width(2) + context.set_source_rgb(0.70, 0.70, 0.70) + context.move_to(width / 2.0, 0) + context.line_to(width / 2.0, height) + context.move_to(0, height / 2.0) + context.line_to(width, height / 2.0) + context.stroke() + + context.set_line_width(5) + context.set_source_rgb(0.12, 0.45, 0.78) + context.move_to(width * 0.14, height * 0.70) + context.curve_to(width * 0.26, height * 0.22, + width * 0.44, height * 0.28, + width * 0.54, height * 0.56) + context.stroke() + + context.set_source_rgb(0.95, 0.50, 0.08) + context.rectangle(width * 0.62, height * 0.22, + width * 0.19, height * 0.23) + context.stroke() + context.arc(width * 0.72, height * 0.68, + min(width, height) * 0.09, 0, 2 * math.pi) + context.stroke() + + context.set_font_size(12) + context.set_source_rgb(0.12, 0.45, 0.78) + context.move_to(width * 0.14, height * 0.78) + context.show_text('Student A') + context.set_source_rgb(0.95, 0.50, 0.08) + context.move_to(width * 0.62, height * 0.18) + context.show_text('Student B') + return False + + def __generated_canvas_draw_cb(self, widget, context): + allocation = widget.get_allocation() + width = allocation.width + height = allocation.height + context.set_source_rgb(1, 1, 1) + context.rectangle(0, 0, width, height) + context.fill() + context.set_source_rgb(0.88, 0.88, 0.88) + for x in range(24, max(24, width), 32): + context.move_to(x, 0) + context.line_to(x, height) + for y in range(24, max(24, height), 32): + context.move_to(0, y) + context.line_to(width, y) + context.stroke() + context.set_source_rgb(0.18, 0.32, 0.62) + context.set_line_width(4) + context.move_to(width * 0.18, height * 0.62) + context.curve_to(width * 0.34, height * 0.20, + width * 0.58, height * 0.78, + width * 0.82, height * 0.34) + context.stroke() + return False + + def __generated_chess_square_clicked_cb(self, button, status, state, + buttons, log, row, col): + board = state['board'] + piece = board[row][col] + turn = state['turn'] + selected = state['selected'] + square = self._generated_chess_square_name(row, col) + self._set_live_edit_target(_('chess board square %s') % square) + + if selected is None: + if not piece: + status.set_text( + _('Choose a %s piece first.') % turn.capitalize()) + return + if self._generated_chess_piece_color(piece) != turn: + status.set_text( + _('%s to move. Pick a %s piece.') % + (turn.capitalize(), turn)) + return + state['selected'] = (row, col) + status.set_text( + _('Selected %s on %s. Choose a destination.') % + (self._generated_chess_piece_name(piece), square)) + self._refresh_generated_chess_preview( + status, state, buttons, log) + return + + start_row, start_col = selected + moving = board[start_row][start_col] + if (row, col) == selected: + state['selected'] = None + status.set_text(_('Selection cleared.')) + self._refresh_generated_chess_preview( + status, state, buttons, log) + return + + if piece and self._generated_chess_piece_color(piece) == turn: + state['selected'] = (row, col) + status.set_text( + _('Selected %s on %s. Choose a destination.') % + (self._generated_chess_piece_name(piece), square)) + self._refresh_generated_chess_preview( + status, state, buttons, log) + return + + if not self._generated_chess_can_move( + board, moving, start_row, start_col, row, col): + status.set_text( + _('%s cannot move to %s. Try a legal chess move.') % + (self._generated_chess_piece_name(moving), square)) + return + + capture = piece + board[row][col] = moving + board[start_row][start_col] = '' + start_square = self._generated_chess_square_name(start_row, start_col) + move_text = _('%s %s to %s') % ( + self._generated_chess_piece_name(moving), + start_square, + square, + ) + if capture: + move_text = _('%s captures %s') % ( + move_text, self._generated_chess_piece_name(capture)) + if state.get('show_move_log', True): + state['moves'].append(move_text) + state['turn'] = 'black' if turn == 'white' else 'white' + state['selected'] = None + status.set_text( + _('%s. %s to move.') % + (move_text, state['turn'].capitalize())) + self._refresh_generated_chess_preview(status, state, buttons, log) + + def __generated_chess_reset_clicked_cb(self, button, status, state, + buttons, log, pieces): + self._set_live_edit_target(_('reset control')) + state['board'] = [row[:] for row in pieces] + state['turn'] = 'white' + state['selected'] = None + state['moves'] = [] + status.set_text(_('Board reset. White to move.')) + self._refresh_generated_chess_preview(status, state, buttons, log) + + def __generated_grid_tile_clicked_cb(self, button, index): + self._set_live_edit_target( + _('grid tile %d') % (index + 1)) + + def __generated_carrom_board_press_cb(self, widget, event, state, + status, score, log): + left, top, size = self._generated_square_geometry(widget) + if event.x < left or event.y < top or \ + event.x > left + size or event.y > top + size: + return False + state['aim'] = [ + (event.x - left) / float(size), + (event.y - top) / float(size), + ] + self._set_live_edit_target(_('carrom striker aim marker')) + status.set_text( + _('%s set the striker aim. Record the shot result.') % + self._generated_carrom_player_name(state)) + self._refresh_generated_carrom_preview( + status, score, log, widget, state) + return True + + def __generated_carrom_action_clicked_cb(self, button, action, state, + status, score, log, drawing): + player = state['turn'] + player_name = self._generated_carrom_player_name(state) + if action in ('white', 'black', 'queen'): + coins = state['coins'] + if coins[action] <= 0: + status.set_text(_('No %s coins remain.') % action) + else: + coins[action] -= 1 + points = 3 if action == 'queen' else 1 + state['scores'][player] += points + state['log'].append( + _('%s pocketed %s for %d point%s.') % ( + player_name, + action, + points, + '' if points == 1 else 's', + )) + status.set_text( + _('%s scored. Switch turns when ready.') % player_name) + self._set_live_edit_target(_('carrom pocket controls')) + elif action == 'foul': + state['fouls'][player] += 1 + if state['scores'][player] > 0: + state['scores'][player] -= 1 + state['log'].append(_('%s made a foul.') % player_name) + status.set_text(_('Foul recorded. Switch turns.')) + self._set_live_edit_target(_('carrom foul control')) + elif action == 'switch': + state['turn'] = 'B' if player == 'A' else 'A' + status.set_text( + _('%s to shoot next.') % + self._generated_carrom_player_name(state)) + self._set_live_edit_target(_('carrom turn control')) + elif action == 'reset': + state['turn'] = 'A' + state['scores'] = {'A': 0, 'B': 0} + state['fouls'] = {'A': 0, 'B': 0} + state['coins'] = {'white': 9, 'black': 9, 'queen': 1} + state['aim'] = [0.5, 0.82] + state['log'] = [] + status.set_text(_('New carrom match ready.')) + self._set_live_edit_target(_('carrom reset control')) + state['log'] = state['log'][-8:] + self._refresh_generated_carrom_preview( + status, score, log, drawing, state) + + def __generated_counter_clicked_cb(self, button, label, state, amount): + state['count'] += amount + label.set_text(str(state['count'])) + self._set_live_edit_target(_('counter controls')) + + def __generated_counter_reset_clicked_cb(self, button, label, state): + state['count'] = 0 + label.set_text('0') + self._set_live_edit_target(_('counter reset control')) + + def __generated_carrom_draw_cb(self, widget, context, state): + left, top, size = self._generated_square_geometry(widget) + context.set_source_rgb(0.92, 0.80, 0.58) + context.rectangle(left, top, size, size) + context.fill() + + border = max(style.zoom(7), size * 0.035) + context.set_source_rgb(0.44, 0.22, 0.10) + context.set_line_width(border) + context.rectangle(left + border / 2.0, top + border / 2.0, + size - border, size - border) + context.stroke() + + context.set_source_rgb(0.64, 0.35, 0.16) + context.set_line_width(max(2, size * 0.006)) + context.rectangle(left + size * 0.12, top + size * 0.12, + size * 0.76, size * 0.76) + context.stroke() + + for nx, ny in ((0.08, 0.08), (0.92, 0.08), + (0.08, 0.92), (0.92, 0.92)): + self._draw_generated_disc( + context, left + nx * size, top + ny * size, + size * 0.052, (0.05, 0.05, 0.05), (0.35, 0.18, 0.08)) + + context.set_source_rgb(0.52, 0.22, 0.12) + context.arc(left + size * 0.5, top + size * 0.5, + size * 0.16, 0, 2 * math.pi) + context.stroke() + + self._draw_generated_carrom_coins(context, left, top, size, state) + + aim_x = left + state['aim'][0] * size + aim_y = top + state['aim'][1] * size + context.set_source_rgb(0.20, 0.35, 0.75) + context.set_line_width(max(2, size * 0.006)) + context.move_to(aim_x, aim_y) + context.line_to(left + size * 0.5, top + size * 0.5) + context.stroke() + self._draw_generated_disc( + context, aim_x, aim_y, size * 0.04, + (0.94, 0.94, 0.98), (0.20, 0.35, 0.75)) + return False + + def _draw_generated_carrom_coins(self, context, left, top, size, state): + positions = ( + (0.00, -0.09), (0.08, -0.04), (0.08, 0.05), + (0.00, 0.10), (-0.08, 0.05), (-0.08, -0.04), + (0.15, 0.00), (-0.15, 0.00), (0.00, 0.18), + (0.00, -0.18), (0.14, 0.12), (-0.14, 0.12), + (0.14, -0.12), (-0.14, -0.12), (0.21, 0.08), + (-0.21, 0.08), (0.21, -0.08), (-0.21, -0.08), + ) + radius = size * 0.028 + index = 0 + for count, fill, stroke in ( + (state['coins']['white'], + (0.96, 0.94, 0.86), (0.55, 0.48, 0.38)), + (state['coins']['black'], + (0.08, 0.08, 0.08), (0.35, 0.35, 0.35))): + for unused in range(count): + dx, dy = positions[index % len(positions)] + self._draw_generated_disc( + context, left + size * (0.5 + dx), + top + size * (0.5 + dy), radius, fill, stroke) + index += 1 + if state['coins']['queen']: + self._draw_generated_disc( + context, left + size * 0.5, top + size * 0.5, + radius * 1.05, (0.72, 0.05, 0.08), + (0.40, 0.02, 0.04)) + + def _draw_generated_disc(self, context, x, y, radius, fill, stroke): + context.set_source_rgb(fill[0], fill[1], fill[2]) + context.arc(x, y, radius, 0, 2 * math.pi) + context.fill_preserve() + context.set_source_rgb(stroke[0], stroke[1], stroke[2]) + context.set_line_width(max(1, radius * 0.16)) + context.stroke() + + def _generated_square_geometry(self, widget): + allocation = widget.get_allocation() + size = min(allocation.width, allocation.height) - style.zoom(18) + if size <= 0: + size = min(allocation.width, allocation.height) + left = (allocation.width - size) / 2.0 + top = (allocation.height - size) / 2.0 + return left, top, size + + def _generated_carrom_player_name(self, state): + return _('Student A') if state['turn'] == 'A' else _('Student B') + + def _refresh_generated_carrom_preview(self, status, score, log, drawing, + state): + score.set_text( + _('Score - Student A: %(a)d Student B: %(b)d\n' + 'Fouls - Student A: %(fa)d Student B: %(fb)d\n' + 'Coins left - white: %(white)d black: %(black)d ' + 'queen: %(queen)d\n' + 'Aim marker: %(x).0f%% across, %(y).0f%% down') % { + 'a': state['scores']['A'], + 'b': state['scores']['B'], + 'fa': state['fouls']['A'], + 'fb': state['fouls']['B'], + 'white': state['coins']['white'], + 'black': state['coins']['black'], + 'queen': state['coins']['queen'], + 'x': state['aim'][0] * 100, + 'y': state['aim'][1] * 100, + }) + moves = state['log'] + if moves: + log.set_text('\n'.join( + '%d. %s' % (index + 1, move) + for index, move in enumerate(moves[-8:]) + )) + else: + log.set_text( + _('1. Student A chooses an aim point\n' + '2. Record pocket, queen, or foul\n' + '3. Switch to Student B\n' + '4. Save the match in the Journal')) + drawing.queue_draw() + + def _refresh_generated_chess_preview(self, status, state, buttons, log): + board = state['board'] + selected = state['selected'] + for row in range(8): + for col in range(8): + button = buttons[row][col] + button.set_label(board[row][col] or ' ') + context = button.get_style_context() + if selected == (row, col): + context.add_class('create-ai-generated-chess-selected') + else: + context.remove_class('create-ai-generated-chess-selected') + square = self._generated_chess_square_name(row, col) + piece = board[row][col] + tooltip = self._generated_chess_piece_name(piece) + button.set_tooltip_text('%s %s' % (square, tooltip)) + + if log is None: + return + + moves = state['moves'] + if moves: + first = max(0, len(moves) - 8) + log.set_text('\n'.join( + '%d. %s' % (index + 1, move) + for index, move in enumerate(moves[first:], first) + )) + else: + log.set_text( + _('1. White makes a legal move\n' + '2. Black answers\n' + '3. Explain the move idea\n' + '4. Install and open when the preview feels ready')) + + def _generated_chess_square_name(self, row, col): + return '%s%d' % ('abcdefgh'[col], 8 - row) + + def _generated_chess_piece_color(self, piece): + if piece in '♙♖♘♗♕♔': + return 'white' + if piece in '♟♜♞♝♛♚': + return 'black' + return '' + + def _generated_chess_piece_name(self, piece): + names = { + '♔': _('White king'), + '♕': _('White queen'), + '♖': _('White rook'), + '♗': _('White bishop'), + '♘': _('White knight'), + '♙': _('White pawn'), + '♚': _('Black king'), + '♛': _('Black queen'), + '♜': _('Black rook'), + '♝': _('Black bishop'), + '♞': _('Black knight'), + '♟': _('Black pawn'), + } + return names.get(piece, _('empty square')) + + def _generated_chess_piece_kind(self, piece): + kinds = { + '♔': 'king', '♚': 'king', + '♕': 'queen', '♛': 'queen', + '♖': 'rook', '♜': 'rook', + '♗': 'bishop', '♝': 'bishop', + '♘': 'knight', '♞': 'knight', + '♙': 'pawn', '♟': 'pawn', + } + return kinds.get(piece, '') + + def _generated_chess_can_move(self, board, piece, start_row, start_col, + row, col): + if not piece: + return False + color = self._generated_chess_piece_color(piece) + target = board[row][col] + if target and self._generated_chess_piece_color(target) == color: + return False + + dr = row - start_row + dc = col - start_col + abs_dr = abs(dr) + abs_dc = abs(dc) + kind = self._generated_chess_piece_kind(piece) + + if kind == 'pawn': + direction = -1 if color == 'white' else 1 + home_row = 6 if color == 'white' else 1 + if dc == 0 and not target: + if dr == direction: + return True + if start_row == home_row and dr == 2 * direction: + return not board[start_row + direction][start_col] + return abs_dc == 1 and dr == direction and bool(target) + if kind == 'knight': + return (abs_dr, abs_dc) in ((1, 2), (2, 1)) + if kind == 'king': + return max(abs_dr, abs_dc) == 1 + if kind == 'bishop': + return abs_dr == abs_dc and self._generated_chess_path_clear( + board, start_row, start_col, row, col) + if kind == 'rook': + return (dr == 0 or dc == 0) and \ + self._generated_chess_path_clear( + board, start_row, start_col, row, col) + if kind == 'queen': + diagonal = abs_dr == abs_dc + straight = dr == 0 or dc == 0 + return (diagonal or straight) and \ + self._generated_chess_path_clear( + board, start_row, start_col, row, col) + return False + + def _generated_chess_path_clear(self, board, start_row, start_col, + row, col): + step_row = self._generated_chess_step(row - start_row) + step_col = self._generated_chess_step(col - start_col) + current_row = start_row + step_row + current_col = start_col + step_col + while (current_row, current_col) != (row, col): + if board[current_row][current_col]: + return False + current_row += step_row + current_col += step_col + return True + + def _generated_chess_step(self, value): + if value < 0: + return -1 + if value > 0: + return 1 + return 0 + + def _set_generation_step_active(self, active_index): + for index, step in enumerate(self._preview_generation_steps): + context = step.get_style_context() + if index <= active_index: + context.add_class('create-ai-generation-step-active') + else: + context.remove_class('create-ai-generation-step-active') + for index, row in enumerate( + self._preview_generation_step_boxes): + context = row.get_style_context() + if index == active_index: + context.add_class('create-ai-generation-step-row-active') + else: + context.remove_class( + 'create-ai-generation-step-row-active') + + def _generation_step_index_for_stage(self, stage): + stage_indexes = { + 'queued': 0, + 'planning': 0, + 'provider': 0, + 'grounding': 1, + 'generating': 2, + 'validating': 3, + 'packaging': 4, + 'finished': 4, + } + return stage_indexes.get(stage, 0) + + def __expand_button_clicked_cb(self, button): + window = self.get_toplevel() + if not isinstance(window, Gtk.Window): + return + + if self._is_fullscreen: + window.unfullscreen() + self._is_fullscreen = False + else: + window.fullscreen() + window.present() + self._is_fullscreen = True + + def __option_card_clicked_cb(self, clicked_button, group_name, value): + for button in self._option_buttons[group_name]: + button.get_style_context().remove_class( + 'create-ai-option-card-active') + clicked_button.get_style_context().add_class( + 'create-ai-option-card-active') + self._selected_options[group_name] = value + + if group_name == 'template': + self._update_template_hint() + elif group_name in ('planner', 'policy'): + self._update_planner_hint() + elif group_name == 'license': + self._update_license_hint() + self._refresh_generated_context() + + status = { + 'make': _('Make'), + 'play': _('Play'), + 'share': _('Share'), + 'logic_math': _('Logic & math'), + 'tools_utils': _('Tools/utilities'), + 'games': _('Games'), + 'creation': _('Creation'), + 'default': _('Default'), + 'rag': _('RAG'), + 'validate': _('Validate'), + 'standard': _('Standard'), + 'local': _('Local'), + 'strict': _('Strict'), + } + if self._prompt_status_label is not None: + if group_name == 'license': + self._prompt_status_label.set_text( + self._get_selected_license()['label']) + else: + self._prompt_status_label.set_text(status[value]) + + def __provider_combo_changed_cb(self, combo): + provider_name = combo.get_active_id() or 'default' + if provider_name == self._selected_options['provider']: + return + + self._selected_options['provider'] = provider_name + self._provider_key_entry.set_text('') + self._provider_model_entry.set_text('') + self._provider_endpoint_entry.set_text('') + self._update_provider_controls() + self._update_planner_hint() + + def __provider_apply_clicked_cb(self, button): + provider = self._configure_selected_provider(persist=True) + if provider is not True and provider: + if self._provider_status_label is not None: + self._provider_status_label.set_text( + _('Provider saved. Ready to generate activities.')) + if self._prompt_status_label is not None: + self._prompt_status_label.set_text(_('Ready to generate')) + + def __provider_paste_clicked_cb(self, button): + self._paste_provider_key_from_clipboard() + + def __provider_key_entry_key_press_event_cb(self, entry, event): + modifiers = Gtk.accelerator_get_default_mod_mask() + state = event.state & modifiers + ctrl = state & Gdk.ModifierType.CONTROL_MASK + shift = state & Gdk.ModifierType.SHIFT_MASK + if ctrl and event.keyval in (Gdk.KEY_v, Gdk.KEY_V): + if not entry.get_sensitive(): + if self._provider_status_label is not None: + self._provider_status_label.set_text( + _('Choose a cloud provider before pasting a key.')) + return True + if self._provider_status_label is not None: + self._provider_status_label.set_text(_('Pasting API key...')) + GObject.idle_add(self.__provider_key_entry_paste_finished_cb) + return False + if shift and event.keyval == Gdk.KEY_Insert: + if self._provider_status_label is not None: + self._provider_status_label.set_text(_('Pasting API key...')) + GObject.idle_add(self.__provider_key_entry_paste_finished_cb) + return False + return False + + def __provider_remove_clicked_cb(self, button): + provider_name = self._selected_options['provider'] + if provider_name not in ( + 'freemodel', 'gemini', 'openai', 'deepseek', 'qwen', 'moonshot', + 'opencode', 'opencode-go', 'claude'): + return + + from jarabe.model.aodservice import get_service + + try: + get_service().remove_provider_api_key(provider_name) + except Exception as error: + logging.exception('Could not remove saved provider key') + self._provider_status_label.set_text(str(error)) + return + self._provider_key_entry.set_text('') + self._update_provider_controls() + self._provider_status_label.set_text( + _('%s API key removed.') % + self._get_provider_label(provider_name)) + + def __guided_button_toggled_cb(self, button): + if button.get_active(): + button.get_style_context().add_class('create-ai-pill-active') + if self._prompt_status_label is not None: + self._prompt_status_label.set_text(_('Guided')) + else: + button.get_style_context().remove_class('create-ai-pill-active') + if self._prompt_status_label is not None: + self._prompt_status_label.set_text(_('Thinking')) + + def _select_studio_tab(self, tab_name): + if self._studio_mode_stack is not None: + self._studio_mode_stack.set_visible_child_name(tab_name) + + if self._studio_preview_tab is not None: + self._studio_preview_tab.get_style_context().remove_class( + 'create-ai-studio-tab-active') + if self._studio_review_tab is not None: + self._studio_review_tab.get_style_context().remove_class( + 'create-ai-studio-tab-active') + if self._studio_versions_tab is not None: + self._studio_versions_tab.get_style_context().remove_class( + 'create-ai-studio-tab-active') + + if tab_name == 'review' and self._studio_review_tab is not None: + self._studio_review_tab.get_style_context().add_class( + 'create-ai-studio-tab-active') + elif tab_name == 'versions' and self._studio_versions_tab is not None: + self._studio_versions_tab.get_style_context().add_class( + 'create-ai-studio-tab-active') + elif self._studio_preview_tab is not None: + self._studio_preview_tab.get_style_context().add_class( + 'create-ai-studio-tab-active') + + def __studio_tab_clicked_cb(self, button, tab_name): + self._select_studio_tab(tab_name) + + def __review_file_clicked_cb(self, button, file_key): + self._set_review_file(file_key) + + def __version_switch_clicked_cb(self, button, mode): + self._set_versions_mode(mode) + + def __version_card_button_release_cb(self, card, event, version_key): + if event.button != 1: + return False + + self._selected_version = version_key + self._set_versions_mode('source') + return False + + def __prompt_buffer_changed_cb(self, text_buffer): + if self._prompt_char_label is None: + return + + start, end = text_buffer.get_bounds() + count = len(text_buffer.get_text(start, end, True)) + self._prompt_char_label.set_text(_('%d chars') % count) + + def __prompt_entry_activate_cb(self, entry): + self.__send_button_clicked_cb(entry) + + def __prompt_button_press_event_cb(self, text_view, event): + self._clear_prompt_placeholder() + text_view.grab_focus() + return False + + def __prompt_key_press_event_cb(self, text_view, event): + self._clear_prompt_placeholder() + return False + + def __prompt_focus_in_event_cb(self, text_view, event): + self._clear_prompt_placeholder() + return False + + def __prompt_focus_out_event_cb(self, text_view, event): + if not self._get_prompt_text(): + self._set_prompt_placeholder() + return False + + def __prompt_example_clicked_cb(self, button): + self._set_prompt_text( + _('Treasure-map quest where teams solve clues and explain each ' + 'step.')) + self._prompt_text.grab_focus() + if self._prompt_status_label is not None: + self._prompt_status_label.set_text(_('Ready')) + + def __send_button_clicked_cb(self, button): + prompt = self._get_prompt_text() + if not prompt: + self._clear_prompt_placeholder() + self._prompt_text.grab_focus() + if self._prompt_status_label is not None: + self._prompt_status_label.set_text(_('Need prompt')) + return + + self._submit_generation_from_prompt(prompt, chat_prompt=prompt) + + def _submit_generation_from_prompt(self, prompt, chat_prompt=None): + from jarabe.model.aodspec import ActivitySpec + from jarabe.model.aodspec import name_from_prompt + + license_info = self._get_selected_license() + spec = ActivitySpec( + name=name_from_prompt(prompt), + prompt=prompt, + category=self._selected_options['template'], + license_id=license_info['spdx'], + ) + self._submit_generation_spec( + spec, + chat_prompt=chat_prompt or prompt, + display_prompt=prompt, + is_refinement=False, + ) + + def _submit_refinement_from_prompt(self, refinement, source='chat'): + if self._generation_result is None: + self._submit_generation_from_prompt( + refinement, + chat_prompt=refinement, + ) + return + + target = self._live_edit_target or _('activity canvas') + backend_refinement = refinement + display_refinement = refinement + if source == 'preview': + backend_refinement = ( + 'Target: %(target)s\nChange: %(change)s' + ) % { + 'target': target, + 'change': refinement, + } + display_refinement = _('%(target)s: %(change)s') % { + 'target': target, + 'change': refinement, + } + if source == 'preview' and self._live_edit_status_label is not None: + self._live_edit_status_label.set_text(_('Refining preview...')) + if source == 'sidebar' and \ + self._sidebar_refine_status_label is not None: + self._sidebar_refine_status_label.set_text(_('Refining...')) + spec = self._build_refinement_spec(backend_refinement) + self._submit_generation_spec( + spec, + chat_prompt=display_refinement, + display_prompt=_('Refine: %s') % display_refinement, + is_refinement=True, + ) + + def _build_refinement_spec(self, refinement): + from jarabe.model.aodspec import ActivitySpec + + result = self._generation_result + base_spec = result.spec.normalized() + plan = result.plan if isinstance(result.plan, dict) else {} + flow = plan.get('classroom_flow') or plan.get('learner_steps') or [] + flow_text = '\n'.join('- %s' % step for step in flow[:5]) + plan_context = self._compact_plan_for_refinement(plan) + source_context = self._source_context_for_refinement(result) + prompt = ( + 'Refine the existing generated Sugar activity. Preserve working ' + 'behavior unless the new request changes it.\n\n' + 'Original learner request:\n%(original)s\n\n' + 'Current generated activity:\n' + '- Name: %(name)s\n' + '- Template: %(template)s\n' + '- Summary: %(summary)s\n' + '- Classroom flow:\n%(flow)s\n\n' + 'Current plan JSON excerpt:\n%(plan_context)s\n\n' + 'Current activity.py excerpt:\n%(source_context)s\n\n' + 'Refinement request:\n%(refinement)s' + ) % { + 'original': base_spec.prompt, + 'name': base_spec.name, + 'template': plan.get('template', base_spec.template), + 'summary': plan.get('summary', ''), + 'flow': flow_text or '- Keep the activity usable for learners.', + 'plan_context': plan_context, + 'source_context': source_context, + 'refinement': refinement, + } + prompt = self._limit_refinement_prompt(prompt) + template = plan.get('template', base_spec.template) + return ActivitySpec( + name=base_spec.name, + prompt=prompt, + category=base_spec.category, + license_id=base_spec.license_id, + template=template, + age_band=base_spec.age_band, + learner_goal=base_spec.learner_goal or + plan.get('learner_goal', ''), + ) + + def _compact_plan_for_refinement(self, plan): + if not isinstance(plan, dict): + return '{}' + keys = ( + 'template', + 'activity_kind', + 'summary', + 'learner_goal', + 'learner_steps', + 'interaction_model', + 'ui_regions', + 'state_schema', + 'features', + 'classroom_flow', + ) + compact = { + key: plan[key] + for key in keys + if key in plan + } + text = json.dumps(compact, indent=2, sort_keys=True) + if len(text) <= 2200: + return text + return text[:2100].rstrip() + '\n...' + + def _source_context_for_refinement(self, result): + files = getattr(result, 'files', {}) + if not isinstance(files, dict): + return '# Current source is unavailable.' + source = files.get('activity.py', '').strip() + if not source: + return '# Current source is unavailable.' + return self._compact_source_for_refinement(source) + + def _compact_source_for_refinement(self, source): + if len(source) <= 6500: + return source + head = source[:4200].rstrip() + tail = source[-2200:].lstrip() + return '%s\n\n# ... current source shortened ...\n\n%s' % ( + head, + tail, + ) + + def _limit_refinement_prompt(self, prompt): + if len(prompt) <= 11500: + return prompt + keep_head = prompt[:5600].rstrip() + keep_tail = prompt[-5600:].lstrip() + return '%s\n\n[Previous context shortened]\n\n%s' % ( + keep_head, keep_tail) + + def _submit_generation_spec(self, spec, chat_prompt=None, + display_prompt=None, + is_refinement=False): + if self._has_active_generation_job(): + if self._prompt_status_label is not None: + self._prompt_status_label.set_text(_('Already generating')) + if self._sidebar_refine_status_label is not None: + self._sidebar_refine_status_label.set_text( + _('Wait for the current generation to finish.')) + self._append_chat_message( + _('Please wait for the current activity generation to finish.')) + return + + from jarabe.model.aodservice import get_service + + license_info = self._get_selected_license() + planner = self._selected_options['planner'] + policy = self._selected_options['policy'] + service = get_service() + provider_name = self._resolve_generation_provider_name(service) + selected_provider = self._selected_options['provider'] + if provider_name == selected_provider and provider_name in ( + 'freemodel', 'gemini', 'openai', 'deepseek', 'qwen', 'moonshot', + 'opencode', 'opencode-go', 'claude', 'ollama'): + if not self._configure_selected_provider(persist=True): + return + + if self._prompt_status_label is not None: + if is_refinement: + self._prompt_status_label.set_text(_('Refining')) + else: + self._prompt_status_label.set_text(_('Planning')) + if self._sidebar_refine_status_label is not None: + if is_refinement: + self._sidebar_refine_status_label.set_text(_('Refining...')) + else: + self._sidebar_refine_status_label.set_text( + _('Generating activity...')) + use_rag = policy not in ('local', 'strict') + + self._detach_generation_job() + self._generation_result = None + if not is_refinement: + self._aod_session_id = '' + self._aod_active_revision_id = '' + if display_prompt is None: + display_prompt = spec.prompt + self._set_prompt_text(spec.prompt) + self._set_studio_prompt(display_prompt) + self._update_preview_license_summary() + self._append_chat_message(chat_prompt or display_prompt, + from_user=True) + self._append_sidebar_message(chat_prompt or display_prompt, + from_user=True) + if is_refinement: + self._append_chat_status(_('Refining selected activity')) + self._append_sidebar_status(_('Refining selected activity')) + else: + self._append_chat_status(_('Generating activity')) + self._append_sidebar_status(_('Generating activity')) + self._append_chat_status( + _('Planner: %s · %s') % + (self._get_provider_label(provider_name), + license_info['label'])) + self._append_sidebar_status( + _('Planner: %s · %s') % + (self._get_provider_label(provider_name), + license_info['label'])) + self._review_generation_context = { + 'provider': self._get_provider_label(provider_name), + 'stage': 'queued', + 'progress': 0.0, + 'message': _('Queued generation request'), + 'prompt': chat_prompt or display_prompt, + 'is_refinement': is_refinement, + 'draft_activity_source': '', + } + self._review_draft_was_shown = False + self._set_review_file(self._current_review_file) + self._use_studio_layout() + self._select_studio_tab('preview') + self._stack.set_visible_child_name('studio') + if self._preview_empty_title is not None: + if is_refinement: + self._preview_empty_title.set_text(_('Refining activity')) + else: + self._preview_empty_title.set_text(_('Generating activity')) + if is_refinement: + self._start_generation_animation( + _('Applying the refinement and preparing files...')) + else: + self._start_generation_animation( + _('Planning the activity and preparing files...')) + + if self._provider_status_label is not None: + self._provider_status_label.set_text( + _('Generating with %s...') % + self._get_provider_label(provider_name)) + if self._prompt_status_label is not None: + if is_refinement: + self._prompt_status_label.set_text(_('Refining')) + else: + self._prompt_status_label.set_text(_('Generating')) + + try: + job = service.submit_activity( + spec, + provider_name=provider_name, + use_rag=use_rag, + session_id=self._aod_session_id if is_refinement else '', + parent_revision_id=( + self._aod_active_revision_id if is_refinement else ''), + user_prompt=chat_prompt or display_prompt, + ) + except Exception as error: + logging.exception('Could not submit Activity on Demand job') + self._generation_failed_cb(str(error)) + return + + self._generation_job_id = job.job_id + self._aod_session_id = job.session_id + service.watch(job.job_id, self._generation_job_callback) + self._generation_job_updated_from_worker(job) + + def _has_active_generation_job(self): + if self._generation_job_id is None: + return False + + from jarabe.model.aodservice import get_service + + job = get_service().get_job(self._generation_job_id) + return job is not None and not job.is_terminal() + + def _generation_job_updated_from_worker(self, job): + GObject.idle_add(self._generation_job_updated_cb, job.job_id) + + def _generation_job_updated_cb(self, job_id): + if job_id != self._generation_job_id: + return False + + from jarabe.model.aodjobs import STATUS_CANCELLED + from jarabe.model.aodjobs import STATUS_FAILED + from jarabe.model.aodjobs import STATUS_FINISHED + from jarabe.model.aodservice import get_service + + job = get_service().get_job(job_id) + if job is None: + return False + + if job.status == STATUS_FINISHED: + if job.result is not None: + self._aod_session_id = job.session_id + self._aod_active_revision_id = job.result_summary.get( + 'revision_id', '') + self._generation_finished_cb(job.result) + return False + + if job.status == STATUS_FAILED: + self._generation_failed_cb(job.error or job.message, job=job) + return False + + if job.status == STATUS_CANCELLED: + self._generation_failed_cb(_('Generation cancelled')) + return False + + self._generation_progress_cb( + job.stage, + job.progress, + job.message, + job, + ) + return False + + def _generation_progress_cb(self, stage, fraction, message, job=None): + if self._prompt_status_label is not None: + self._prompt_status_label.set_text(message) + self._update_live_review_generation(stage, fraction, message, job) + self._update_provider_call_status(stage, fraction, message) + self._update_generation_animation(stage, fraction, message) + if self._preview_empty_title is not None: + if self._preview_empty_title.get_text() != _('Building your activity'): + self._preview_empty_title.set_text(_('Building your activity')) + if self._preview_empty_note is not None: + self._preview_empty_note.set_text( + _('%(stage)s - %(percent)d%%') % { + 'stage': message, + 'percent': int(fraction * 100), + } + ) + return False + + def _update_live_review_generation(self, stage, fraction, message, + job=None): + if not self._review_generation_context: + return + self._review_generation_context['stage'] = stage + self._review_generation_context['progress'] = max( + 0.0, + min(1.0, float(fraction)), + ) + self._review_generation_context['message'] = message + draft_source = getattr(job, 'draft_activity_source', '') if job else '' + if draft_source: + self._review_generation_context['draft_activity_source'] = \ + draft_source + if not self._review_draft_was_shown: + self._review_draft_was_shown = True + self._select_studio_tab('review') + self._set_review_file(self._current_review_file) + + def _update_provider_call_status(self, stage, fraction, message): + if self._provider_status_label is None: + return + + provider = self._get_provider_label( + self._selected_options.get('provider', 'default')) + percent = int(fraction * 100) + if stage == 'grounding': + self._provider_status_label.set_text( + _('RAG selected Sugar examples for %(provider)s context - ' + '%(percent)d%%. No training or ingestion is happening.') % { + 'provider': provider, + 'percent': percent, + } + ) + elif stage == 'provider': + self._provider_status_label.set_text( + _('API call active: %(provider)s is planning from RAG context ' + '- ' + '%(percent)d%%. %(message)s') % { + 'provider': provider, + 'percent': percent, + 'message': message, + } + ) + elif stage == 'generating': + self._provider_status_label.set_text( + _('API/code step active: %(provider)s is preparing the ' + 'activity - %(percent)d%%. %(message)s') % { + 'provider': provider, + 'percent': percent, + 'message': message, + } + ) + elif stage == 'validating': + self._provider_status_label.set_text( + _('Model response received. Sugar is validating the activity ' + '- %(percent)d%%') % {'percent': percent} + ) + elif stage == 'packaging': + self._provider_status_label.set_text( + _('Validation passed. Sugar is packaging the XO - ' + '%(percent)d%%') % {'percent': percent} + ) + + def _generation_finished_cb(self, result): + self._detach_generation_job() + self._generation_result = result + self._review_generation_context = {} + self._review_draft_was_shown = False + self._complete_generation_animation(result) + plan = result.plan if isinstance(result.plan, dict) else {} + provider_status = self._generation_provider_status_text(result, plan) + if self._prompt_status_label is not None: + self._prompt_status_label.set_text(_('Ready')) + if self._provider_status_label is not None: + self._provider_status_label.set_text(provider_status) + if self._preview_empty_title is not None: + self._preview_empty_title.set_text(result.spec.name) + if self._preview_empty_note is not None: + provider = result.provider + if result.model: + provider = '%s / %s' % (provider, result.model) + self._preview_empty_note.set_text( + _('Reference family: %(template)s\nPlanner: %(provider)s\n' + '%(provider_status)s\n' + 'Project: %(project)s\n' + 'XO will be packaged when you export or install.') % { + 'template': plan.get('template', ''), + 'provider': provider, + 'provider_status': provider_status, + 'project': os.path.basename(result.project_path), + } + ) + if self._sidebar_refine_status_label is not None: + self._sidebar_refine_status_label.set_text( + _('Ready for another refinement.')) + self._set_review_file(self._current_review_file) + if self._aod_active_revision_id: + self._selected_version = self._aod_active_revision_id + self._refresh_version_history() + self._append_chat_status(provider_status) + self._append_chat_status( + _('Generated and validated. Select a part or type a refinement.')) + if self._chat_entry is not None: + self._chat_entry.set_placeholder_text( + _('Ask for a refinement...')) + self._append_sidebar_status(provider_status) + self._append_sidebar_message( + _('Generated and validated. Type another prompt here to refine ' + 'this activity.')) + return False + + def _generation_provider_status_text(self, result, plan): + fallback_reason = plan.get('provider_fallback_reason', '') + if fallback_reason: + return _('Provider did not answer: %s') % ( + self._short_provider_status(fallback_reason)) + + if result.provider == 'local': + return _('Provider: local activity builder') + + provider = self._get_provider_label(result.provider) + if result.model: + provider = '%s / %s' % (provider, result.model) + + if plan.get('code_source') == 'provider': + return _('Provider plan and code received: %s') % provider + + code_fallback_reason = plan.get('codegen_fallback_reason', '') + if code_fallback_reason: + return _( + 'Provider planned activity; code generation failed: %s' + ) % self._short_provider_status(code_fallback_reason) + + return _('Provider response received: %s') % provider + + def _short_provider_status(self, text): + text = ' '.join(str(text).split()) + if len(text) <= 140: + return text + return text[:137].rstrip() + '...' + + def _generation_failed_cb(self, error_text, job=None): + self._detach_generation_job() + display_error = _clean_generation_error_text(error_text) + draft_source = '' + if job is not None: + draft_source = getattr(job, 'draft_activity_source', '') or '' + if self._review_generation_context: + self._review_generation_context['stage'] = 'failed' + self._review_generation_context['progress'] = 1.0 + self._review_generation_context['message'] = display_error + if draft_source: + self._review_generation_context['draft_activity_source'] = \ + draft_source + self._stop_generation_animation() + if self._prompt_status_label is not None: + self._prompt_status_label.set_text(_('Generation failed')) + if self._provider_status_label is not None: + self._provider_status_label.set_text( + _('API/generation failed: %s') % + self._short_provider_status(display_error)) + if self._sidebar_refine_status_label is not None: + self._sidebar_refine_status_label.set_text( + _('Generation failed. Try a smaller refinement.')) + if self._preview_empty_title is not None: + self._preview_empty_title.set_text( + _('Could not generate activity')) + if self._preview_empty_note is not None: + self._preview_empty_note.set_text(display_error) + self._append_chat_status( + _('Generation failed: %s') % display_error) + self._append_sidebar_message( + _('Generation failed: %s') % display_error) + self._set_review_file(self._current_review_file) + if draft_source: + self._select_studio_tab('review') + return False + + def __review_and_install_cb(self, button): + self._select_studio_tab('review') + + def _ensure_generation_bundle(self): + if self._generation_result is None: + raise ValueError('Generate first') + + bundle_path = self._generation_result.bundle_path + if bundle_path and os.path.isfile(bundle_path): + return bundle_path + + if self._prompt_status_label is not None: + self._prompt_status_label.set_text(_('Packaging XO...')) + self._append_chat_status(_('Packaging XO bundle...')) + + from jarabe.model.aodpipeline import package_generation_result + + bundle_path = package_generation_result(self._generation_result) + self._append_chat_status(_('XO bundle packaged.')) + if self._provider_status_label is not None: + self._provider_status_label.set_text( + _('XO packaged for export or install.')) + return bundle_path + + def __export_xo_cb(self, button): + if self._generation_result is None: + if self._prompt_status_label is not None: + self._prompt_status_label.set_text(_('Generate first')) + return + + try: + bundle_path = self._ensure_generation_bundle() + except Exception as error: + logging.exception('Could not package generated XO bundle') + if self._prompt_status_label is not None: + self._prompt_status_label.set_text(_('Packaging failed')) + self._append_chat_message(_('Packaging failed: %s') % error) + return + + dialog = Gtk.FileChooserDialog( + title=_('Export XO bundle'), + parent=self.get_toplevel(), + action=Gtk.FileChooserAction.SAVE, + ) + dialog.add_buttons( + _('Cancel'), Gtk.ResponseType.CANCEL, + _('Export'), Gtk.ResponseType.ACCEPT, + ) + dialog.set_do_overwrite_confirmation(True) + dialog.set_current_name(os.path.basename(bundle_path)) + response = dialog.run() + destination = dialog.get_filename() + dialog.destroy() + + if response != Gtk.ResponseType.ACCEPT or not destination: + return + + try: + shutil.copy2(bundle_path, destination) + except OSError as error: + logging.exception('Could not export generated XO bundle') + if self._prompt_status_label is not None: + self._prompt_status_label.set_text(_('Export failed')) + self._append_chat_message(_('Export failed: %s') % error) + return + + if self._prompt_status_label is not None: + self._prompt_status_label.set_text(_('Exported')) + self._append_chat_message( + _('XO bundle exported to %s') % destination) + + def __install_and_open_cb(self, button): + if self._generation_result is None: + if self._prompt_status_label is not None: + self._prompt_status_label.set_text(_('Generate first')) + return + + from sugar3.bundle.activitybundle import ActivityBundle + from sugar3.bundle.helpers import bundle_from_archive + + from jarabe.journal import misc + from jarabe.model import bundleregistry + + try: + bundle_path = self._ensure_generation_bundle() + bundle = bundle_from_archive( + bundle_path, + mime_type=ActivityBundle.MIME_TYPE, + ) + if bundle is None: + raise ValueError('Sugar could not read the generated XO.') + registry = bundleregistry.get_registry() + registry.install(bundle, force_downgrade=True) + installed = registry.get_bundle( + self._generation_result.bundle_id) + if installed is None: + raise ValueError('Sugar did not register the activity.') + misc.launch(installed) + except Exception as error: + logging.exception('Could not install generated activity') + if self._prompt_status_label is not None: + self._prompt_status_label.set_text(_('Install failed')) + self._append_chat_message(_('Install failed: %s') % error) + return + + if self._prompt_status_label is not None: + self._prompt_status_label.set_text(_('Opening')) + + def __studio_back_cb(self, button): + self._use_centered_layout() + self._stack.set_visible_child_name('create') + + def __studio_rebuild_cb(self, button): + self.__send_button_clicked_cb(button) + + def __live_edit_entry_activate_cb(self, entry): + self.__live_edit_add_clicked_cb(entry) + + def __chat_entry_activate_cb(self, entry): + self.__chat_send_clicked_cb(entry) + + def __chat_send_clicked_cb(self, button): + if self._chat_entry is None: + return + + text = self._chat_entry.get_text().strip() + if not text: + self._chat_entry.grab_focus() + return + + self._chat_entry.set_text('') + if self._generation_result is not None: + self._submit_refinement_from_prompt(text, source='chat') + return + + current_prompt = self._get_prompt_text() + if current_prompt: + prompt = _('%(prompt)s\n\nRefinement: %(refinement)s') % { + 'prompt': current_prompt, + 'refinement': text, + } + else: + prompt = text + self._set_prompt_text(prompt) + self._submit_generation_from_prompt(prompt, chat_prompt=text) + + def __sidebar_refine_entry_activate_cb(self, entry): + self.__sidebar_refine_send_clicked_cb(entry) + + def __sidebar_refine_send_clicked_cb(self, button): + if self._sidebar_refine_entry is None: + return + + text = self._sidebar_refine_entry.get_text().strip() + if not text: + self._sidebar_refine_entry.grab_focus() + return + + self._sidebar_refine_entry.set_text('') + if self._generation_result is None: + if self._sidebar_refine_status_label is not None: + self._sidebar_refine_status_label.set_text( + _('Generate an activity first.')) + self._submit_generation_from_prompt(text, chat_prompt=text) + return + + self._submit_refinement_from_prompt(text, source='sidebar') + + def __live_toggle_clicked_cb(self, button, enabled): + self._live_edit_enabled = enabled + if self._live_edit_on_button is not None: + self._live_edit_on_button.get_style_context().remove_class( + 'create-ai-live-toggle-active') + if self._live_edit_off_button is not None: + self._live_edit_off_button.get_style_context().remove_class( + 'create-ai-live-toggle-active') + + if enabled and self._live_edit_on_button is not None: + self._live_edit_on_button.get_style_context().add_class( + 'create-ai-live-toggle-active') + elif self._live_edit_off_button is not None: + self._live_edit_off_button.get_style_context().add_class( + 'create-ai-live-toggle-active') + + if self._live_edit_entry is not None: + self._live_edit_entry.set_sensitive(enabled) + if self._live_edit_status_label is not None: + if enabled: + self._live_edit_status_label.set_text( + _('Ready for preview edits')) + else: + self._live_edit_status_label.set_text( + _('Live edit is paused')) + + def __live_edit_add_clicked_cb(self, button): + if self._live_edit_entry is None: + return + + if not self._live_edit_enabled: + if self._live_edit_status_label is not None: + self._live_edit_status_label.set_text( + _('Turn Live Edit on before adding changes.')) + return + + text = self._live_edit_entry.get_text().strip() + if not text: + self._live_edit_entry.grab_focus() + if self._live_edit_status_label is not None: + self._live_edit_status_label.set_text( + _('Describe the preview change first.')) + return + + self._live_edit_entry.set_text('') + if self._generation_result is None: + if self._live_edit_status_label is not None: + self._live_edit_status_label.set_text( + _('Generate an activity before preview refinements.')) + self._append_chat_message( + _('Generate an activity first, then describe refinements.')) + return + + self._submit_refinement_from_prompt(text, source='preview') + + def __stage_card_enter_notify_cb(self, card, event): + if event.mode == Gdk.CrossingMode.NORMAL: + card.get_style_context().add_class('create-ai-stage-card-hover') + return False + + def __stage_card_leave_notify_cb(self, card, event): + card.get_style_context().remove_class('create-ai-stage-card-hover') + return False + + def __stage_card_button_release_cb(self, card, event, callback): + if event.button != 1: + return False + alloc = card.get_allocation() + if 0 < event.x < alloc.width and 0 < event.y < alloc.height: + callback() + return False + + def __open_create_view(self): + self._use_centered_layout() + self._stack.set_visible_child_name('create') + self.focus_prompt() + + def __close_button_clicked_cb(self, button): + self.emit('close-requested') + + def __focus_prompt_text(self): + if self._prompt_text is not None and \ + not self._provider_control_has_focus(): + self._prompt_text.grab_focus() + return False + + def _provider_control_has_focus(self): + top_level = self.get_toplevel() + if not isinstance(top_level, Gtk.Window): + return False + + focused = top_level.get_focus() + return focused in ( + self._provider_combo, + self._provider_key_entry, + self._provider_paste_button, + self._provider_model_entry, + self._provider_endpoint_entry, + self._provider_apply_button, + self._provider_remove_button, + ) + + +def _clean_generation_error_text(error_text): + """Strip redundant pipeline prefixes so the learner sees the real + validation reasons instead of a doubled-up error chain. + + The pipeline wraps validation failures as: + Provider could not generate valid activity code: + Provider generated code did not pass validation: + Both prefixes are implementation details; the learner only needs + the part. + """ + text = str(error_text or '').strip() + for prefix in ( + 'Provider could not generate valid activity code: ', + 'Provider generated code did not pass validation: '): + if text.startswith(prefix): + text = text[len(prefix):].strip() + return text diff --git a/src/jarabe/desktop/homewindow.py b/src/jarabe/desktop/homewindow.py index ee37399301..3be2dc3967 100644 --- a/src/jarabe/desktop/homewindow.py +++ b/src/jarabe/desktop/homewindow.py @@ -188,6 +188,9 @@ def __key_press_event_cb(self, window, event): self._alt_timeout_sid = GLib.timeout_add(100, self.__alt_timeout_cb) + if self._home_box.is_create_ai_panel_visible(): + return False + if not self._toolbar.search_entry.props.has_focus: self._toolbar.search_entry.grab_focus() diff --git a/src/jarabe/desktop/viewtoolbar.py b/src/jarabe/desktop/viewtoolbar.py index 5a2ddb422f..8573c184aa 100644 --- a/src/jarabe/desktop/viewtoolbar.py +++ b/src/jarabe/desktop/viewtoolbar.py @@ -25,10 +25,16 @@ from gi.repository import Gtk from gi.repository import GObject from gi.repository import GLib +from gi.repository import Gdk from sugar3.graphics import style from sugar3.graphics import iconentry from sugar3.graphics.radiotoolbutton import RadioToolButton +from sugar3.graphics.toolbutton import ToolButton +from sugar3.graphics.palette import Palette +from sugar3.graphics.palettemenu import PaletteMenuBox +from sugar3.graphics.palettemenu import PaletteMenuItem +from sugar3.graphics.icon import Icon from jarabe.desktop import favoritesview from jarabe.model import desktop @@ -44,6 +50,7 @@ class ViewToolbar(Gtk.Toolbar): ([str])), 'view-changed': (GObject.SignalFlags.RUN_FIRST, None, ([object])), + 'create-ai-activity': (GObject.SignalFlags.RUN_FIRST, None, ([])), } def __init__(self): @@ -99,6 +106,24 @@ def __init__(self): 'toggled', self.__view_button_toggled_cb, self._list_view_index) self.insert(self._list_button, -1) + self._create_button = ToolButton() + create_icon = Icon(icon_name='list-add', + pixel_size=style.STANDARD_ICON_SIZE, + stroke_color=style.COLOR_BLACK.get_svg(), + fill_color=style.COLOR_WHITE.get_svg()) + self._create_button.set_icon_widget(create_icon) + create_icon.show() + self._create_button.props.tooltip = _('Create activity with AI') + self._create_button.set_palette(self._create_ai_palette()) + self._create_button.connect('clicked', + self.__create_button_clicked_cb) + # Keep click behavior available and also force immediate hover popup. + self._create_button.palette_invoker.props.toggle_palette = True + self._create_button.get_child().connect( + 'enter-notify-event', self.__create_button_enter_notify_cb) + self.insert(self._create_button, -1) + self._create_button.show() + self._add_separator() def _add_favorites_button(self, i): @@ -201,6 +226,42 @@ def __desktop_view_icons_changed_cb(self, model): 'toggled', self.__view_button_toggled_cb, self._list_view_index) self._list_button.show() + def _create_ai_palette(self): + palette = Palette(None) + menu_box = PaletteMenuBox() + menu_item = PaletteMenuItem(_('Create activity with AI'), + icon_name='list-add') + menu_item.set_size_request(style.GRID_CELL_SIZE * 3, + style.GRID_CELL_SIZE - style.DEFAULT_PADDING) + if menu_item.icon is not None: + menu_item.icon.props.pixel_size = style.STANDARD_ICON_SIZE + menu_item.connect('activate', self.__create_activity_with_ai_cb) + menu_box.append_item(menu_item) + menu_item.show() + palette.set_content(menu_box) + menu_box.show() + # Remove the default header gutter so this palette shows one compact row. + palette._primary_event_box.hide() + palette._separator.hide() + palette.action_bar.hide() + palette._secondary_box.set_child_packing( + palette._content, True, True, 0, Gtk.PackType.START) + return palette + + def __create_activity_with_ai_cb(self, menu_item): + self.emit('create-ai-activity') + + def __create_button_clicked_cb(self, button): + if button.palette is not None: + button.palette.popdown(immediate=True) + self.emit('create-ai-activity') + + def __create_button_enter_notify_cb(self, widget, event): + if event.mode == Gdk.CrossingMode.NORMAL and \ + self._create_button.palette is not None: + self._create_button.palette.popup(immediate=True) + return False + class FavoritesButton(RadioToolButton): __gtype_name__ = 'SugarFavoritesButton' diff --git a/src/jarabe/model/Makefile.am b/src/jarabe/model/Makefile.am index db1605882f..395f018b0d 100644 --- a/src/jarabe/model/Makefile.am +++ b/src/jarabe/model/Makefile.am @@ -3,6 +3,21 @@ sugardir = $(pythondir)/jarabe/model sugar_PYTHON = \ adhoc.py \ __init__.py \ + aodcodegen.py \ + aodcredentials.py \ + aodgenerator.py \ + aodjobs.py \ + aodlicenses.py \ + aodllm.py \ + aodpipeline.py \ + aodprompts.py \ + aodqueue.py \ + aodrag.py \ + aodsessions.py \ + aodservice.py \ + aodspec.py \ + aodtemplates.py \ + aodvalidator.py \ buddy.py \ bundleregistry.py \ brightness.py \ diff --git a/src/jarabe/model/aodcodegen.py b/src/jarabe/model/aodcodegen.py new file mode 100644 index 0000000000..a2efa089be --- /dev/null +++ b/src/jarabe/model/aodcodegen.py @@ -0,0 +1,258 @@ +# Copyright (C) 2026 Sugar Labs +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import json + +from jarabe.model.aodprompts import extract_json_object +from jarabe.model.aodrag import get_api_reference +from jarabe.model.aodvalidator import ALLOWED_IMPORT_ROOTS +from jarabe.model.aodvalidator import FORBIDDEN_CALLS +from jarabe.model.aodvalidator import FORBIDDEN_IMPORT_ROOTS + + +def build_codegen_system_prompt(spec, plan, references=()): + """Build the provider prompt for a complete Sugar activity.py file.""" + return ( + 'You are Sugar Activity on Demand, a code generator for Sugar ' + 'activities.\n\n' + 'Return the complete activity.py source inside a single Python ' + 'code fence and nothing else:\n' + '```python\n\n```\n\n' + 'Do not wrap the source in JSON. Do not add explanation, notes, ' + 'or any text before or after the fence. Every output token is ' + 'precious, so spend them on the Python code, not on JSON escaping ' + 'or commentary.\n\n' + 'The generated source must be a complete Sugar GTK3 activity ' + 'that follows the same patterns as real installed Sugar ' + 'activities.\n\n' + 'SUGAR ACTIVITY STRUCTURE (follow exactly):\n' + '1. Start with a copyright header and SPDX line:\n' + ' # Copyright (C) 2026 Sugar Labs\n' + ' # SPDX-License-Identifier: GPL-3.0-or-later\n' + '2. Import gi and require versions BEFORE importing GTK:\n' + ' import gi\n' + ' gi.require_version("Gtk", "3.0")\n' + ' gi.require_version("Gdk", "3.0")\n' + '3. Use gettext for any user-visible strings:\n' + ' from gettext import gettext as _\n' + '4. Set up logging:\n' + ' import logging\n' + ' _logger = logging.getLogger("GeneratedActivity")\n' + '5. Import sugar3 modules:\n' + ' from sugar3.activity import activity\n' + ' from sugar3.graphics.toolbarbox import ToolbarBox\n' + ' from sugar3.activity.widgets import ActivityToolbarButton\n' + ' from sugar3.activity.widgets import StopButton\n' + '6. Class must be named GeneratedActivity(activity.Activity)\n' + '7. In __init__:\n' + ' - Call activity.Activity.__init__(self, handle)\n' + ' - Create ToolbarBox with ActivityToolbarButton and StopButton\n' + ' - Call self.set_toolbar_box(toolbar_box)\n' + ' - Build the canvas with Gtk widgets\n' + ' - Call self.set_canvas(canvas)\n' + ' - Call self.show_all()\n' + '8. Implement read_file(self, file_path) and ' + 'write_file(self, file_path)\n' + ' for Journal persistence using json.\n\n' + 'Hard requirements:\n' + '- Build the specific activity described by activity_kind, ' + 'interaction_model, ui_regions, learner_steps, and the learner ' + 'prompt. Do not copy a canned local template.\n' + '- The RAG references below show how real Sugar activities are ' + 'assembled. Follow the Sugar lifecycle and GTK patterns from those ' + 'references, but create new code for this request.\n' + '- Treat the plan.template value only as a reference family for ' + 'metadata; the generated UI and behavior must follow the learner ' + 'request.\n' + '- The visible activity must include the controls and work area ' + 'needed for the learner prompt. If the prompt asks for two learners, ' + 'include separate learner/team state and a turn or collaboration ' + 'workflow. If it asks for drawing, implement pointer events and ' + 'actual drawing state, not a static sample image.\n' + '- This activity.py is the generated product. Do not return a ' + 'preview card, explanation-only mockup, tiny demo, TODO, or ' + 'placeholder. A teacher should be able to install it and have ' + 'learners use the requested activity immediately.\n' + '- Make the canvas/work area fill the activity window naturally with ' + 'Gtk containers that expand. Avoid small centered toy panels unless ' + 'the requested activity is intentionally compact.\n' + '- If the structured request includes "Current activity.py excerpt", ' + 'this is a refinement. Preserve working behavior from that source ' + 'and apply the requested change directly in the regenerated source.\n' + '- Use only classroom-safe local state. No networking, subprocesses, ' + 'or arbitrary filesystem access.\n' + '- Keep the UI useful on 1024x768 screens.\n' + '- Make the activity interactive enough for learners to try directly ' + 'after installing it from the preview.\n' + '- Include the prompt-specific domain objects and actions. Examples: ' + 'drawing prompts need DrawingArea pointer events and saved strokes; ' + 'two-student prompts need visible learner roles and collaboration or ' + 'turn-taking state; board games need actual board state, scoring or ' + 'move rules; quiz prompts need input, feedback, and saved progress.\n' + '- Make the activity full-featured and rich, not minimal. Add ' + 'multiple toolbar controls, a sidebar or status area, clear visual ' + 'feedback, and enough functionality that a learner can use it for ' + 'a real classroom session. Think 200-400 lines of real, working ' + 'code — the kind of activity a teacher would actually install and ' + 'use with students.\n' + '- Add helpful UI elements: status labels, color palettes, ' + 'instructions, score displays, clear buttons, undo where ' + 'appropriate. The activity should feel polished, not bare.\n\n' + 'Allowed import roots: %(allowed)s\n' + 'Forbidden import roots: %(forbidden_imports)s\n' + 'Forbidden calls: %(forbidden_calls)s\n\n' + 'Sugar Activity API reference:\n%(api_reference)s\n\n' + 'Retrieved Sugar references:\n%(references)s' + ) % { + 'allowed': ', '.join(sorted(ALLOWED_IMPORT_ROOTS)), + 'forbidden_imports': ', '.join(sorted(FORBIDDEN_IMPORT_ROOTS)), + 'forbidden_calls': ', '.join(sorted(FORBIDDEN_CALLS)), + 'api_reference': get_api_reference(), + 'references': _format_references(references) or + 'No extra references were retrieved.', + } + + +def build_codegen_user_prompt(spec, plan, validation_feedback=''): + """Describe the requested activity source to the provider.""" + feedback_block = '' + if validation_feedback: + feedback_block = ( + '\n\nPrevious generated source failed validation. Fix these ' + 'issues and return a corrected complete activity.py:\n%s' + % validation_feedback + ) + return ( + 'Create activity.py for this Sugar activity request.\n\n' + 'Generate the real learner activity now. The output must be complete ' + 'runnable GTK3/Sugar code, not a sketch, template note, preview ' + 'description, static sample image, or generic local template. ' + 'Before returning, self-check that the code implements the concrete ' + 'nouns and verbs in the learner prompt.\n\n' + 'Structured request:\n%s\n\n' + 'Normalized plan JSON:\n%s%s' + ) % ( + spec.to_prompt(), + json.dumps(plan, indent=2, sort_keys=True), + feedback_block, + ) + + +def extract_activity_source(value): + """Extract Python source from provider JSON or a fenced code string.""" + source = '' + if isinstance(value, dict): + files = value.get('files') + if isinstance(files, dict): + source = files.get('activity.py') or files.get('activity_py') or '' + elif isinstance(files, list): + for item in files: + if not isinstance(item, dict): + continue + path = item.get('path') or item.get('name') + if path in ('activity.py', './activity.py'): + source = item.get('content') or item.get('source') or '' + break + if not source: + for key in ('activity_py', 'activity.py', 'source', 'code'): + candidate = value.get(key) + if isinstance(candidate, str): + source = candidate + break + elif isinstance(value, str): + source = value + else: + raise ValueError('Provider code response must be text or JSON.') + + source = _strip_code_fence(source) + if not source: + raise ValueError('Provider code response did not include activity.py.') + + # A truncated JSON wrapper (e.g. {"activity_py": "...) looks + # like Python to a naive reader but is not valid Python. Detect it + # here so the caller gets a clear "truncated" message instead of a + # confusing syntax error from ast.parse on the JSON text. + stripped = source.lstrip() + if stripped.startswith('{') or stripped.startswith('['): + try: + parsed = json.loads(stripped) + except json.JSONDecodeError: + raise ValueError( + 'Model response was truncated: the JSON wrapping the ' + 'activity source is incomplete. This usually means the ' + 'model ran out of output tokens (finish_reason=length). ' + 'Try a smaller prompt or a model with a larger output ' + 'budget.' + ) + if isinstance(parsed, dict): + return extract_activity_source(parsed) + + if 'GeneratedActivity' not in source: + raise ValueError( + 'Provider code response did not define GeneratedActivity.' + ) + return source.rstrip() + '\n' + + +def extract_activity_source_from_response(text): + """Extract activity.py from a raw codegen text response. + + Models may return the source inside a ```python fence or as a JSON + object with an activity_py field. Truncated JSON responses are + detected and reported clearly instead of being misread as Python. + Model error messages (e.g. "ERROR: Cannot read image.png") are + detected so the caller sees the real provider error instead of a + confusing "did not define GeneratedActivity" message. + """ + if not isinstance(text, str) or not text.strip(): + raise ValueError('Provider code response did not include activity.py.') + + stripped = text.strip() + + # Detect model-side error messages that are not code. Some + # OpenRouter models return errors like "ERROR: Cannot read + # image.png (this model does not support image input)" instead of + # activity source. These short, non-Python messages should be + # surfaced to the user, not fed to ast.parse. + if (stripped.startswith('ERROR:') + or stripped.startswith('Error:') + or stripped.startswith('ERROR ')): + raise ValueError( + 'Model returned an error instead of activity code: %s' + % stripped[:300] + ) + + try: + value = extract_json_object(text) + except ValueError: + value = text + return extract_activity_source(value) + + +def _strip_code_fence(source): + source = (source or '').strip() + if source.startswith('```'): + first_newline = source.find('\n') + last_fence = source.rfind('```') + if first_newline >= 0 and last_fence > first_newline: + source = source[first_newline + 1:last_fence].strip() + else: + fence_start = source.find('```') + if fence_start >= 0: + first_newline = source.find('\n', fence_start) + fence_end = source.find('```', first_newline + 1) + if first_newline >= 0 and fence_end > first_newline: + source = source[first_newline + 1:fence_end].strip() + return source + + +def _format_references(references): + blocks = [] + for index, document in enumerate(references[:3], 1): + text = ' '.join(getattr(document, 'text', '').split()) + title = getattr(document, 'title', 'Reference') + blocks.append( + 'Reference %d - %s:\n%s' % (index, title, text[:1200]) + ) + return '\n\n'.join(blocks) diff --git a/src/jarabe/model/aodcredentials.py b/src/jarabe/model/aodcredentials.py new file mode 100644 index 0000000000..bfed58679f --- /dev/null +++ b/src/jarabe/model/aodcredentials.py @@ -0,0 +1,355 @@ +# Copyright (C) 2026 Sugar Labs +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import json +import os +import tempfile + +from sugar3 import env + + +_PROVIDER_VARIABLES = { + 'gemini': { + 'key': 'GEMINI_API_KEY', + 'model': 'AOD_GEMINI_MODEL', + 'endpoint': 'AOD_GEMINI_ENDPOINT', + 'storage': 'AOD_GEMINI_KEY_STORAGE', + }, + 'openai': { + 'key': 'OPENAI_API_KEY', + 'model': 'AOD_OPENAI_MODEL', + 'endpoint': 'AOD_OPENAI_ENDPOINT', + 'storage': 'AOD_OPENAI_KEY_STORAGE', + }, + 'openrouter': { + 'key': 'OPENROUTER_API_KEY', + 'model': 'AOD_OPENROUTER_MODEL', + 'endpoint': 'AOD_OPENROUTER_ENDPOINT', + 'storage': 'AOD_OPENROUTER_KEY_STORAGE', + }, + 'deepseek': { + 'key': 'DEEPSEEK_API_KEY', + 'model': 'AOD_DEEPSEEK_MODEL', + 'endpoint': 'AOD_DEEPSEEK_ENDPOINT', + 'storage': 'AOD_DEEPSEEK_KEY_STORAGE', + }, + 'qwen': { + 'key': 'QWEN_API_KEY', + 'model': 'AOD_QWEN_MODEL', + 'endpoint': 'AOD_QWEN_ENDPOINT', + 'storage': 'AOD_QWEN_KEY_STORAGE', + }, + 'moonshot': { + 'key': 'MOONSHOT_API_KEY', + 'model': 'AOD_MOONSHOT_MODEL', + 'endpoint': 'AOD_MOONSHOT_ENDPOINT', + 'storage': 'AOD_MOONSHOT_KEY_STORAGE', + }, + 'opencode': { + 'key': 'OPENCODE_API_KEY', + 'model': 'AOD_OPENCODE_MODEL', + 'endpoint': 'AOD_OPENCODE_ENDPOINT', + 'storage': 'AOD_OPENCODE_KEY_STORAGE', + }, + 'opencode-go': { + 'key': 'OPENCODE_API_KEY', + 'model': 'AOD_OPENCODE_GO_MODEL', + 'endpoint': 'AOD_OPENCODE_GO_ENDPOINT', + 'storage': 'AOD_OPENCODE_GO_KEY_STORAGE', + }, + 'freemodel': { + 'key': 'FREEMODEL_API_KEY', + 'model': 'AOD_FREEMODEL_MODEL', + 'endpoint': 'AOD_FREEMODEL_ENDPOINT', + 'storage': 'AOD_FREEMODEL_KEY_STORAGE', + }, + 'claude': { + 'key': 'ANTHROPIC_API_KEY', + 'model': 'AOD_CLAUDE_MODEL', + 'endpoint': 'AOD_CLAUDE_ENDPOINT', + 'storage': 'AOD_CLAUDE_KEY_STORAGE', + }, + 'ollama': { + 'model': 'AOD_OLLAMA_MODEL', + 'endpoint': 'AOD_OLLAMA_ENDPOINT', + }, +} + +_DEFAULT_PROVIDER_VARIABLE = 'AOD_DEFAULT_PROVIDER' +_ALLOWED_VARIABLES = {_DEFAULT_PROVIDER_VARIABLE} +for _variables in _PROVIDER_VARIABLES.values(): + _ALLOWED_VARIABLES.update(_variables.values()) + + +class CredentialStoreError(Exception): + pass + + +class AODCredentialStore: + """Store provider settings without exposing keys to generated projects.""" + + def __init__(self, root_path=None, secret_backend=None): + self._root_path = root_path or env.get_profile_path('aod') + self._path = os.path.join(self._root_path, 'providers.env') + if secret_backend is False: + self._secret_backend = None + elif secret_backend == 'auto': + self._secret_backend = _create_secret_backend() + elif secret_backend is None: + self._secret_backend = None + else: + self._secret_backend = secret_backend + + @property + def path(self): + return self._path + + def save_provider(self, provider_name, api_key=None, model=None, + endpoint=None): + variables = self._variables_for(provider_name) + values = self._read_values() + storage = values.get(variables.get('storage', ''), '') + + self._set_optional(values, variables.get('model'), model) + self._set_optional(values, variables.get('endpoint'), endpoint) + values[_DEFAULT_PROVIDER_VARIABLE] = provider_name + + if api_key and variables.get('key'): + storage = self._store_api_key(provider_name, api_key, values) + values[variables['storage']] = storage + + self._write_values(values) + return storage + + def load_provider(self, provider_name): + variables = self._variables_for(provider_name) + values = self._read_values() + storage = values.get(variables.get('storage', ''), '') + api_key = '' + + if variables.get('key'): + if storage == 'keyring' and self._secret_backend is not None: + try: + api_key = self._secret_backend.lookup(provider_name) or '' + except Exception: + api_key = '' + if not api_key: + api_key = values.get(variables['key'], '') + + return { + 'api_key': api_key, + 'model': values.get(variables.get('model', ''), ''), + 'endpoint': values.get(variables.get('endpoint', ''), ''), + 'storage': storage, + } + + def provider_status(self, provider_name): + variables = self._variables_for(provider_name) + values = self._read_values() + storage = values.get(variables.get('storage', ''), '') + has_key = bool( + storage == 'keyring' or values.get( + variables.get('key', ''), + '', + ) + ) + return { + 'has_api_key': has_key, + 'storage': storage, + 'model': values.get(variables.get('model', ''), ''), + 'endpoint': values.get(variables.get('endpoint', ''), ''), + } + + def remove_api_key(self, provider_name): + variables = self._variables_for(provider_name) + if not variables.get('key'): + return False + + values = self._read_values() + removed = False + if self._secret_backend is not None: + try: + removed = bool( + self._secret_backend.clear(provider_name) + ) or removed + except Exception: + pass + + if values.pop(variables['key'], None) is not None: + removed = True + if values.pop(variables['storage'], None) is not None: + removed = True + self._write_values(values) + return removed + + def get_default_provider_name(self): + values = self._read_values() + provider_name = values.get(_DEFAULT_PROVIDER_VARIABLE, '') + if provider_name not in _PROVIDER_VARIABLES: + return '' + + status = self.provider_status(provider_name) + if provider_name == 'ollama' or status['has_api_key']: + return provider_name + return '' + + def _store_api_key(self, provider_name, api_key, values): + variables = self._variables_for(provider_name) + if self._secret_backend is not None: + try: + if not self._secret_backend.store(provider_name, api_key): + raise CredentialStoreError( + 'The system keyring did not store the API key.' + ) + values.pop(variables['key'], None) + return 'keyring' + except Exception: + pass + + values[variables['key']] = api_key + return 'profile-file' + + def _variables_for(self, provider_name): + try: + return _PROVIDER_VARIABLES[provider_name] + except KeyError: + raise CredentialStoreError( + 'Provider settings cannot be saved for %s.' % provider_name + ) + + def _read_values(self): + if not os.path.exists(self._path): + return {} + + try: + os.chmod(self._path, 0o600) + with open(self._path, encoding='utf-8') as source: + lines = source.readlines() + except OSError as error: + raise CredentialStoreError( + 'Could not read provider settings: %s' % error + ) + + values = {} + for line in lines: + line = line.strip() + if not line or line.startswith('#') or '=' not in line: + continue + name, encoded = line.split('=', 1) + if name not in _ALLOWED_VARIABLES: + continue + try: + value = json.loads(encoded) + except (TypeError, ValueError): + continue + if isinstance(value, str): + values[name] = value + return values + + def _write_values(self, values): + values = { + name: value + for name, value in values.items() + if name in _ALLOWED_VARIABLES and isinstance(value, str) and value + } + + try: + os.makedirs(self._root_path, mode=0o700, exist_ok=True) + os.chmod(self._root_path, 0o700) + descriptor, temporary_path = tempfile.mkstemp( + prefix='.providers-', + dir=self._root_path, + text=True, + ) + try: + os.fchmod(descriptor, 0o600) + with os.fdopen(descriptor, 'w', encoding='utf-8') as output: + output.write( + '# Sugar Activity-on-Demand provider settings\n' + ) + output.write( + '# This file is private to the current OS user.\n' + ) + for name in sorted(values): + output.write( + '%s=%s\n' % (name, json.dumps(values[name])) + ) + output.flush() + os.fsync(output.fileno()) + os.replace(temporary_path, self._path) + os.chmod(self._path, 0o600) + except Exception: + try: + os.unlink(temporary_path) + except OSError: + pass + raise + except OSError as error: + raise CredentialStoreError( + 'Could not save provider settings: %s' % error + ) + + def _set_optional(self, values, name, value): + if not name: + return + if value: + values[name] = value + else: + values.pop(name, None) + + +class _LibSecretBackend: + + def __init__(self): + import gi + gi.require_version('Secret', '1') + from gi.repository import Secret + + self._secret = Secret + self._profile_path = env.get_profile_path() + self._schema = Secret.Schema.new( + 'org.sugarlabs.ActivityOnDemand.Provider', + Secret.SchemaFlags.NONE, + { + 'provider': Secret.SchemaAttributeType.STRING, + 'profile': Secret.SchemaAttributeType.STRING, + }, + ) + + def store(self, provider_name, api_key): + return self._secret.password_store_sync( + self._schema, + self._attributes(provider_name), + self._secret.COLLECTION_DEFAULT, + 'Sugar Activity-on-Demand %s API key' % provider_name, + api_key, + None, + ) + + def lookup(self, provider_name): + return self._secret.password_lookup_sync( + self._schema, + self._attributes(provider_name), + None, + ) + + def clear(self, provider_name): + return self._secret.password_clear_sync( + self._schema, + self._attributes(provider_name), + None, + ) + + def _attributes(self, provider_name): + return { + 'provider': provider_name, + 'profile': self._profile_path, + } + + +def _create_secret_backend(): + try: + return _LibSecretBackend() + except Exception: + return None diff --git a/src/jarabe/model/aodgenerator.py b/src/jarabe/model/aodgenerator.py new file mode 100644 index 0000000000..e4e0daf1af --- /dev/null +++ b/src/jarabe/model/aodgenerator.py @@ -0,0 +1,1010 @@ +# Copyright (C) 2026 Sugar Labs +# +# SPDX-License-Identifier: GPL-3.0-or-later + +from dataclasses import dataclass +import hashlib +import json +import os +import re +import time + +from sugar3.activity import bundlebuilder + +from jarabe.model.aodlicenses import get_license +from jarabe.model.aodtemplates import render_activity_source + + +@dataclass +class GenerationResult: + spec: object + plan: dict + project_path: str + bundle_path: str + bundle_id: str + files: dict + provider: str = 'local' + model: str = '' + + +def restore_generation_result(spec, summary): + """Restore a completed result from its persisted artifact paths.""" + if not isinstance(summary, dict): + return None + + project_path = summary.get('project_path', '') + bundle_path = summary.get('bundle_path', '') + if not os.path.isdir(project_path): + return None + if bundle_path and not os.path.isfile(bundle_path): + bundle_path = '' + + plan_path = os.path.join(project_path, 'aod_plan.json') + try: + with open(plan_path, encoding='utf-8') as plan_file: + plan = json.load(plan_file) + except (OSError, ValueError, TypeError): + return None + if not isinstance(plan, dict): + return None + + bundle_id = summary.get('bundle_id') or plan.get('bundle_id', '') + if not bundle_id: + return None + + try: + files = read_project_files(project_path) + except OSError: + return None + + return GenerationResult( + spec=spec.normalized(), + plan=plan, + project_path=project_path, + bundle_path=bundle_path, + bundle_id=bundle_id, + files=files, + provider=summary.get('provider', plan.get('provider', 'local')), + model=summary.get('model', plan.get('model', '')), + ) + + +def create_prototype_activity(spec, output_root, plan=None, + package_bundle=True, activity_source=None): + """Generate a complete Sugar activity project and optional XO bundle.""" + errors = spec.validate() + if errors: + raise ValueError('\n'.join(errors)) + + spec = spec.normalized() + generation_plan = plan or build_plan(spec) + generation_plan = enrich_plan(spec, generation_plan) + project_path = assemble_project( + spec, + generation_plan, + output_root, + activity_source=activity_source, + ) + + bundle_path = '' + if package_bundle: + bundle_path = package_project(project_path) + + files = read_project_files(project_path) + return GenerationResult( + spec=spec, + plan=generation_plan, + project_path=project_path, + bundle_path=bundle_path, + bundle_id=generation_plan['bundle_id'], + files=files, + ) + + +def infer_template(spec): + if spec.template != 'auto': + return spec.template + + prompt = spec.prompt.lower() + words = set(re.findall(r'[a-z0-9]+', spec.prompt.lower())) + scores = { + 'canvas': _keyword_score(words, { + 'art', 'canvas', 'comic', 'color', 'design', 'diagram', 'draw', + 'drawing', 'map', 'paint', 'picture', 'poster', 'shape', 'sketch', + 'atlas', 'city', 'compass', 'continent', 'country', 'discover', + 'earth', 'explore', 'explorer', 'geography', 'globe', 'landmark', + 'location', 'maps', 'navigate', 'navigation', 'ocean', 'osm', + 'openstreetmap', 'place', 'places', 'region', 'route', 'street', + 'streetmap', 'territory', 'travel', 'visualize', 'world', + }) + _phrase_score(prompt, ( + 'drawing canvas', 'paint a picture', 'open street map', + 'street map', 'world map', 'map of', 'mind map', + 'city map', 'explore the world', 'explore countries', + 'explore the map', 'geography activity', 'visualize', + )), + 'narrative': _keyword_score(words, { + 'book', 'diary', 'journal', 'letter', 'narrative', 'poem', + 'reading', 'reflection', 'script', 'story', 'write', 'writing', + }), + 'quiz': _keyword_score(words, { + 'answer', 'assessment', 'flashcard', 'practice', 'question', + 'quiz', 'spelling', 'test', 'trivia', 'vocabulary', + }), + 'grid': _keyword_score(words, { + 'board', 'classify', 'game', 'grid', 'logic', 'match', 'maze', + 'pattern', 'puzzle', 'sort', 'table', 'tile', + }) + _phrase_score(prompt, ('board game', 'pattern game')), + 'utility': _keyword_score(words, { + 'calculate', 'calculator', 'checklist', 'converter', 'counter', + 'list', 'measure', 'organize', 'planner', 'schedule', 'stopwatch', + 'tally', 'timer', 'tool', 'tracker', 'utility', + }) + _phrase_score(prompt, ('word count', 'word counting')), + } + scores['carrom'] = _carrom_template_score(prompt, words) + scores['chess'] = _chess_template_score(prompt, words) + + template_order = _category_template_order(spec.category) + ranked = sorted( + scores.items(), + key=lambda item: ( + item[1], + -template_order.index(item[0]) + if item[0] in template_order else -len(template_order), + ), + reverse=True, + ) + template, score = ranked[0] + if score: + return template + + defaults = { + 'logic_math': 'quiz', + 'tools_utils': 'utility', + 'games': 'grid', + 'creation': 'narrative', + } + return defaults[spec.category] + + +def build_plan(spec): + template = infer_template(spec) + subject = _subject_from_spec(spec) + learner_goal = spec.learner_goal or ( + 'Create, test, explain, and improve an idea about %s.' + % spec.name.lower() + ) + digest = hashlib.sha256( + ('%s\0%s' % (spec.name, spec.prompt)).encode('utf-8') + ).hexdigest()[:10] + class_stem = _identifier(spec.name) + bundle_id = 'org.sugarlabs.aod.%s%s' % (class_stem, digest) + + plan = { + 'name': spec.name, + 'summary': _summary_from_prompt(spec.prompt), + 'template': template, + 'category': spec.category, + 'age_band': spec.age_band, + 'learner_goal': learner_goal, + 'learner_steps': [ + 'Make a first version.', + 'Test it and explain what happened.', + 'Change one part and share the new version.', + ], + 'word_bank': _word_bank(spec.prompt), + 'bundle_id': bundle_id, + 'class_name': 'GeneratedActivity', + 'license': spec.license_id, + 'activity_version': int(time.time()), + } + + if template == 'quiz': + plan['questions'] = _quiz_questions(spec) + elif template == 'chess': + plan['summary'] = ( + 'A two-student chess board for practicing legal moves, turn ' + 'taking, move explanations, captures, and Journal saving.' + ) + plan['learner_goal'] = ( + 'Practice chess moves, turns, and explanation with a partner.' + ) + plan['learner_steps'] = [ + 'Choose a white piece and make a legal move.', + 'Let black answer with a legal move.', + 'Explain the move idea before the next turn.', + 'Reset or save the board when the game is done.', + ] + plan['word_bank'] = [ + 'king', 'queen', 'rook', 'bishop', 'knight', 'pawn', + 'capture', 'check', + ] + elif template == 'carrom': + plan['summary'] = ( + 'A two-student carrom board for taking turns, aiming the ' + 'striker, scoring pocketed coins, tracking fouls, and saving ' + 'the match.' + ) + plan['learner_goal'] = ( + 'Practice turn taking, aim planning, scoring, and strategy ' + 'explanation with a partner.' + ) + plan['learner_steps'] = [ + 'Student A chooses an aim point and explains the shot.', + 'Record pocketed coins, queen claims, or fouls.', + 'Switch turns so Student B plans the next shot.', + 'Compare scores and reset or save the match in the Journal.', + ] + plan['word_bank'] = [ + 'striker', 'coin', 'queen', 'pocket', 'rebound', 'foul', + 'turn', 'score', + ] + elif template == 'canvas': + plan['summary'] = ( + 'A drawing canvas for learners to sketch, label, revise, and ' + 'share ideas about %s.' % subject + ) + plan['learner_goal'] = ( + 'Create and explain a visual model or artifact about %s.' + % subject + ) + plan['learner_steps'] = [ + 'Sketch the first idea on the canvas.', + 'Point to one part and explain what it means.', + 'Revise the drawing after a partner question.', + 'Save the final visual artifact to the Journal.', + ] + elif template == 'grid': + plan['summary'] = ( + 'An interactive grid for sorting, matching, or building patterns ' + 'about %s.' % subject + ) + plan['learner_goal'] = ( + 'Use a grid pattern or classification to explain %s.' % subject + ) + plan['learner_steps'] = [ + 'Choose tiles that belong in the first pattern.', + 'Describe the rule or reason for each choice.', + 'Ask a partner to change one tile and explain why.', + 'Save the final grid to the Journal.', + ] + elif template == 'narrative': + plan['starter_text'] = ( + '%s\n\nStart creating here.\n' % spec.prompt.strip() + ) + elif template == 'utility': + plan['utility_mode'] = _utility_mode(spec.prompt) + plan['summary'] = _utility_summary(subject, plan['utility_mode']) + plan['learner_goal'] = _utility_goal(subject, plan['utility_mode']) + plan['learner_steps'] = _utility_steps(plan['utility_mode']) + return plan + + +def enrich_plan(spec, plan, references=None): + """Add local classroom design detail before rendering the project.""" + enriched = normalize_plan(spec, plan) + references = references or () + template = enriched['template'] + subject = _subject_from_spec(spec) + + defaults = { + 'canvas': { + 'features': [ + 'large drawing surface', + 'drag-to-draw interaction', + 'clear/reset action', + 'Journal-saved learner artifact', + ], + 'classroom_flow': [ + 'Sketch a first idea on the canvas.', + 'Explain what the drawing shows to a partner.', + 'Revise one part after feedback.', + 'Save the final artifact to the Journal.', + ], + }, + 'chess': { + 'features': [ + 'full 8x8 board', + 'white and black turn taking', + 'legal move feedback', + 'captures and move log', + 'Journal-saved board state', + ], + 'classroom_flow': [ + 'White chooses a legal first move.', + 'Black answers and explains the idea.', + 'Partners record one reason for each move.', + 'Reset, replay, or save the board when finished.', + ], + }, + 'carrom': { + 'features': [ + 'full carrom board with four pockets', + 'two-student turn taking', + 'striker aim marker', + 'coin, queen, and foul scoring', + 'Journal-saved match state', + ], + 'classroom_flow': [ + 'Student A picks an aim point and names the shot idea.', + 'Record the pocketed coin, queen, or foul outcome.', + 'Student B takes the next turn and explains the strategy.', + 'Review the shot log, then reset or save the match.', + ], + }, + 'grid': { + 'features': [ + 'toggleable pattern grid', + 'visible selected-square count', + 'quick reset by untoggling choices', + 'Journal-saved pattern state', + ], + 'classroom_flow': [ + 'Create a pattern on the grid.', + 'Describe the rule that creates the pattern.', + 'Ask a partner to extend or change the rule.', + 'Save the final pattern to the Journal.', + ], + }, + 'narrative': { + 'features': [ + 'large writing space', + 'starter prompt', + 'revision-friendly text', + 'Journal-saved writing draft', + ], + 'classroom_flow': [ + 'Read the starter prompt.', + 'Write a first response.', + 'Share one sentence with a partner.', + 'Revise and save the draft to the Journal.', + ], + }, + 'quiz': { + 'features': [ + 'short learner-friendly questions', + 'typed responses', + 'immediate feedback', + 'score tracking', + ], + 'classroom_flow': [ + 'Answer the first question in your own words.', + 'Use feedback to improve the next answer.', + 'Explain one strategy to a partner.', + 'Try again and compare the new score.', + ], + }, + 'utility': { + 'features': [ + 'focused input area', + 'immediate calculated result', + 'simple reusable workflow', + 'Journal-saved tool state', + ], + 'classroom_flow': [ + 'Enter or paste the material to explore.', + 'Read the result and check if it makes sense.', + 'Change one input and compare the result.', + 'Save the useful result to the Journal.', + ], + }, + } + + template_defaults = defaults[template] + enriched['features'] = _unique_strings( + enriched.get('features') or template_defaults['features'], + 8, + ) + enriched['classroom_flow'] = _unique_strings( + enriched.get('classroom_flow') or template_defaults['classroom_flow'], + 6, + ) + enriched['teacher_notes'] = _unique_strings( + enriched.get('teacher_notes') or [ + 'Pair learners so one student controls and the other explains.', + 'Ask for one prediction before each action.', + 'Use the Journal entry as evidence of learning.', + ], + 6, + ) + enriched['assessment_prompts'] = _unique_strings( + enriched.get('assessment_prompts') or [ + 'What did you try first?', + 'What changed after feedback?', + 'What would you improve next?', + ], + 6, + ) + enriched['materials'] = _unique_strings( + enriched.get('materials') or [ + 'One XO or shared computer', + 'Partner discussion', + 'Journal for saved work', + ], + 6, + ) + if references: + enriched['grounding_references'] = _unique_strings( + [ + getattr(reference, 'title', '') + for reference in references + if getattr(reference, 'title', '') + ], + 4, + ) + elif 'grounding_references' not in enriched: + enriched['grounding_references'] = [] + + if template == 'quiz': + enriched['questions'] = _enriched_quiz_questions(spec, enriched) + elif template == 'chess': + enriched['chess_show_move_log'] = _chess_should_show_move_log( + spec, + enriched, + ) + if not enriched['chess_show_move_log']: + enriched['features'] = _unique_strings([ + 'clean chess board without move-history panel', + 'white and black turn taking', + 'legal move feedback', + 'Journal-saved board position', + ] + enriched.get('features', []), 8) + enriched['summary'] = ( + 'A clean, two-player chess board for partners to play, ' + 'discuss moves, and reason together without move history ' + 'clutter.' + ) + elif template == 'carrom': + enriched['players'] = _unique_strings( + enriched.get('players') or ['Student A', 'Student B'], + 2, + ) + elif template == 'narrative' and not enriched.get('starter_text'): + enriched['starter_text'] = ( + '%s\n\nI notice...\nI wonder...\nMy next revision is...\n' + % spec.prompt.strip() + ) + elif template == 'utility': + enriched['utility_mode'] = _utility_mode_from_plan(spec, enriched) + + if not enriched.get('summary') or enriched['summary'] == spec.prompt: + enriched['summary'] = ( + 'A Sugar activity about %s with hands-on work, reflection, ' + 'and Journal saving.' % subject + ) + + return enriched + + +def normalize_plan(spec, plan): + normalized = build_plan(spec) + if not isinstance(plan, dict): + return normalized + + text_fields = ( + 'activity_kind', + 'interaction_model', + 'summary', + 'learner_goal', + 'starter_text', + 'state_schema', + ) + for field in text_fields: + value = plan.get(field) + if isinstance(value, str) and value.strip(): + normalized[field] = value.strip() + + template = plan.get('template') + if template in ( + 'canvas', 'carrom', 'chess', 'grid', 'narrative', 'quiz', + 'utility'): + normalized['template'] = template + + steps = plan.get('learner_steps') + if isinstance(steps, list): + clean_steps = [ + str(step).strip() for step in steps + if isinstance(step, str) and step.strip() + ][:5] + if clean_steps: + normalized['learner_steps'] = clean_steps + + words = plan.get('word_bank') + if isinstance(words, list): + normalized['word_bank'] = _unique_strings(words, 8) + + for field, limit in ( + ('ui_regions', 8), + ('features', 8), + ('classroom_flow', 6), + ('teacher_notes', 6), + ('assessment_prompts', 6), + ('materials', 6), + ('grounding_references', 4)): + values = plan.get(field) + if isinstance(values, list): + clean_values = _unique_strings(values, limit) + if clean_values: + normalized[field] = clean_values + + questions = plan.get('questions') + if isinstance(questions, list): + clean_questions = [] + for item in questions[:10]: + if not isinstance(item, dict): + continue + question = item.get('question', '') + answer = item.get('answer', '') + if isinstance(question, str) and question.strip(): + clean_questions.append({ + 'question': question.strip()[:240], + 'answer': str(answer).strip()[:120] or 'anything', + }) + if clean_questions: + normalized['questions'] = clean_questions + + for field in ( + 'provider', + 'model', + 'provider_fallback_reason', + 'code_source', + 'codegen_provider', + 'codegen_model', + 'codegen_fallback_reason'): + value = plan.get(field) + if isinstance(value, str) and value: + normalized[field] = value + + attempts = plan.get('codegen_attempts') + if isinstance(attempts, int): + normalized['codegen_attempts'] = attempts + + for field in ('chess_show_move_log',): + value = plan.get(field) + if isinstance(value, bool): + normalized[field] = value + + utility_mode = plan.get('utility_mode') + if utility_mode in ('word_counter', 'counter', 'timer'): + normalized['utility_mode'] = utility_mode + + return normalized + + +def assemble_project(spec, plan, output_root, activity_source=None): + os.makedirs(output_root, exist_ok=True) + project_path = _new_project_path(output_root, spec.name) + activity_path = os.path.join(project_path, 'activity') + os.makedirs(activity_path) + + license_info = get_license(spec.license_id) + source = activity_source or render_activity_source(spec, plan) + files = { + 'activity.py': source, + 'setup.py': _SETUP_SOURCE, + 'README.md': _render_readme(spec, plan), + 'LICENSE': license_info.get_text(), + 'aod_plan.json': json.dumps(plan, indent=2, sort_keys=True) + '\n', + os.path.join('activity', 'activity.info'): + _render_activity_info(spec, plan), + os.path.join('activity', 'activity.svg'): _ACTIVITY_ICON, + } + + for relative_path, content in files.items(): + path = os.path.join(project_path, relative_path) + with open(path, 'w', encoding='utf-8') as output: + output.write(content) + + return project_path + + +def package_project(project_path): + dist_path = os.path.join(project_path, 'dist') + os.makedirs(dist_path, exist_ok=True) + config = bundlebuilder.Config( + source_dir=project_path, + dist_dir=dist_path, + ) + bundlebuilder.cmd_dist_xo(config, None) + return os.path.join(dist_path, config.xo_name) + + +def read_project_files(project_path): + result = {} + for root, directories, filenames in os.walk(project_path): + directories[:] = [ + name for name in directories if name not in ('dist', '__pycache__') + ] + for filename in filenames: + path = os.path.join(root, filename) + relative_path = os.path.relpath(path, project_path) + try: + with open(path, encoding='utf-8') as source: + result[relative_path] = source.read() + except UnicodeDecodeError: + continue + return result + + +def _render_activity_info(spec, plan): + return ( + '[Activity]\n' + 'name = %(name)s\n' + 'bundle_id = %(bundle_id)s\n' + 'icon = activity\n' + 'exec = sugar-activity3 activity.GeneratedActivity\n' + 'activity_version = %(activity_version)s\n' + 'license = %(license)s\n' + 'max_participants = 1\n' + 'summary = %(summary)s\n' + 'tags = Education\n' + ) % { + 'name': spec.name.replace('\n', ' '), + 'bundle_id': plan['bundle_id'], + 'license': spec.license_id, + 'summary': plan['summary'].replace('\n', ' '), + 'activity_version': plan['activity_version'], + } + + +def _render_readme(spec, plan): + steps = '\n'.join( + '%d. %s' % (index, step) + for index, step in enumerate(plan['learner_steps'], 1) + ) + extra_sections = ''.join(( + _render_plan_section('Key features', plan.get('features')), + _render_plan_section('Classroom flow', plan.get('classroom_flow')), + _render_plan_section('Teacher notes', plan.get('teacher_notes')), + _render_plan_section( + 'Assessment prompts', + plan.get('assessment_prompts'), + ), + _render_plan_section('Materials', plan.get('materials')), + _render_plan_section( + 'Grounded Sugar patterns', + plan.get('grounding_references'), + ), + )) + return ( + '# %(name)s\n\n' + '%(summary)s\n\n' + '## Learner goal\n\n' + '%(goal)s\n\n' + '## Suggested learning flow\n\n' + '%(steps)s\n\n' + '%(extra_sections)s' + '## Generation details\n\n' + '- Category: `%(category)s`\n' + '- Template: `%(template)s`\n' + '- Age band: `%(age_band)s`\n' + '- License: `%(license)s`\n\n' + 'This project was generated by Sugar Activity on Demand and can be ' + 'changed, tested, and shared as an XO bundle.\n' + ) % { + 'name': spec.name, + 'summary': plan['summary'], + 'goal': plan['learner_goal'], + 'steps': steps, + 'extra_sections': extra_sections, + 'category': spec.category, + 'template': plan['template'], + 'age_band': spec.age_band, + 'license': spec.license_id, + } + + +def _render_plan_section(title, values): + if not values: + return '' + lines = [ + '- %s' % str(value).strip() + for value in values + if str(value).strip() + ] + if not lines: + return '' + return '## %s\n\n%s\n\n' % (title, '\n'.join(lines)) + + +def _summary_from_prompt(prompt): + summary = ' '.join(prompt.split()) + if len(summary) > 180: + summary = summary[:177].rstrip() + '...' + return summary + + +def _word_bank(prompt): + words = re.findall(r'[A-Za-z][A-Za-z0-9-]+', prompt.lower()) + ignored = { + 'activity', 'and', 'build', 'create', 'for', 'make', 'that', 'the', + 'their', 'this', 'where', 'with', + } + return _unique_strings( + [word for word in words if word not in ignored and len(word) > 2], + 8, + ) + + +def _keyword_score(words, keywords): + return len(words.intersection(keywords)) + + +def _phrase_score(prompt, phrases): + return sum(1 for phrase in phrases if phrase in prompt) + + +def _category_template_order(category): + orders = { + 'logic_math': [ + 'quiz', 'grid', 'carrom', 'utility', 'canvas', 'narrative', + 'chess', + ], + 'tools_utils': [ + 'utility', 'grid', 'quiz', 'carrom', 'canvas', 'narrative', + 'chess', + ], + 'games': [ + 'carrom', 'chess', 'grid', 'quiz', 'canvas', 'narrative', + 'utility', + ], + 'creation': [ + 'canvas', 'narrative', 'grid', 'carrom', 'quiz', 'utility', + 'chess', + ], + } + return orders.get(category, orders['logic_math']) + + +def _carrom_template_score(prompt, words): + if 'carrom' in words: + return 6 + if any(phrase in prompt for phrase in ( + 'carrom board', 'carrom game', 'carrom activity')): + return 6 + + carrom_words = { + 'striker', 'pocket', 'pockets', 'coin', 'coins', 'queen', + 'rebound', 'flick', 'flicking', 'foul', 'fouls', + } + score = len(words.intersection(carrom_words)) + if score and {'board', 'game', 'score', 'scoring', 'turn', 'turns'} & words: + return 2 + score + return 0 + + +def _chess_template_score(prompt, words): + if 'chess' in words or 'checkmate' in words: + return 5 + if any(phrase in prompt for phrase in ( + 'chess board', 'chess game', 'chess puzzle', 'legal chess')): + return 5 + + pieces = { + 'king', 'queen', 'rook', 'bishop', 'knight', 'pawn', + } + piece_count = len(words.intersection(pieces)) + chess_actions = { + 'capture', 'castle', 'castling', 'check', 'move', 'moves', + 'opening', + } + if piece_count >= 2 and words.intersection(chess_actions): + return 3 + piece_count + return 0 + + +def _utility_mode(prompt): + words = set(re.findall(r'[a-z0-9]+', prompt.lower())) + text = prompt.lower() + if 'word count' in text or 'word counting' in text or \ + ({'word', 'words'} & words and {'count', 'counting'} & words): + return 'word_counter' + if {'timer', 'stopwatch'} & words or 'time tracker' in text: + return 'timer' + if {'counter', 'count', 'tally', 'scorekeeper'} & words: + return 'counter' + return 'word_counter' + + +def _utility_mode_from_plan(spec, plan): + mode = plan.get('utility_mode') + if mode in ('word_counter', 'counter', 'timer'): + return mode + return _utility_mode(spec.prompt) + + +def _utility_summary(subject, mode): + if mode == 'timer': + return ( + 'A classroom timer for pacing, comparing, and reflecting on %s.' + % subject + ) + if mode == 'counter': + return ( + 'A simple counter for tallying observations and decisions about ' + '%s.' % subject + ) + return ( + 'A text tool that counts words and characters while learners inspect ' + '%s.' % subject + ) + + +def _utility_goal(subject, mode): + if mode == 'timer': + return 'Use elapsed time as evidence while working on %s.' % subject + if mode == 'counter': + return 'Track and explain counts or scores connected to %s.' % subject + return 'Measure and revise text connected to %s.' % subject + + +def _utility_steps(mode): + if mode == 'timer': + return [ + 'Start the timer before the activity round.', + 'Pause when the round ends.', + 'Compare elapsed time with the class goal.', + 'Reset or save the timing note in the Journal.', + ] + if mode == 'counter': + return [ + 'Press plus when an event or idea appears.', + 'Press minus to correct a tally.', + 'Explain what the count means.', + 'Reset or save the tally in the Journal.', + ] + return [ + 'Enter or paste the text to inspect.', + 'Read the word and character counts.', + 'Revise the text and compare the new count.', + 'Save the useful version to the Journal.', + ] + + +def _unique_strings(values, limit): + result = [] + seen = set() + for value in values: + text = str(value).strip() + key = text.lower() + if not text or key in seen: + continue + seen.add(key) + result.append(text) + if len(result) == limit: + break + return result + + +def _quiz_questions(spec): + subject = spec.name.lower() + return [ + { + 'question': 'What is the main idea in %s?' % subject, + 'answer': 'anything', + }, + { + 'question': 'Explain one example in your own words.', + 'answer': 'anything', + }, + { + 'question': 'What would you test or change next?', + 'answer': 'anything', + }, + ] + + +def _enriched_quiz_questions(spec, plan): + questions = plan.get('questions') or [] + if len(questions) >= 4: + return questions[:6] + + subject = _subject_from_spec(spec) + words = plan.get('word_bank') or [] + focus = words[0] if words else subject + generated = questions[:] + generated.extend([ + { + 'question': 'What is one important idea about %s?' % subject, + 'answer': 'anything', + }, + { + 'question': 'Give an example that uses %s.' % focus, + 'answer': 'anything', + }, + { + 'question': 'How would you explain %s to a partner?' % subject, + 'answer': 'anything', + }, + { + 'question': 'What would you change or test next?', + 'answer': 'anything', + }, + ]) + + cleaned = [] + seen = set() + for item in generated: + question = item.get('question', '').strip() + if not question or question.lower() in seen: + continue + seen.add(question.lower()) + cleaned.append({ + 'question': question, + 'answer': str(item.get('answer', 'anything')).strip() or + 'anything', + }) + if len(cleaned) == 6: + break + return cleaned + + +def _chess_should_show_move_log(spec, plan): + if isinstance(plan.get('chess_show_move_log'), bool): + return plan['chess_show_move_log'] + + prompt = spec.prompt.lower() + remove_words = ( + 'remove', 'hide', 'without', 'no ', 'don\'t', 'do not', 'disable', + 'clean', + ) + tracking_words = ( + 'move log', 'move history', 'history', 'tracking', 'track moves', + 'move tracking', 'move list', 'log panel', + ) + if any(word in prompt for word in remove_words) and \ + any(word in prompt for word in tracking_words): + return False + return True + + +def _subject_from_spec(spec): + words = _word_bank(spec.prompt) + if words: + return ' '.join(words[:3]) + return spec.name.lower() + + +def _identifier(name): + words = re.findall(r'[A-Za-z0-9]+', name) + identifier = ''.join(word.capitalize() for word in words) + if not identifier: + identifier = 'Generated' + if identifier[0].isdigit(): + identifier = 'Activity' + identifier + return identifier[:45] + + +def _new_project_path(output_root, name): + base = _identifier(name) + '.activity' + candidate = os.path.join(output_root, base) + suffix = 2 + while os.path.exists(candidate): + candidate = os.path.join( + output_root, + '%s%d.activity' % (_identifier(name), suffix), + ) + suffix += 1 + os.makedirs(candidate) + return candidate + + +_SETUP_SOURCE = """#!/usr/bin/python3 +# SPDX-License-Identifier: GPL-3.0-or-later + +from sugar3.activity import bundlebuilder + + +if __name__ == '__main__': + bundlebuilder.start() +""" + +_ACTIVITY_ICON = """ + + + + +""" diff --git a/src/jarabe/model/aodjobs.py b/src/jarabe/model/aodjobs.py new file mode 100644 index 0000000000..f943024f6e --- /dev/null +++ b/src/jarabe/model/aodjobs.py @@ -0,0 +1,233 @@ +# Copyright (C) 2026 Sugar Labs +# +# SPDX-License-Identifier: GPL-3.0-or-later + +from dataclasses import dataclass +from dataclasses import field +import json +import os +import threading +import time +import uuid + +from sugar3 import env + +from jarabe.model.aodspec import ActivitySpec + + +STATUS_QUEUED = 'queued' +STATUS_PLANNING = 'planning' +STATUS_GROUNDING = 'grounding' +STATUS_PROVIDER = 'provider' +STATUS_GENERATING = 'generating' +STATUS_VALIDATING = 'validating' +STATUS_PACKAGING = 'packaging' +STATUS_FINISHED = 'finished' +STATUS_FAILED = 'failed' +STATUS_CANCELLED = 'cancelled' + +TERMINAL_STATUSES = ( + STATUS_FINISHED, + STATUS_FAILED, + STATUS_CANCELLED, +) + + +@dataclass +class AODJob: + """A persistent Activity-on-Demand generation job.""" + + job_id: str + spec: ActivitySpec + provider_name: str = 'default' + use_rag: bool = True + output_root: str = '' + session_id: str = '' + parent_revision_id: str = '' + user_prompt: str = '' + status: str = STATUS_QUEUED + stage: str = STATUS_QUEUED + progress: float = 0.0 + message: str = 'Queued' + result_summary: dict = field(default_factory=dict) + draft_activity_source: str = '' + error: str = '' + created_at: float = field(default_factory=time.time) + updated_at: float = field(default_factory=time.time) + started_at: float = 0.0 + finished_at: float = 0.0 + cancel_requested: bool = False + result: object = None + + @classmethod + def create(cls, spec, provider_name='default', use_rag=True, + output_root=None, session_id='', parent_revision_id='', + user_prompt=''): + return cls( + job_id=uuid.uuid4().hex, + spec=spec.normalized(), + provider_name=provider_name, + use_rag=use_rag, + output_root=output_root or '', + session_id=session_id or '', + parent_revision_id=parent_revision_id or '', + user_prompt=user_prompt or spec.prompt, + ) + + @classmethod + def from_dict(cls, data): + job = cls( + job_id=data['job_id'], + spec=ActivitySpec.from_dict(data['spec']), + provider_name=data.get('provider_name', 'default'), + use_rag=data.get('use_rag', True), + output_root=data.get('output_root', ''), + session_id=data.get('session_id', ''), + parent_revision_id=data.get('parent_revision_id', ''), + user_prompt=data.get('user_prompt', ''), + status=data.get('status', STATUS_QUEUED), + stage=data.get('stage', STATUS_QUEUED), + progress=data.get('progress', 0.0), + message=data.get('message', ''), + result_summary=data.get('result_summary', {}), + draft_activity_source=data.get('draft_activity_source', ''), + error=data.get('error', ''), + created_at=data.get('created_at', time.time()), + updated_at=data.get('updated_at', time.time()), + started_at=data.get('started_at', 0.0), + finished_at=data.get('finished_at', 0.0), + cancel_requested=data.get('cancel_requested', False), + ) + return job + + def to_dict(self): + return { + 'job_id': self.job_id, + 'spec': self.spec.to_dict(), + 'provider_name': self.provider_name, + 'use_rag': self.use_rag, + 'output_root': self.output_root, + 'session_id': self.session_id, + 'parent_revision_id': self.parent_revision_id, + 'user_prompt': self.user_prompt, + 'status': self.status, + 'stage': self.stage, + 'progress': self.progress, + 'message': self.message, + 'result_summary': self.result_summary, + 'draft_activity_source': self.draft_activity_source, + 'error': self.error, + 'created_at': self.created_at, + 'updated_at': self.updated_at, + 'started_at': self.started_at, + 'finished_at': self.finished_at, + 'cancel_requested': self.cancel_requested, + } + + def is_terminal(self): + return self.status in TERMINAL_STATUSES + + def mark_started(self): + now = time.time() + self.started_at = self.started_at or now + self.updated_at = now + + def request_cancel(self): + self.cancel_requested = True + self.updated_at = time.time() + + def update_progress(self, status, stage, progress, message): + self.status = status + self.stage = stage + self.progress = max(0.0, min(1.0, float(progress))) + self.message = message + self.updated_at = time.time() + + def finish(self, result): + self.status = STATUS_FINISHED + self.stage = STATUS_FINISHED + self.progress = 1.0 + self.message = 'Activity project is ready' + self.result = result + self.result_summary = result_summary_from_generation(result) + self.finished_at = time.time() + self.updated_at = self.finished_at + + def fail(self, error): + self.status = STATUS_FAILED + self.stage = STATUS_FAILED + self.error = str(error) + self.message = str(error) + self.finished_at = time.time() + self.updated_at = self.finished_at + + def cancel(self): + self.status = STATUS_CANCELLED + self.stage = STATUS_CANCELLED + self.error = '' + self.message = 'Cancelled' + self.finished_at = time.time() + self.updated_at = self.finished_at + + +class AODJobStore: + """File-backed job store used by the local service.""" + + def __init__(self, root_path=None): + self._root_path = root_path or env.get_profile_path( + os.path.join('aod', 'jobs') + ) + self._lock = threading.RLock() + os.makedirs(self._root_path, exist_ok=True) + + def save(self, job): + with self._lock: + path = self._job_path(job.job_id) + tmp_path = path + '.tmp' + with open(tmp_path, 'w', encoding='utf-8') as output: + json.dump(job.to_dict(), output, indent=2, sort_keys=True) + output.write('\n') + os.replace(tmp_path, path) + + def load(self, job_id): + with self._lock: + path = self._job_path(job_id) + if not os.path.exists(path): + return None + with open(path, encoding='utf-8') as source: + return AODJob.from_dict(json.load(source)) + + def list_jobs(self): + with self._lock: + jobs = [] + for filename in os.listdir(self._root_path): + if not filename.endswith('.json'): + continue + path = os.path.join(self._root_path, filename) + try: + with open(path, encoding='utf-8') as source: + jobs.append(AODJob.from_dict(json.load(source))) + except (OSError, ValueError, KeyError, TypeError): + continue + jobs.sort(key=lambda job: job.created_at, reverse=True) + return jobs + + def _job_path(self, job_id): + safe_id = ''.join( + char for char in job_id + if char.isalnum() or char in ('-', '_') + ) + return os.path.join(self._root_path, safe_id + '.json') + + +def result_summary_from_generation(result): + return { + 'activity_name': result.spec.name, + 'bundle_id': result.bundle_id, + 'bundle_path': result.bundle_path, + 'project_path': result.project_path, + 'provider': result.provider, + 'model': result.model, + 'template': result.plan.get('template', ''), + 'code_source': result.plan.get('code_source', 'template'), + } diff --git a/src/jarabe/model/aodlicenses.py b/src/jarabe/model/aodlicenses.py new file mode 100644 index 0000000000..b039b6474a --- /dev/null +++ b/src/jarabe/model/aodlicenses.py @@ -0,0 +1,136 @@ +# Copyright (C) 2026 Sugar Labs +# +# SPDX-License-Identifier: GPL-3.0-or-later + +from dataclasses import dataclass +import os + + +@dataclass(frozen=True) +class LicenseInfo: + key: str + label: str + spdx: str + description: str + common_license_file: str = '' + fallback_text: str = '' + + def get_text(self): + if self.common_license_file: + path = os.path.join('/usr/share/common-licenses', + self.common_license_file) + try: + with open(path, encoding='utf-8') as license_file: + return license_file.read() + except OSError: + pass + return self.fallback_text + + +_MIT_TEXT = """MIT License + +Copyright (c) 2026 Sugar Labs and activity contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +_BSD_TEXT = """BSD 3-Clause License + +Copyright (c) 2026, Sugar Labs and activity contributors +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. +3. Neither the name of the copyright holder nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES ARE DISCLAIMED. +""" + + +def _fallback(spdx, url): + return ( + '%s\n\n' + 'This activity is licensed under %s. The canonical license text is ' + 'available at:\n%s\n' + ) % (spdx, spdx, url) + + +LICENSES = { + 'mit': LicenseInfo( + 'mit', 'MIT', 'MIT', 'Short permissive license', + fallback_text=_MIT_TEXT), + 'gplv3_plus': LicenseInfo( + 'gplv3_plus', 'GPLv3+', 'GPL-3.0-or-later', + 'Share-alike license used by many Sugar activities', + common_license_file='GPL-3', + fallback_text=_fallback( + 'GNU General Public License version 3 or later', + 'https://www.gnu.org/licenses/gpl-3.0.txt')), + 'apache_2': LicenseInfo( + 'apache_2', 'Apache', 'Apache-2.0', + 'Permissive license with an explicit patent grant', + common_license_file='Apache-2.0', + fallback_text=_fallback( + 'Apache License 2.0', + 'https://www.apache.org/licenses/LICENSE-2.0.txt')), + 'agplv3': LicenseInfo( + 'agplv3', 'AGPLv3', 'AGPL-3.0-or-later', + 'Network share-alike license', + fallback_text=_fallback( + 'GNU Affero General Public License version 3 or later', + 'https://www.gnu.org/licenses/agpl-3.0.txt')), + 'lgplv3': LicenseInfo( + 'lgplv3', 'LGPLv3', 'LGPL-3.0-or-later', + 'Library-focused share-alike license', + common_license_file='LGPL-3', + fallback_text=_fallback( + 'GNU Lesser General Public License version 3 or later', + 'https://www.gnu.org/licenses/lgpl-3.0.txt')), + 'mpl_2': LicenseInfo( + 'mpl_2', 'MPL-2.0', 'MPL-2.0', + 'File-level share-alike license', + common_license_file='MPL-2.0', + fallback_text=_fallback( + 'Mozilla Public License 2.0', + 'https://www.mozilla.org/MPL/2.0/')), + 'bsd_3': LicenseInfo( + 'bsd_3', 'BSD-3', 'BSD-3-Clause', + 'Permissive license with attribution', + fallback_text=_BSD_TEXT), +} + + +def get_license(value): + if value in LICENSES: + return LICENSES[value] + + for license_info in LICENSES.values(): + if value == license_info.spdx: + return license_info + + raise ValueError('Unknown activity license: %s' % value) diff --git a/src/jarabe/model/aodllm.py b/src/jarabe/model/aodllm.py new file mode 100644 index 0000000000..b53299577d --- /dev/null +++ b/src/jarabe/model/aodllm.py @@ -0,0 +1,1067 @@ +# Copyright (C) 2026 Sugar Labs +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import json +import os +import urllib.error +import urllib.parse +import urllib.request + +from jarabe.model.aodprompts import extract_json_object +from jarabe.model.aodcodegen import extract_activity_source +from jarabe.model.aodcodegen import extract_activity_source_from_response + + +class ProviderError(Exception): + pass + + +_CODEGEN_MAX_TOKENS = 16000 +_FREEMODEL_CODEGEN_MAX_OUTPUT_TOKENS = 16000 +_PROVIDER_PLAN_TIMEOUT = 120 +_PROVIDER_CODEGEN_TIMEOUT = 360 +_GEMINI_CODEGEN_MAX_OUTPUT_TOKENS = 20000 + +# Kimi K2.x on OpenRouter is a reasoning model with reasoning enabled by +# default (reasoning.default_enabled=true, mandatory=false). Reasoning +# tokens count against the completion budget, so the default 9000-token +# codegen cap is exhausted by chain-of-thought before activity.py is +# written, yielding finish_reason=length / empty content. For code +# generation we therefore bound the reasoning effort and give the model a +# larger completion budget so bounded reasoning plus a complete Sugar +# activity.py both fit. See OpenAICompatibleProvider._is_reasoning_codegen. +_OPENROUTER_REASONING_CODEGEN_MAX_TOKENS = 32000 +_OPENROUTER_REASONING_CODEGEN_EFFORT = 'low' +# Special effort values that map to disabling reasoning entirely. These are +# only safe for non-mandatory reasoning models; mandatory reasoning models +# reject disable, so if we can't tell, we fall back to a minimal effort. +_REASONING_DISABLE_VALUES = ('none', 'false', 'off', 'disabled') +_REASONING_SAFE_MINIMAL_EFFORT = 'minimal' + + +def _env_int(name, default): + try: + return int(os.environ.get(name, str(default))) + except ValueError: + return default + + +class LLMProvider: + name = 'provider' + model = '' + label = 'Provider' + + def generate_plan(self, system_prompt, user_prompt, + timeout=_PROVIDER_PLAN_TIMEOUT): + raise NotImplementedError + + def generate_activity_source(self, system_prompt, user_prompt, + timeout=_PROVIDER_CODEGEN_TIMEOUT): + """Return a complete generated activity.py source string.""" + return extract_activity_source( + self.generate_plan(system_prompt, user_prompt, timeout=timeout) + ) + + +class GeminiProvider(LLMProvider): + name = 'gemini' + label = 'Gemini' + + # Google Generative Language API safety settings. BLOCK_ONLY_HIGH keeps + # the planner permissive for educational prompts while still refusing + # content that the API classifies as highly harmful. + _SAFETY_SETTINGS = [ + { + 'category': 'HARM_CATEGORY_DANGEROUS_CONTENT', + 'threshold': 'BLOCK_ONLY_HIGH', + }, + { + 'category': 'HARM_CATEGORY_HATE_SPEECH', + 'threshold': 'BLOCK_ONLY_HIGH', + }, + { + 'category': 'HARM_CATEGORY_HARASSMENT', + 'threshold': 'BLOCK_ONLY_HIGH', + }, + { + 'category': 'HARM_CATEGORY_SEXUALLY_EXPLICIT', + 'threshold': 'BLOCK_ONLY_HIGH', + }, + ] + + def __init__(self, api_key=None, model=None, endpoint=None): + self._api_key = api_key or os.environ.get('GEMINI_API_KEY', '') + self.model = model or os.environ.get( + 'AOD_GEMINI_MODEL', + 'gemini-2.5-flash', + ) + self._endpoint = endpoint or ( + 'https://generativelanguage.googleapis.com/v1beta/models' + ) + if not self._api_key: + raise ProviderError('Gemini API key is not configured.') + + def generate_plan(self, system_prompt, user_prompt, + timeout=_PROVIDER_PLAN_TIMEOUT): + return self._generate_json(system_prompt, user_prompt, timeout) + + def generate_activity_source(self, system_prompt, user_prompt, + timeout=_PROVIDER_CODEGEN_TIMEOUT): + text = self._generate_content( + system_prompt, + user_prompt, + timeout, + max_output_tokens=_GEMINI_CODEGEN_MAX_OUTPUT_TOKENS, + response_json=False, + ) + return extract_activity_source_from_response(text) + + def _generate_json(self, system_prompt, user_prompt, timeout, + max_output_tokens=None): + return extract_json_object( + self._generate_content( + system_prompt, + user_prompt, + timeout, + max_output_tokens=max_output_tokens, + response_json=True, + ) + ) + + def _generate_content(self, system_prompt, user_prompt, timeout, + max_output_tokens=None, response_json=True): + model = urllib.parse.quote(self.model, safe='') + key = urllib.parse.quote(self._api_key, safe='') + url = '%s/%s:generateContent?key=%s' % ( + self._endpoint.rstrip('/'), + model, + key, + ) + generation_config = { + 'temperature': 0.3, + } + if response_json: + generation_config['responseMimeType'] = 'application/json' + if max_output_tokens is not None: + generation_config['maxOutputTokens'] = max_output_tokens + payload = { + 'systemInstruction': { + 'parts': [{'text': system_prompt}], + }, + 'contents': [{ + 'role': 'user', + 'parts': [{'text': user_prompt}], + }], + 'generationConfig': generation_config, + 'safetySettings': self._SAFETY_SETTINGS, + } + request = urllib.request.Request( + url, + data=json.dumps(payload).encode('utf-8'), + headers={'Content-Type': 'application/json'}, + method='POST', + ) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + response_data = json.loads(response.read().decode('utf-8')) + except urllib.error.HTTPError as error: + detail = error.read().decode('utf-8', errors='replace')[:500] + raise ProviderError( + 'Gemini request failed with HTTP %d: %s' + % (error.code, detail) + ) + except (OSError, ValueError) as error: + raise ProviderError('Gemini request failed: %s' % error) + + try: + candidate = response_data['candidates'][0] + finish_reason = candidate.get('finishReason', '') + if finish_reason and finish_reason != 'STOP': + raise ProviderError( + 'Gemini response was blocked: %s' % finish_reason + ) + parts = candidate['content']['parts'] + text = ''.join(part.get('text', '') for part in parts) + except (KeyError, IndexError, TypeError): + raise ProviderError( + 'Gemini response did not contain a result.' + ) + return text + + +class OpenAIProvider(LLMProvider): + name = 'openai' + label = 'OpenAI' + + def __init__(self, api_key=None, model=None, endpoint=None): + self._api_key = api_key or os.environ.get('OPENAI_API_KEY', '') + self.model = model or os.environ.get( + 'AOD_OPENAI_MODEL', + 'gpt-4.1-mini', + ) + self._endpoint = endpoint or os.environ.get( + 'AOD_OPENAI_ENDPOINT', + 'https://api.openai.com/v1/chat/completions', + ) + self._user_agent = 'SugarActivityOnDemand/1.0' + if not self._api_key: + raise ProviderError('OpenAI API key is not configured.') + + def generate_plan(self, system_prompt, user_prompt, + timeout=_PROVIDER_PLAN_TIMEOUT): + return self._generate_json(system_prompt, user_prompt, timeout) + + def generate_activity_source(self, system_prompt, user_prompt, + timeout=_PROVIDER_CODEGEN_TIMEOUT): + text = self._generate_text( + system_prompt, + user_prompt, + timeout, + max_tokens=_CODEGEN_MAX_TOKENS, + json_response=False, + ) + return extract_activity_source_from_response(text) + + def _generate_json(self, system_prompt, user_prompt, timeout, + max_tokens=None): + return extract_json_object( + self._generate_text( + system_prompt, + user_prompt, + timeout, + max_tokens=max_tokens, + json_response=True, + ) + ) + + def _generate_text(self, system_prompt, user_prompt, timeout, + max_tokens=None, json_response=True): + payload = { + 'model': self.model, + 'messages': [ + {'role': 'system', 'content': system_prompt}, + {'role': 'user', 'content': user_prompt}, + ], + 'temperature': self._generation_temperature(), + } + if json_response: + payload['response_format'] = {'type': 'json_object'} + if max_tokens is not None: + payload['max_tokens'] = max_tokens + payload.update(self._extra_generation_params(max_tokens, + json_response)) + response_data = _post_json( + self._endpoint, + payload, + self._request_headers(), + timeout, + self._request_label(), + ) + text = _chat_completion_message_text( + response_data, + self._request_label(), + ) + return text + + def _extra_generation_params(self, max_tokens, json_response): + """Return extra chat-completion payload fields for a request. + + Subclasses can override this to inject provider/model specific + parameters (for example reasoning-effort control or a larger + completion budget) without rebuilding the whole payload. + """ + return {} + + def _generation_temperature(self): + return 0.3 + + def _request_headers(self): + return { + 'Authorization': 'Bearer %s' % self._api_key, + 'Content-Type': 'application/json', + 'User-Agent': self._user_agent, + } + + def _request_label(self): + return self.label + + +class OpenAICompatibleProvider(OpenAIProvider): + """OpenAI-compatible endpoints for DeepSeek, Qwen, Moonshot, etc.""" + + _CONFIG = { + 'deepseek': { + 'label': 'DeepSeek', + 'key_env': 'DEEPSEEK_API_KEY', + 'model_env': 'AOD_DEEPSEEK_MODEL', + 'endpoint_env': 'AOD_DEEPSEEK_ENDPOINT', + 'default_model': 'deepseek-chat', + 'default_endpoint': 'https://api.deepseek.com/v1/chat/completions', + }, + 'qwen': { + 'label': 'Qwen', + 'key_env': 'QWEN_API_KEY', + 'model_env': 'AOD_QWEN_MODEL', + 'endpoint_env': 'AOD_QWEN_ENDPOINT', + 'default_model': 'qwen-turbo', + 'default_endpoint': ( + 'https://dashscope.aliyuncs.com/' + 'compatible-mode/v1/chat/completions' + ), + }, + 'openrouter': { + 'label': 'OpenRouter', + 'key_env': 'OPENROUTER_API_KEY', + 'model_env': 'AOD_OPENROUTER_MODEL', + 'endpoint_env': 'AOD_OPENROUTER_ENDPOINT', + 'default_model': 'moonshotai/kimi-k2.6', + 'default_endpoint': ( + 'https://openrouter.ai/api/v1/chat/completions' + ), + }, + 'moonshot': { + 'label': 'Moonshot', + 'key_env': 'MOONSHOT_API_KEY', + 'model_env': 'AOD_MOONSHOT_MODEL', + 'endpoint_env': 'AOD_MOONSHOT_ENDPOINT', + 'default_model': 'moonshot-v1-8k', + 'default_endpoint': 'https://api.moonshot.cn/v1/chat/completions', + }, + 'opencode': { + 'label': 'OpenCode Zen', + 'key_env': 'OPENCODE_API_KEY', + 'model_env': 'AOD_OPENCODE_MODEL', + 'endpoint_env': 'AOD_OPENCODE_ENDPOINT', + 'default_model': 'claude-sonnet-4-6', + 'default_endpoint': 'https://opencode.ai/zen/v1/chat/completions', + }, + 'opencode-go': { + 'label': 'OpenCode Go', + 'key_env': 'OPENCODE_API_KEY', + 'model_env': 'AOD_OPENCODE_GO_MODEL', + 'endpoint_env': 'AOD_OPENCODE_GO_ENDPOINT', + # Kimi K2.7 Code is the code-tuned variant and reliably + # produces complete activity.py sources. Kimi K2.6 is a + # reasoning model that often exhausts its output budget on + # chain-of-thought before writing the activity; it remains + # selectable via the model field. + 'default_model': 'kimi-k2.7-code', + 'default_endpoint': ( + 'https://opencode.ai/zen/go/v1/chat/completions' + ), + }, + } + + def __init__(self, api_key=None, model=None, endpoint=None, + provider_name='openai-compatible'): + config = self._CONFIG.get(provider_name, {}) + self._provider_name = provider_name + self.name = provider_name + self.label = config.get('label', provider_name.capitalize()) + self._api_key = api_key or os.environ.get( + config.get('key_env', 'OPENAI_API_KEY'), '' + ) + self.model = model or os.environ.get( + config.get('model_env', 'AOD_OPENAI_MODEL'), + config.get('default_model', 'gpt-4.1-mini'), + ) + self._endpoint = endpoint or os.environ.get( + config.get('endpoint_env', 'AOD_OPENAI_ENDPOINT'), + config.get( + 'default_endpoint', + 'https://api.openai.com/v1/chat/completions', + ), + ) + self._user_agent = 'SugarActivityOnDemand/1.0' + # OpenCode's API is fronted by Cloudflare and requires an + # OpenCode-identifying User-Agent to avoid a 1010 block. + if provider_name in ('opencode', 'opencode-go'): + self._user_agent = 'OpenCode-AI-SDK/1.0' + if not self._api_key: + raise ProviderError('%s API key is not configured.' % self.label) + + def _generation_temperature(self): + if self._provider_name == 'opencode-go' and \ + self.model == 'kimi-k2.7-code': + return 1.0 + return OpenAIProvider._generation_temperature(self) + + def _request_headers(self): + headers = OpenAIProvider._request_headers(self) + if self._provider_name == 'openrouter': + headers['HTTP-Referer'] = 'https://www.sugarlabs.org/' + headers['X-Title'] = 'Sugar Activity on Demand' + return headers + + def _extra_generation_params(self, max_tokens, json_response): + params = {} + # The activity-codegen call uses json_response=False with a + # bounded max_tokens. Plan calls (json_response=True) leave + # max_tokens uncapped, so reasoning has room and we do not need + # to intervene there. + if not self._is_reasoning_codegen(json_response): + return params + + budget = max_tokens or 0 + budget = max( + budget, + _env_int('AOD_OPENROUTER_CODEGEN_MAX_TOKENS', + _OPENROUTER_REASONING_CODEGEN_MAX_TOKENS), + ) + params['max_tokens'] = budget + + effort = os.environ.get( + 'AOD_OPENROUTER_CODEGEN_REASONING_EFFORT', + _OPENROUTER_REASONING_CODEGEN_EFFORT, + ) + if effort: + effort = effort.strip() + if effort.lower() in _REASONING_DISABLE_VALUES: + # Disabling reasoning is only valid for non-mandatory + # models; we don't fetch the catalog at runtime, so when + # the user asks to disable we degrade to the smallest + # accepted effort rather than risk an API rejection on a + # mandatory-reasoning model. + effort = _REASONING_SAFE_MINIMAL_EFFORT + params['reasoning'] = {'effort': effort} + return params + + def _is_reasoning_codegen(self, json_response): + """True for a reasoning-capable Kimi model on OpenRouter during + activity-codegen (non-JSON) calls, where the default reasoning + budget would otherwise starve the generated activity.py.""" + if json_response: + return False + if self._provider_name != 'openrouter': + return False + model = (self.model or '').lower().lstrip('~') + return model.startswith('moonshotai/kimi') + + +class FreeModelProvider(LLMProvider): + name = 'freemodel' + label = 'FreeModel' + + def __init__(self, api_key=None, model=None, endpoint=None): + self._api_key = api_key or os.environ.get('FREEMODEL_API_KEY', '') + self.model = model or os.environ.get( + 'AOD_FREEMODEL_MODEL', + 'gpt-5.5', + ) + self._endpoint = _responses_endpoint( + endpoint or os.environ.get( + 'AOD_FREEMODEL_ENDPOINT', + 'https://api.freemodel.dev', + ) + ) + self._reasoning_effort = os.environ.get( + 'AOD_FREEMODEL_REASONING_EFFORT', + 'xhigh', + ) + self._codegen_reasoning_effort = os.environ.get( + 'AOD_FREEMODEL_CODEGEN_REASONING_EFFORT', + 'high', + ) + if not self._api_key: + raise ProviderError('FreeModel API key is not configured.') + + def generate_plan(self, system_prompt, user_prompt, + timeout=_PROVIDER_PLAN_TIMEOUT): + return self._generate_json(system_prompt, user_prompt, timeout) + + def generate_activity_source(self, system_prompt, user_prompt, + timeout=_PROVIDER_CODEGEN_TIMEOUT): + text = self._generate_responses_text( + system_prompt, + user_prompt, + timeout, + max_output_tokens=_FREEMODEL_CODEGEN_MAX_OUTPUT_TOKENS, + reasoning_effort=self._codegen_reasoning_effort, + ) + return extract_activity_source_from_response(text) + + def _generate_json(self, system_prompt, user_prompt, timeout, + max_output_tokens=None, reasoning_effort=None): + return extract_json_object( + self._generate_responses_text( + system_prompt, + user_prompt, + timeout, + max_output_tokens=max_output_tokens, + reasoning_effort=reasoning_effort, + ) + ) + + def _generate_responses_text(self, system_prompt, user_prompt, timeout, + max_output_tokens=None, reasoning_effort=None): + payload = { + 'model': self.model, + 'instructions': system_prompt, + 'input': user_prompt, + } + effort = reasoning_effort + if effort is None: + effort = self._reasoning_effort + if effort: + payload['reasoning'] = {'effort': effort} + if max_output_tokens is not None: + payload['max_output_tokens'] = max_output_tokens + + response_data = _post_json( + self._endpoint, + payload, + { + 'Authorization': 'Bearer %s' % self._api_key, + 'Content-Type': 'application/json', + }, + timeout, + 'FreeModel', + ) + return _responses_text(response_data) + + +class ClaudeProvider(LLMProvider): + name = 'claude' + label = 'Claude' + + def __init__(self, api_key=None, model=None, endpoint=None): + self._api_key = api_key or os.environ.get('ANTHROPIC_API_KEY', '') + self.model = model or os.environ.get( + 'AOD_CLAUDE_MODEL', + 'claude-3-5-haiku-latest', + ) + self._endpoint = endpoint or os.environ.get( + 'AOD_CLAUDE_ENDPOINT', + 'https://api.anthropic.com/v1/messages', + ) + if not self._api_key: + raise ProviderError('Claude API key is not configured.') + + def generate_plan(self, system_prompt, user_prompt, + timeout=_PROVIDER_PLAN_TIMEOUT): + return self._generate_json( + system_prompt, + user_prompt, + timeout, + max_tokens=1600, + ) + + def generate_activity_source(self, system_prompt, user_prompt, + timeout=_PROVIDER_CODEGEN_TIMEOUT): + text = self._generate_text( + system_prompt, + user_prompt, + timeout, + max_tokens=_CODEGEN_MAX_TOKENS, + ) + return extract_activity_source_from_response(text) + + def _generate_json(self, system_prompt, user_prompt, timeout, + max_tokens): + return extract_json_object( + self._generate_text(system_prompt, user_prompt, timeout, + max_tokens=max_tokens) + ) + + def _generate_text(self, system_prompt, user_prompt, timeout, + max_tokens): + payload = { + 'model': self.model, + 'max_tokens': max_tokens, + 'temperature': 0.3, + 'system': system_prompt, + 'messages': [ + {'role': 'user', 'content': user_prompt}, + ], + } + response_data = _post_json( + self._endpoint, + payload, + { + 'anthropic-version': os.environ.get( + 'AOD_CLAUDE_VERSION', + '2023-06-01', + ), + 'x-api-key': self._api_key, + 'Content-Type': 'application/json', + }, + timeout, + 'Claude', + ) + try: + parts = response_data['content'] + text = ''.join( + part.get('text', '') for part in parts + if part.get('type') == 'text' + ) + except (KeyError, TypeError): + raise ProviderError('Claude response did not contain a result.') + return text + + +class OllamaProvider(LLMProvider): + name = 'ollama' + label = 'Ollama' + + def __init__(self, model=None, endpoint=None): + self.model = model or os.environ.get( + 'AOD_OLLAMA_MODEL', + 'llama3.1', + ) + base_url = os.environ.get( + 'AOD_OLLAMA_URL', + os.environ.get('OLLAMA_HOST', 'http://127.0.0.1:11434'), + ) + self._endpoint = endpoint or os.environ.get( + 'AOD_OLLAMA_ENDPOINT', + '%s/api/generate' % base_url.rstrip('/'), + ) + + def generate_plan(self, system_prompt, user_prompt, + timeout=_PROVIDER_PLAN_TIMEOUT): + return self._generate_json(system_prompt, user_prompt, timeout) + + def generate_activity_source(self, system_prompt, user_prompt, + timeout=_PROVIDER_CODEGEN_TIMEOUT): + text = self._generate_text( + system_prompt, + user_prompt, + timeout, + num_predict=_CODEGEN_MAX_TOKENS, + ) + return extract_activity_source_from_response(text) + + def _generate_json(self, system_prompt, user_prompt, timeout, + num_predict=None): + return extract_json_object( + self._generate_text( + system_prompt, + user_prompt, + timeout, + num_predict=num_predict, + json_mode=True, + ) + ) + + def _generate_text(self, system_prompt, user_prompt, timeout, + num_predict=None, json_mode=False): + options = { + 'temperature': 0.3, + } + if num_predict is not None: + options['num_predict'] = num_predict + payload = { + 'model': self.model, + 'prompt': '%s\n\n%s' % (system_prompt, user_prompt), + 'stream': False, + 'options': options, + } + if json_mode: + payload['format'] = 'json' + response_data = _post_json( + self._endpoint, + payload, + {'Content-Type': 'application/json'}, + timeout, + 'Ollama', + ) + try: + text = response_data['response'] + except (KeyError, TypeError): + raise ProviderError('Ollama response did not contain a result.') + return text + + +def _responses_endpoint(endpoint): + endpoint = (endpoint or 'https://api.freemodel.dev').strip().rstrip('/') + if endpoint.endswith('/responses'): + return endpoint + if endpoint.endswith('/v1'): + return endpoint + '/responses' + return endpoint + '/v1/responses' + + +def _responses_text(response_data): + direct_text = response_data.get('output_text') + if isinstance(direct_text, str) and direct_text: + return direct_text + + blocks = [] + output = response_data.get('output') + if isinstance(output, list): + for item in output: + if not isinstance(item, dict): + continue + text = item.get('text') + if isinstance(text, str): + blocks.append(text) + content = item.get('content') + if isinstance(content, str): + blocks.append(content) + elif isinstance(content, list): + for content_item in content: + if not isinstance(content_item, dict): + continue + text = content_item.get('text') + if isinstance(text, str): + blocks.append(text) + + if blocks: + return ''.join(blocks) + + raise ProviderError('FreeModel response did not contain a result.') + + +def _chat_completion_message_text(response_data, label): + try: + choices = response_data['choices'] + choice = choices[0] + except (KeyError, IndexError, TypeError): + raise ProviderError('%s response did not contain a result.' % label) + + if not isinstance(choice, dict): + raise ProviderError('%s response did not contain a result.' % label) + + message = choice.get('message') or choice.get('delta') or {} + if not isinstance(message, dict): + raise ProviderError('%s response did not contain a result.' % label) + + text = _content_to_text(message.get('content')).strip() + if text: + return text + + refusal = ( + _content_to_text(message.get('refusal')).strip() or + _content_refusal_text(message.get('content')).strip() + ) + if refusal: + raise ProviderError( + '%s refused the request: %s' % (label, refusal[:300]) + ) + + tool_calls = message.get('tool_calls') or choice.get('tool_calls') + if tool_calls: + raise ProviderError( + '%s returned tool calls instead of activity text. Select a ' + 'text/code chat model for generation.' % label + ) + + finish_reason = ( + choice.get('finish_reason') or choice.get('finishReason') or '' + ) + if finish_reason: + raise ProviderError( + _chat_finish_reason_error(label, finish_reason, message) + ) + + reasoning = ( + _content_to_text(message.get('reasoning_content')).strip() or + _content_to_text(message.get('reasoning')).strip() + ) + if reasoning: + raise ProviderError( + '%s returned reasoning but no final activity text. Try rerunning ' + 'with a smaller prompt or a text/code model that returns normal ' + 'assistant content.' % label + ) + + raise ProviderError( + '%s returned an empty assistant message with no activity text content.' + % label + ) + + +def _content_refusal_text(value): + if isinstance(value, list): + blocks = [] + for item in value: + if isinstance(item, dict): + blocks.append(_content_to_text(item.get('refusal'))) + return ''.join(blocks) + if isinstance(value, dict): + return _content_to_text(value.get('refusal')) + return '' + + +def _chat_finish_reason_error(label, finish_reason, message): + reason = str(finish_reason) + if reason == 'length': + return ( + '%s stopped before returning activity.py ' + '(finish_reason=length). Try a smaller activity prompt or a ' + 'model with a larger output budget.' % label + ) + if reason in ('content_filter', 'safety'): + return ( + '%s blocked the activity response ' + '(finish_reason=%s). Try a classroom-safe wording or a different ' + 'model.' % (label, reason) + ) + if reason == 'tool_calls': + return ( + '%s returned tool calls instead of activity text. Select a ' + 'text/code chat model for generation.' % label + ) + + detail = _content_to_text(message.get('reasoning_content')).strip() + if detail: + return ( + '%s returned no final activity text ' + '(finish_reason=%s) after reasoning. Try rerunning with a smaller ' + 'prompt or a different OpenRouter route/model.' + % (label, reason) + ) + return ( + '%s returned no activity text (finish_reason=%s). Try rerunning or ' + 'switching to a text/code model such as moonshotai/kimi-k2.6.' + % (label, reason) + ) + + +def _raise_response_error(response_data, label): + if not isinstance(response_data, dict): + return + error = response_data.get('error') + if not error: + return + + if isinstance(error, dict): + message = _content_to_text(error.get('message')).strip() + if not message: + message = _content_to_text(error.get('detail')).strip() + code = error.get('code') or error.get('type') + if code and message: + message = '%s: %s' % (code, message) + elif code: + message = str(code) + if not message: + try: + message = json.dumps(error, sort_keys=True)[:500] + except (TypeError, ValueError): + message = str(error)[:500] + else: + message = str(error)[:500] + + raise ProviderError('%s request failed: %s' % (label, message)) + + +def _content_to_text(value): + if isinstance(value, str): + return value + if isinstance(value, list): + return ''.join(_content_to_text(item) for item in value) + if isinstance(value, dict): + blocks = [] + for key in ('text', 'content', 'output_text', 'value'): + text = _content_to_text(value.get(key)) + if text: + blocks.append(text) + parts = value.get('parts') + if isinstance(parts, list): + blocks.append(_content_to_text(parts)) + return ''.join(blocks) + return '' + + +def _post_json(url, payload, headers, timeout, label): + request = urllib.request.Request( + url, + data=json.dumps(payload).encode('utf-8'), + headers=headers, + method='POST', + ) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + response_data = json.loads(response.read().decode('utf-8')) + except urllib.error.HTTPError as error: + detail = error.read().decode('utf-8', errors='replace')[:500] + raise ProviderError( + '%s request failed with HTTP %d: %s' + % (label, error.code, detail) + ) + except (OSError, ValueError) as error: + raise ProviderError('%s request failed: %s' % (label, error)) + _raise_response_error(response_data, label) + return response_data + + +_PROVIDER_FACTORIES = { + 'gemini': GeminiProvider, + 'openai': OpenAIProvider, + 'openrouter': OpenAICompatibleProvider, + 'deepseek': OpenAICompatibleProvider, + 'qwen': OpenAICompatibleProvider, + 'moonshot': OpenAICompatibleProvider, + 'opencode': OpenAICompatibleProvider, + 'opencode-go': OpenAICompatibleProvider, + 'freemodel': FreeModelProvider, + 'claude': ClaudeProvider, + 'ollama': OllamaProvider, +} + + +def normalize_provider_name(provider_name): + name = (provider_name or 'default').strip().lower() + aliases = { + 'anthropic': 'claude', + 'codex': 'openai', + 'default': 'default', + 'deepseek': 'deepseek', + 'freemodel': 'freemodel', + 'gemini': 'gemini', + 'google': 'gemini', + 'local': 'local-template', + 'local-template': 'local-template', + 'moonshot': 'moonshot', + 'none': 'local-template', + 'offline': 'local-template', + 'ollama': 'ollama', + 'opencode': 'opencode', + 'opencode-go': 'opencode-go', + 'openai': 'openai', + 'openrouter': 'openrouter', + 'qwen': 'qwen', + 'template': 'local-template', + } + return aliases.get(name, name) + + +def create_provider(provider_name, api_key=None, model=None, endpoint=None): + """Create a provider from runtime settings without persisting secrets.""" + requested = normalize_provider_name(provider_name) + if requested == 'default': + requested = get_default_provider_name() + if requested == 'local-template': + return None + + factory = _PROVIDER_FACTORIES.get(requested) + if factory is None: + raise ProviderError('Unknown LLM provider: %s' % requested) + + settings = {} + if model: + settings['model'] = model + if endpoint: + settings['endpoint'] = endpoint + if requested != 'ollama' and api_key: + settings['api_key'] = api_key + if issubclass(factory, OpenAICompatibleProvider): + settings['provider_name'] = requested + return factory(**settings) + + +def get_default_provider_name(): + requested = normalize_provider_name( + os.environ.get('AOD_LLM_PROVIDER', 'default') + ) + if requested != 'default': + return requested + + for name in ('gemini', 'openai', 'openrouter', 'claude', 'deepseek', + 'qwen', 'moonshot', 'opencode', 'opencode-go', + 'freemodel'): + if _is_cloud_provider_configured(name): + return name + if os.environ.get('AOD_OLLAMA_MODEL') or \ + os.environ.get('AOD_OLLAMA_URL') or \ + os.environ.get('OLLAMA_HOST'): + return 'ollama' + return 'local-template' + + +def get_local_provider_name(): + if _is_provider_configured('ollama'): + return 'ollama' + return 'local-template' + + +def get_configured_provider(provider_name='default'): + return create_provider(provider_name) + + +def get_provider_statuses(): + """Return provider availability for preferences and diagnostics.""" + statuses = [{ + 'name': 'local-template', + 'label': 'Local template planner', + 'available': True, + 'configured': True, + 'model': 'template', + 'reason': '', + }] + + for name in ('gemini', 'openai', 'openrouter', 'deepseek', 'qwen', + 'moonshot', 'opencode', 'opencode-go', 'freemodel', + 'claude', 'ollama'): + configured = _is_provider_configured(name) + statuses.append({ + 'name': name, + 'label': _provider_label(name), + 'available': configured, + 'configured': configured, + 'model': _provider_model(name), + 'reason': '' if configured else _provider_missing_reason(name), + }) + return statuses + + +def _is_provider_configured(name): + if name == 'ollama': + provider = normalize_provider_name( + os.environ.get('AOD_LLM_PROVIDER', '') + ) + return any(( + os.environ.get('AOD_OLLAMA_MODEL'), + os.environ.get('AOD_OLLAMA_URL'), + os.environ.get('OLLAMA_HOST'), + provider == 'ollama', + )) + return _is_cloud_provider_configured(name) + + +def _is_cloud_provider_configured(name): + key_name = _provider_key_env(name) + return bool(key_name and os.environ.get(key_name)) + + +def _provider_label(name): + if name in OpenAICompatibleProvider._CONFIG: + return OpenAICompatibleProvider._CONFIG[name]['label'] + return _PROVIDER_FACTORIES[name].label + + +def _provider_key_env(name): + if name in OpenAICompatibleProvider._CONFIG: + return OpenAICompatibleProvider._CONFIG[name]['key_env'] + key_by_name = { + 'freemodel': 'FREEMODEL_API_KEY', + 'gemini': 'GEMINI_API_KEY', + 'openai': 'OPENAI_API_KEY', + 'claude': 'ANTHROPIC_API_KEY', + } + return key_by_name.get(name) + + +def _provider_model(name): + model_env = { + 'gemini': ('AOD_GEMINI_MODEL', 'gemini-2.5-flash'), + 'openai': ('AOD_OPENAI_MODEL', 'gpt-4.1-mini'), + 'openrouter': ('AOD_OPENROUTER_MODEL', 'moonshotai/kimi-k2.6'), + 'deepseek': ('AOD_DEEPSEEK_MODEL', 'deepseek-chat'), + 'qwen': ('AOD_QWEN_MODEL', 'qwen-turbo'), + 'moonshot': ('AOD_MOONSHOT_MODEL', 'moonshot-v1-8k'), + 'opencode': ('AOD_OPENCODE_MODEL', 'claude-sonnet-4-6'), + 'opencode-go': ('AOD_OPENCODE_GO_MODEL', 'kimi-k2.7-code'), + 'freemodel': ('AOD_FREEMODEL_MODEL', 'gpt-5.5'), + 'claude': ('AOD_CLAUDE_MODEL', 'claude-3-5-haiku-latest'), + 'ollama': ('AOD_OLLAMA_MODEL', 'llama3.1'), + } + env_name, default = model_env[name] + return os.environ.get(env_name, default) + + +def _provider_missing_reason(name): + if name == 'ollama': + return 'Set AOD_LLM_PROVIDER=ollama or AOD_OLLAMA_MODEL.' + return 'Set %s.' % _provider_key_env(name) diff --git a/src/jarabe/model/aodpipeline.py b/src/jarabe/model/aodpipeline.py new file mode 100644 index 0000000000..61d952f0b5 --- /dev/null +++ b/src/jarabe/model/aodpipeline.py @@ -0,0 +1,378 @@ +# Copyright (C) 2026 Sugar Labs +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import json +import os +import time + +from sugar3 import env + +from jarabe.model.aodcodegen import build_codegen_system_prompt +from jarabe.model.aodcodegen import build_codegen_user_prompt +from jarabe.model.aodgenerator import build_plan +from jarabe.model.aodgenerator import create_prototype_activity +from jarabe.model.aodgenerator import enrich_plan +from jarabe.model.aodgenerator import normalize_plan +from jarabe.model.aodgenerator import package_project +from jarabe.model.aodgenerator import read_project_files +from jarabe.model.aodllm import ProviderError +from jarabe.model.aodllm import get_configured_provider +from jarabe.model.aodprompts import build_system_prompt +from jarabe.model.aodprompts import build_user_prompt +from jarabe.model.aodrag import build_corpus +from jarabe.model.aodrag import search +from jarabe.model.aodvalidator import validate_bundle +from jarabe.model.aodvalidator import validate_activity_source_for_request +from jarabe.model.aodvalidator import validate_project + + +class PipelineError(Exception): + pass + + +_LOCAL_PROVIDER_NAMES = ('local', 'local-template') + + +def _env_int(name, default): + try: + return int(os.environ.get(name, str(default))) + except ValueError: + return default + + +_CODEGEN_ATTEMPT_LIMIT = _env_int('AOD_CODEGEN_ATTEMPT_LIMIT', 3) + + +def generate_activity(spec, output_root=None, provider=None, + provider_name='default', use_rag=True, + progress_cb=None, pace=False, package_bundle=True, + template_fallback=False): + """Run prompt grounding, provider planning, generation, and validation. + + When template_fallback is True and the provider fails to deliver valid + activity code, the pipeline renders activity.py from the local template + using the provider's plan instead of raising. The plan records + codegen_fallback_reason so callers can surface what happened to the user. + The default stays False so the pipeline's strict no-fallback contract + remains the default for tests and CLI callers that want to fail fast. + """ + progress = _PipelineProgress(progress_cb, pace) + progress.report('planning', 0.06, + 'Reading the prompt and classroom goal') + selected_provider = provider + provider_error = '' + references = [] + provider_required = ( + selected_provider is not None or + provider_name not in _LOCAL_PROVIDER_NAMES + ) + + if selected_provider is None and provider_name not in ( + 'local', 'local-template'): + try: + selected_provider = get_configured_provider(provider_name) + except ProviderError as error: + provider_error = str(error) + + if provider_required and selected_provider is None: + if provider_error: + raise PipelineError( + 'Configured model is required for RAG generation: %s' + % provider_error + ) + raise PipelineError( + 'No configured model is available. Save an API key and choose ' + 'a provider before generating.' + ) + + if selected_provider is not None: + use_rag = True + + if selected_provider is not None: + progress.report('planning', 0.16, + 'Preparing Sugar example context for the model') + else: + progress.report('planning', 0.16, + 'Drafting local activity structure') + local_plan = build_plan(spec) + + if use_rag: + if selected_provider is not None: + progress.report('grounding', 0.24, + 'Retrieving Sugar activity examples for context') + template_filter = '' + reference_limit = 10 + else: + progress.report('grounding', 0.24, + 'Searching Sugar activity patterns') + template_filter = local_plan['template'] + reference_limit = 4 + corpus = build_corpus() + references = search( + spec.prompt, + limit=reference_limit, + template=template_filter, + corpus=corpus, + ) + progress.report('grounding', 0.34, + 'Selecting useful Sugar API and interaction patterns') + + if selected_provider is not None: + system_prompt = build_system_prompt(spec, references) + user_prompt = build_user_prompt(spec) + progress.report('provider', 0.43, + 'Asking the configured model to plan from RAG context') + try: + provider_plan = selected_provider.generate_plan( + system_prompt, + user_prompt, + ) + plan = normalize_plan(spec, provider_plan) + provider_used = selected_provider.name + model_used = selected_provider.model + progress.report('provider', 0.52, + 'Checking the model plan') + except (ProviderError, ValueError) as error: + provider_error = _redact_provider_error( + error, + selected_provider, + ) + if not template_fallback: + raise PipelineError( + 'Provider did not answer: %s' % provider_error + ) + # Provider unavailable but caller asked for graceful degradation: + # build the activity from the local template so the user still + # gets something usable without burning further API credits. + progress.report( + 'provider', 0.46, + 'Provider did not answer; using local template instead', + ) + plan = build_plan(spec) + provider_used = 'local' + model_used = '' + selected_provider = None + else: + plan = local_plan + provider_used = 'local' + model_used = '' + progress.report('provider', 0.43, + 'Using the local activity builder') + + if output_root is None: + output_root = env.get_profile_path(os.path.join('aod', 'projects')) + + plan = enrich_plan(spec, plan, references) + plan = dict(plan) + plan['provider'] = provider_used + plan['model'] = model_used + if provider_error: + plan['provider_fallback_reason'] = provider_error + + activity_source = None + plan['code_source'] = 'template' + if selected_provider is not None and provider_used != 'local': + activity_source, code_error, code_attempts = ( + _generate_activity_source_with_provider( + selected_provider, + spec, + plan, + references, + progress, + ) + ) + plan['codegen_attempts'] = code_attempts + if activity_source: + plan['code_source'] = 'provider' + plan['codegen_provider'] = selected_provider.name + plan['codegen_model'] = selected_provider.model + elif code_error: + if template_fallback: + plan['codegen_fallback_reason'] = code_error + plan['code_source'] = 'template_after_codegen_failure' + progress.report( + 'generating', 0.58, + 'Provider code failed validation; using local ' + 'template instead', + ) + else: + raise PipelineError( + 'Provider could not generate valid activity code: %s' + % code_error + ) + else: + # Provider only supports planning, not code generation. + # Fall back to the local template renderer. + plan['codegen_fallback_reason'] = ( + 'Provider does not support activity source generation; ' + 'using template renderer.' + ) + + progress.report('generating', 0.60, + 'Expanding the plan into activity screens') + result = create_prototype_activity( + spec, + output_root, + plan=plan, + package_bundle=False, + activity_source=activity_source, + ) + result.provider = provider_used + result.model = model_used + + progress.report('validating', 0.78, + 'Checking Python source and activity metadata') + project_report = validate_project(result.project_path) + if project_report.errors: + raise PipelineError('\n'.join(project_report.errors)) + result.plan['validation'] = { + 'project_warnings': project_report.warnings, + } + plan_path = os.path.join(result.project_path, 'aod_plan.json') + with open(plan_path, 'w', encoding='utf-8') as plan_file: + json.dump(result.plan, plan_file, indent=2, sort_keys=True) + plan_file.write('\n') + + if package_bundle: + progress.report('packaging', 0.88, + 'Packaging the XO bundle') + package_generation_result(result) + progress.report('validating', 0.94, + 'Validating the packaged XO') + + progress.report('ready', 1.0, 'Activity project is ready') + return result + + +def package_generation_result(result): + """Build and validate the XO bundle for an already generated project.""" + if result.bundle_path and os.path.isfile(result.bundle_path): + return result.bundle_path + + result.bundle_path = package_project(result.project_path) + bundle_report = validate_bundle(result.bundle_path) + if bundle_report.errors: + raise PipelineError('\n'.join(bundle_report.errors)) + + validation = result.plan.setdefault('validation', {}) + validation['bundle_warnings'] = bundle_report.warnings + plan_path = os.path.join(result.project_path, 'aod_plan.json') + with open(plan_path, 'w', encoding='utf-8') as plan_file: + json.dump(result.plan, plan_file, indent=2, sort_keys=True) + plan_file.write('\n') + result.files = read_project_files(result.project_path) + return result.bundle_path + + +def _progress(callback, stage, fraction, message, metadata=None): + if callback is not None: + try: + if metadata is None: + callback(stage, fraction, message) + else: + callback(stage, fraction, message, metadata) + except TypeError: + callback(stage, fraction, message) + + +class _PipelineProgress: + """Progress reporter with optional UI pacing for real service jobs.""" + + def __init__(self, callback, pace=False): + self._callback = callback + self._pace = pace + + def report(self, stage, fraction, message, metadata=None): + _progress(self._callback, stage, fraction, message, metadata) + if not self._pace: + return + + end_time = time.time() + 0.45 + while time.time() < end_time: + time.sleep(0.09) + _progress(self._callback, stage, fraction, message) + + +def _redact_provider_error(error, provider): + message = str(error) + api_key = getattr(provider, '_api_key', '') + if api_key: + message = message.replace(api_key, '[redacted]') + return message + + +def _generate_activity_source_with_provider(provider, spec, plan, references, + progress): + generate_source = getattr(provider, 'generate_activity_source', None) + if not callable(generate_source): + return None, '', 0 + + validation_feedback = '' + last_error = '' + for attempt in range(1, _CODEGEN_ATTEMPT_LIMIT + 1): + if attempt == 1: + progress.report('generating', 0.56, + 'Asking the model to write activity.py') + else: + progress.report( + 'generating', 0.56, + 'Asking the model to repair activity.py ' + '(attempt %d of %d)' % (attempt, _CODEGEN_ATTEMPT_LIMIT), + ) + + try: + source = generate_source( + build_codegen_system_prompt(spec, plan, references), + build_codegen_user_prompt( + spec, + plan, + validation_feedback, + ), + ) + except ProviderError as error: + return None, _redact_provider_error(error, provider), attempt + except ValueError as error: + last_error = str(error) + validation_feedback = last_error + progress.report( + 'generating', 0.56, + 'Attempt %d did not produce valid code; retrying' + % attempt, + ) + continue + + progress.report( + 'validating', + 0.64, + 'Model returned activity.py; validating the draft', + { + 'draft_activity_source': source, + 'codegen_attempt': attempt, + }, + ) + report = validate_activity_source_for_request(source, spec, plan) + if report.valid: + if report.warnings: + plan.setdefault('codegen_warnings', []).extend( + report.warnings[:4] + ) + return source, '', attempt + + validation_feedback = _format_codegen_feedback(report) + last_error = ( + 'Provider generated code did not pass validation: %s' + % validation_feedback + ) + + return None, last_error, _CODEGEN_ATTEMPT_LIMIT + + +def _format_codegen_feedback(report): + items = list(report.errors[:8]) + if report.warnings: + items.extend( + 'Warning: %s' % warning + for warning in report.warnings[:3] + ) + return '\n'.join(items) diff --git a/src/jarabe/model/aodprompts.py b/src/jarabe/model/aodprompts.py new file mode 100644 index 0000000000..3320b22dfd --- /dev/null +++ b/src/jarabe/model/aodprompts.py @@ -0,0 +1,174 @@ +# Copyright (C) 2026 Sugar Labs +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import json + +from jarabe.model.aodrag import get_api_reference + + +def build_system_prompt(spec, references=()): + learning_direction = _CATEGORY_BLOCKS[spec.category] + if spec.template != 'auto': + learning_direction = '%s\n%s' % ( + learning_direction, + _TEMPLATE_HINTS.get(spec.template, ''), + ) + reference_text = _format_references(references) + return ( + 'You are Sugar Activity on Demand, a planner for safe, ' + 'learner-friendly Sugar activities that will be generated by an ' + 'LLM code generator.\n\n' + 'PRIORITY RULES\n' + '1. Return one JSON object and no Markdown.\n' + '2. Plan for Sugar Activity and GTK3, not Gtk.Application.\n' + '3. Design the actual activity the learner asked for. Do not force ' + 'the idea into a canned quiz/canvas/grid unless that is truly the ' + 'best design.\n' + '4. Retrieved references are API and pattern examples, not templates ' + 'to copy. Use them to understand how Sugar activities are structured, ' + 'how the toolbar/canvas lifecycle works, and how Journal persistence ' + 'is wired.\n' + '5. The provider code generator owns the activity.py source for ' + 'provider jobs. Do not plan a local-template substitute.\n' + '6. Do not request network access, subprocesses, or unrestricted ' + 'filesystem access.\n' + '7. The RAG step has already supplied the relevant Sugar examples. ' + 'Do not ask for more ingestion, training, uploads, or setup.\n' + '8. If the learner request contains a current activity.py excerpt, ' + 'treat it as an iterative refinement: preserve the working activity ' + 'and change only what the refinement asks for.\n' + '9. Respect the selected category, age band, and license.\n\n' + '%s\n\n' + 'SELECTED LEARNING DIRECTION\n%s\n\n' + 'RETRIEVED SUGAR REFERENCES\n%s\n\n' + 'Return these JSON fields:\n' + '- name: short activity name\n' + '- template: closest reference family: canvas, carrom, chess, grid, ' + 'narrative, quiz, or utility\n' + '- activity_kind: the specific kind of activity to build, in your ' + 'own words\n' + '- summary: one sentence\n' + '- learner_goal: one observable learner goal\n' + '- learner_steps: 3 to 5 short steps\n' + '- interaction_model: how learners interact with the activity\n' + '- ui_regions: 3 to 6 visible interface regions or controls\n' + '- state_schema: short description of the data saved to the Journal\n' + '- word_bank: up to 8 useful words\n' + '- features: optional list of activity features teachers can inspect\n' + '- classroom_flow: optional 3 to 5 step classroom use flow\n' + '- teacher_notes: optional short facilitation notes\n' + '- assessment_prompts: optional reflection/check-for-understanding ' + 'prompts\n' + '- materials: optional short list of classroom materials\n' + '- refinement_notes: optional list of changes to preserve or apply ' + 'when this is a refinement\n' + '- chess_show_move_log: optional boolean for chess activities; false ' + 'means use a clean board without a move-history panel\n' + '- questions: optional list of question/answer objects for quizzes\n' + '- starter_text: optional opening text for narrative activities\n' + ) % ( + get_api_reference(), + learning_direction, + reference_text or 'No external examples were retrieved.', + ) + + +def build_user_prompt(spec): + return ( + 'Plan a bespoke Sugar activity from this structured learner request. ' + 'Use the retrieved Sugar references as context for how real Sugar ' + 'activities are built, then make the plan specific enough that a ' + 'second model call can generate a complete activity.py. Do not fall ' + 'back to canned templates, placeholders, or generic demos:\n\n%s' + % spec.to_prompt() + ) + + +def extract_json_object(text): + if not isinstance(text, str): + raise ValueError('Provider response must be text.') + + candidate = text.strip() + if candidate.startswith('```'): + first_newline = candidate.find('\n') + last_fence = candidate.rfind('```') + if first_newline >= 0 and last_fence > first_newline: + candidate = candidate[first_newline + 1:last_fence].strip() + + try: + value = json.loads(candidate) + except json.JSONDecodeError: + start = candidate.find('{') + end = candidate.rfind('}') + if start < 0 or end <= start: + raise ValueError('Provider response did not contain JSON.') + try: + value = json.loads(candidate[start:end + 1]) + except json.JSONDecodeError as error: + raise ValueError('Provider returned invalid JSON: %s' % error) + + if not isinstance(value, dict): + raise ValueError('Provider JSON must be an object.') + return value + + +def _format_references(references): + blocks = [] + for index, document in enumerate(references[:5], 1): + text = ' '.join(document.text.split()) + blocks.append( + 'Reference %d - %s:\n%s' % (index, document.title, text[:1800]) + ) + return '\n\n'.join(blocks) + + +_CATEGORY_BLOCKS = { + 'logic_math': ( + 'Favor patterns, reasoning, explanation, immediate feedback, and ' + 'multiple ways to reach or describe an answer.' + ), + 'tools_utils': ( + 'Favor a focused tool that helps learners measure, organize, count, ' + 'compare, or investigate something meaningful.' + ), + 'games': ( + 'Favor a clear play loop, visible goals, understandable state, and ' + 'feedback that supports experimentation instead of punishment.' + ), + 'creation': ( + 'Favor drawing, writing, composing, building, and learner-owned ' + 'artifacts that can be revised and shared.' + ), +} + +_TEMPLATE_HINTS = { + 'canvas': ( + 'If a canvas is appropriate, use direct manipulation and a clear/reset ' + 'action.' + ), + 'carrom': ( + 'If carrom is appropriate, use a square board, corner pockets, coins, ' + 'a striker/aim marker, two-player turns, scoring, fouls, and Journal ' + 'persistence.' + ), + 'chess': ( + 'If chess is appropriate, use an 8x8 board, turns, pieces, move ' + 'feedback, and Journal persistence.' + ), + 'grid': ( + 'If a grid is appropriate, use visible state, patterns, and ' + 'reversible choices.' + ), + 'narrative': ( + 'If writing is appropriate, use a large editable area and a useful ' + 'starter.' + ), + 'quiz': ( + 'If a quiz is appropriate, use learner-friendly questions and ' + 'feedback.' + ), + 'utility': ( + 'If a utility is appropriate, use visible input and immediate output.' + ), +} diff --git a/src/jarabe/model/aodqueue.py b/src/jarabe/model/aodqueue.py new file mode 100644 index 0000000000..05cd24f22f --- /dev/null +++ b/src/jarabe/model/aodqueue.py @@ -0,0 +1,66 @@ +# Copyright (C) 2026 Sugar Labs +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import logging +import queue +import threading + + +class AODJobQueue: + """Small local worker queue for Activity-on-Demand jobs.""" + + def __init__(self, runner, worker_count=1): + self._runner = runner + self._worker_count = max(1, worker_count) + self._queue = queue.Queue() + self._workers = [] + self._shutdown = threading.Event() + self._lock = threading.Lock() + + def submit(self, job): + self.start() + self._queue.put(job) + + def start(self): + with self._lock: + if self._workers: + return + self._shutdown.clear() + for index in range(self._worker_count): + worker = threading.Thread( + target=self._worker_loop, + name='AODWorker-%d' % (index + 1), + daemon=True, + ) + self._workers.append(worker) + worker.start() + + def join(self): + self._queue.join() + + def shutdown(self, wait=True): + with self._lock: + if not self._workers: + return + self._shutdown.set() + for unused in self._workers: + self._queue.put(None) + workers = list(self._workers) + self._workers = [] + + if wait: + for worker in workers: + worker.join(timeout=2) + + def _worker_loop(self): + while not self._shutdown.is_set(): + job = self._queue.get() + try: + if job is None: + return + self._runner(job) + except Exception: + logging.exception('Activity-on-Demand worker crashed') + finally: + self._queue.task_done() diff --git a/src/jarabe/model/aodrag.py b/src/jarabe/model/aodrag.py new file mode 100644 index 0000000000..a7cf8f85b0 --- /dev/null +++ b/src/jarabe/model/aodrag.py @@ -0,0 +1,383 @@ +# Copyright (C) 2026 Sugar Labs +# +# SPDX-License-Identifier: GPL-3.0-or-later + +from dataclasses import dataclass +import os +import re + + +DEFAULT_ACTIVITY_ROOTS = ( + '/usr/share/sugar/activities', + os.path.expanduser('~/Activities'), +) + + +@dataclass(frozen=True) +class RagDocument: + title: str + text: str + tags: tuple = () + source_path: str = '' + + +def build_corpus(activity_roots=None): + documents = list(_REFERENCE_DOCUMENTS) + roots = activity_roots or DEFAULT_ACTIVITY_ROOTS + + for root in roots: + if not os.path.isdir(root): + continue + for bundle_name in sorted(os.listdir(root)): + bundle_path = os.path.join(root, bundle_name) + if not os.path.isdir(bundle_path): + continue + info_path = os.path.join(bundle_path, 'activity', 'activity.info') + info_text = _read_text(info_path, 6000) + if _is_generated_aod_bundle(bundle_path, info_text): + continue + source_path = _find_activity_source(bundle_path) + if source_path is None and not info_text: + continue + info_tags = _info_tags(bundle_name, info_text) + if info_text: + documents.append(RagDocument( + title='%s activity.info manifest' % bundle_name, + text=info_text, + tags=info_tags + ('manifest', 'bundle'), + source_path=info_path, + )) + if source_path is None: + continue + source = _read_text(source_path, 14000) + if not source: + continue + documents.append(RagDocument( + title='%s main Sugar source example' % bundle_name, + text=_join_activity_context(info_text, source), + tags=tuple(sorted(set(_source_tags(source) + info_tags))), + source_path=source_path, + )) + for support_path in _find_support_sources( + bundle_path, source_path): + support_source = _read_text(support_path, 8000) + if not support_source: + continue + documents.append(RagDocument( + title='%s supporting GTK source: %s' % ( + bundle_name, + os.path.basename(support_path), + ), + text=support_source, + tags=tuple(sorted(set( + _source_tags(support_source) + info_tags + ))), + source_path=support_path, + )) + + return documents + + +def search(query, limit=5, template='', corpus=None): + documents = corpus if corpus is not None else build_corpus() + query_words = _tokens(query) + ranked = [] + + for document in documents: + title_words = _tokens(document.title) + tag_words = set(document.tags) + text_words = _tokens(document.text[:6000]) + score = sum(( + 6 * len(query_words.intersection(title_words)), + 4 * len(query_words.intersection(tag_words)), + len(query_words.intersection(text_words)), + )) + if template and template in tag_words: + score += 5 + if score: + ranked.append((score, document.title, document)) + + ranked.sort(key=lambda item: (-item[0], item[1])) + return [item[2] for item in ranked[:limit]] + + +def get_example_sources(query, template='', limit=2, corpus=None): + documents = search( + query, + limit=max(limit * 3, limit), + template=template, + corpus=corpus, + ) + examples = [ + document for document in documents + if document.source_path or 'example' in document.tags + ] + return examples[:limit] + + +def get_api_reference(): + return _API_REFERENCE + + +def _find_activity_source(bundle_path): + info_path = os.path.join(bundle_path, 'activity', 'activity.info') + exec_module = '' + if os.path.isfile(info_path): + try: + with open(info_path, encoding='utf-8') as info_file: + for line in info_file: + if line.startswith('exec') and '=' in line: + exec_value = line.split('=', 1)[1].strip().split() + if len(exec_value) >= 2: + exec_module = exec_value[1].split('.')[0] + break + except OSError: + pass + + candidates = [] + if exec_module: + candidates.append(os.path.join(bundle_path, exec_module + '.py')) + candidates.extend([ + os.path.join(bundle_path, 'activity.py'), + os.path.join(bundle_path, 'main.py'), + ]) + for candidate in candidates: + if os.path.isfile(candidate): + return candidate + + try: + for filename in sorted(os.listdir(bundle_path)): + if filename.endswith('.py') and filename != 'setup.py': + return os.path.join(bundle_path, filename) + except OSError: + pass + return None + + +def _find_support_sources(bundle_path, primary_source_path, limit=2): + candidates = [] + try: + for filename in sorted(os.listdir(bundle_path)): + if not filename.endswith('.py') or filename == 'setup.py': + continue + path = os.path.join(bundle_path, filename) + if path == primary_source_path: + continue + candidates.append(path) + except OSError: + return [] + return candidates[:limit] + + +def _read_text(path, limit): + if not path or not os.path.isfile(path): + return '' + try: + with open(path, encoding='utf-8') as source_file: + return source_file.read(limit) + except (OSError, UnicodeDecodeError): + return '' + + +def _join_activity_context(info_text, source): + blocks = [] + if info_text: + blocks.append('activity/activity.info:\n%s' % info_text[:3000]) + blocks.append('main Python source:\n%s' % source[:12000]) + return '\n\n'.join(blocks) + + +def _info_tags(bundle_name, info_text): + text = '%s\n%s' % (bundle_name, info_text or '') + tags = {'sugar'} + if 'max_participants' in text: + tags.add('collaboration') + if 'summary' in text: + tags.add('metadata') + if 'mime_types' in text: + tags.add('journal') + for token in _tokens(text): + if len(token) > 2: + tags.add(token) + return tuple(sorted(tags)) + + +def _is_generated_aod_bundle(bundle_path, info_text): + if os.path.exists(os.path.join(bundle_path, 'aod_plan.json')): + return True + lowered = (info_text or '').lower() + return ( + 'org.sugarlabs.aod.' in lowered or + 'activity on demand' in lowered + ) + + +def _source_tags(source): + tags = {'sugar', 'gtk3'} + checks = { + 'canvas': ('DrawingArea', 'draw'), + 'carrom': ('_draw_carrom_board', 'Pocket queen', 'striker'), + 'chess': ('_starting_board', '_can_move', 'White to move'), + 'grid': ('Gtk.Grid', 'Grid('), + 'narrative': ('TextView', 'write_file'), + 'quiz': ('question', 'answer'), + 'utility': ('Entry', 'ToolButton'), + 'journal': ('read_file', 'write_file'), + 'collaboration': ('max_participants', 'presence'), + 'cairo': ('cairo', 'context.'), + 'pango': ('Pango', 'pango'), + } + for tag, needles in checks.items(): + if any(needle in source for needle in needles): + tags.add(tag) + return tuple(sorted(tags)) + + +def _tokens(value): + return set(re.findall(r'[a-z0-9_]+', value.lower())) + + +_API_REFERENCE = """Sugar Activity API reference: +- Subclass sugar3.activity.activity.Activity. +- Receive the Sugar handle in __init__ and initialize the Activity base class. +- Create a ToolbarBox with ActivityToolbarButton and StopButton. +- Set the toolbar with self.set_toolbar_box(toolbar_box). +- Build GTK3 widgets and set the root widget with self.set_canvas(canvas). +- Call show_all() after the widget tree is assembled. +- Implement write_file(file_path) and read_file(file_path) for Journal data. +- GTK widgets must be updated on the GTK main thread. +- Generated activities should work without network access. +""" + +_REFERENCE_DOCUMENTS = ( + RagDocument( + title='Sugar activity lifecycle and bundle contract', + text=_API_REFERENCE, + tags=('api', 'bundle', 'journal', 'sugar', 'toolbar'), + ), + RagDocument( + title='Minimal Sugar activity.py contract', + text=( + 'A generated activity.py should import Gtk from gi.repository, ' + 'import activity from sugar3.activity, subclass ' + 'activity.Activity, initialize the base class with the handle, ' + 'build a ToolbarBox with ActivityToolbarButton and StopButton, ' + 'call self.set_toolbar_box(toolbar_box), create a GTK widget ' + 'tree, call self.set_canvas(root_widget), connect real signal ' + 'handlers for learner actions, implement read_file and ' + 'write_file, and finish with self.show_all().' + ), + tags=('api', 'gtk3', 'journal', 'source', 'sugar', 'toolbar'), + ), + RagDocument( + title='Sugar activity.info manifest contract', + text=( + 'The generated bundle contains activity/activity.info with ' + 'name, bundle_id, icon, exec, activity_version, license, summary, ' + 'and tags. The exec line for generated activities is ' + 'sugar-activity3 activity.GeneratedActivity. The Python class in ' + 'activity.py must match that exec target.' + ), + tags=('bundle', 'manifest', 'metadata', 'sugar'), + ), + RagDocument( + title='GTK layout pattern for learning activities', + text=( + 'Use one main Gtk.Box. Put learner controls in a compact sidebar ' + 'or toolbar and the main work surface in an expanding center ' + 'area. Use Gtk.Grid for board or tile activities, Gtk.TextView ' + 'inside Gtk.ScrolledWindow for writing and explanation, and ' + 'Gtk.DrawingArea for drawing or visual simulations. Prefer clear ' + 'labels and immediate feedback over decorative placeholders.' + ), + tags=('gtk3', 'layout', 'ui', 'widgets'), + ), + RagDocument( + title='Journal persistence pattern', + text=( + 'Keep learner state in simple Python data: strings, numbers, ' + 'lists, and dictionaries. write_file serializes that state, ' + 'usually as JSON text. read_file restores it and refreshes the ' + 'widgets. Never write arbitrary paths; Sugar passes the Journal ' + 'file path to those methods.' + ), + tags=('journal', 'persistence', 'state'), + ), + RagDocument( + title='Canvas activity pattern', + text=( + 'Use Gtk.DrawingArea, connect the draw signal, store learner ' + 'strokes as simple data, handle button-press, motion-notify, and ' + 'button-release events for drawing, call queue_draw after edits, ' + 'draw with cairo in the draw callback, and save the stroke data ' + 'through write_file.' + ), + tags=('cairo', 'canvas', 'drawing', 'example'), + ), + RagDocument( + title='Two learner turn-taking pattern', + text=( + 'For paired activities, keep active_student or active_team in ' + 'state, provide visible controls for Student A and Student B or ' + 'Team A and Team B, include a Switch Turn action, color-code or ' + 'label each learner contribution, and save the shared artifact ' + 'plus the explanation in the Journal.' + ), + tags=('collaboration', 'pair', 'turns', 'two learners'), + ), + RagDocument( + title='Grid activity pattern', + text=( + 'Use Gtk.Grid with buttons or toggles. Keep state separate from ' + 'widgets so it can be serialized to the Journal.' + ), + tags=('grid', 'logic', 'example'), + ), + RagDocument( + title='Carrom board activity pattern', + text=( + 'A carrom activity should use a square visual board with four ' + 'corner pockets, visible white and black coins, a queen, a ' + 'striker or aim marker, two-player turn taking, score and foul ' + 'state, immediate shot feedback, and Journal persistence for ' + 'the match state.' + ), + tags=('board', 'carrom', 'game', 'turns', 'score'), + ), + RagDocument( + title='Chess board activity pattern', + text=( + 'Use an 8x8 Gtk.Grid of buttons for the board. Keep board state ' + 'as piece codes, track whose turn it is, validate basic moves, ' + 'show move feedback, maintain a move log, and persist the board ' + 'through write_file and read_file.' + ), + tags=('board', 'chess', 'game', 'grid', 'turns'), + ), + RagDocument( + title='Narrative activity pattern', + text=( + 'Use Gtk.TextView inside Gtk.ScrolledWindow. Read text through ' + 'the TextBuffer bounds and persist it as UTF-8.' + ), + tags=('narrative', 'story', 'writing', 'example'), + ), + RagDocument( + title='Quiz activity pattern', + text=( + 'Keep questions as structured dictionaries. Show one question ' + 'at a time, accept an answer, provide feedback, and persist ' + 'progress and score.' + ), + tags=('assessment', 'quiz', 'example'), + ), + RagDocument( + title='Utility activity pattern', + text=( + 'A utility should solve one understandable problem. Keep its ' + 'inputs visible, update results immediately, and persist the ' + 'learner-owned input.' + ), + tags=('tool', 'utility', 'example'), + ), +) diff --git a/src/jarabe/model/aodservice.py b/src/jarabe/model/aodservice.py new file mode 100644 index 0000000000..1b51cdd689 --- /dev/null +++ b/src/jarabe/model/aodservice.py @@ -0,0 +1,490 @@ +# Copyright (C) 2026 Sugar Labs +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import logging +import threading + +from jarabe.model.aodcredentials import AODCredentialStore +from jarabe.model.aodcredentials import CredentialStoreError +from jarabe.model.aodgenerator import restore_generation_result +from jarabe.model.aodjobs import AODJob +from jarabe.model.aodjobs import AODJobStore +from jarabe.model.aodjobs import STATUS_FINISHED +from jarabe.model.aodjobs import STATUS_GENERATING +from jarabe.model.aodjobs import STATUS_GROUNDING +from jarabe.model.aodjobs import STATUS_PACKAGING +from jarabe.model.aodjobs import STATUS_PLANNING +from jarabe.model.aodjobs import STATUS_PROVIDER +from jarabe.model.aodjobs import STATUS_QUEUED +from jarabe.model.aodjobs import STATUS_VALIDATING +from jarabe.model.aodllm import create_provider +from jarabe.model.aodllm import get_default_provider_name +from jarabe.model.aodllm import get_local_provider_name +from jarabe.model.aodllm import get_provider_statuses +from jarabe.model.aodllm import normalize_provider_name +from jarabe.model.aodpipeline import generate_activity +from jarabe.model.aodqueue import AODJobQueue +from jarabe.model.aodsessions import AODMessage +from jarabe.model.aodsessions import AODRevision +from jarabe.model.aodsessions import AODSessionStore +from jarabe.model.aodsessions import ROLE_ASSISTANT +from jarabe.model.aodsessions import ROLE_USER +from jarabe.model.aodsessions import TYPE_ERROR +from jarabe.model.aodsessions import TYPE_PROMPT +from jarabe.model.aodsessions import TYPE_RESULT +from jarabe.model.aodsessions import TYPE_STATUS + + +class JobCancelled(Exception): + pass + + +class AODService: + """Local backend service for generated Sugar activities.""" + + def __init__(self, job_store=None, worker_count=1, + credential_store=None, session_store=None): + self._store = job_store or AODJobStore() + self._credential_store = credential_store or AODCredentialStore() + self._session_store = session_store or AODSessionStore() + self._lock = threading.RLock() + self._callbacks = {} + self._jobs = {} + self._provider_overrides = {} + self._job_providers = {} + self._load_jobs() + self._queue = AODJobQueue(self._run_job, worker_count=worker_count) + + def submit_activity(self, spec, provider_name='default', use_rag=True, + output_root=None, callback=None, session_id='', + parent_revision_id='', user_prompt=None): + errors = spec.validate() + if errors: + raise ValueError('\n'.join(errors)) + + provider_name = normalize_provider_name(provider_name) + if provider_name == 'default': + provider_name = self.preferred_provider_name() + session = self._ensure_session(spec, session_id) + prompt_text = user_prompt or spec.prompt + job = AODJob.create( + spec, + provider_name=provider_name, + use_rag=use_rag, + output_root=output_root, + session_id=session.session_id, + parent_revision_id=parent_revision_id, + user_prompt=prompt_text, + ) + if callback is not None: + self.watch(job.job_id, callback) + + with self._lock: + self._jobs[job.job_id] = job + provider = self._provider_overrides.get(provider_name) + if provider is None: + provider = self._load_saved_provider(provider_name) + if provider is not None: + self._job_providers[job.job_id] = provider + self._store.save(job) + + self._record_user_prompt(session.session_id, job, prompt_text) + self._notify(job) + self._queue.submit(job) + return job + + def watch(self, job_id, callback): + with self._lock: + self._callbacks.setdefault(job_id, []).append(callback) + + def unwatch(self, job_id, callback=None): + with self._lock: + if job_id not in self._callbacks: + return + if callback is None: + del self._callbacks[job_id] + return + self._callbacks[job_id] = [ + item for item in self._callbacks[job_id] + if item != callback + ] + if not self._callbacks[job_id]: + del self._callbacks[job_id] + + def get_job(self, job_id): + with self._lock: + return self._jobs.get(job_id) + + def list_jobs(self): + with self._lock: + return sorted( + self._jobs.values(), + key=lambda job: job.created_at, + reverse=True, + ) + + def get_session(self, session_id): + return self._session_store.load(session_id) + + def list_sessions(self): + return self._session_store.list_sessions() + + def cancel_job(self, job_id): + with self._lock: + job = self._jobs.get(job_id) + if job is None or job.is_terminal(): + return False + job.request_cancel() + if job.status == STATUS_QUEUED: + job.cancel() + self._store.save(job) + self._notify(job) + return True + + def provider_statuses(self): + statuses = get_provider_statuses() + with self._lock: + overrides = dict(self._provider_overrides) + + for status in statuses: + provider = overrides.get(status['name']) + credentials = self._credential_store.provider_status( + status['name'] + ) if status['name'] in ( + 'gemini', 'openai', 'deepseek', 'qwen', 'moonshot', + 'opencode', 'opencode-go', 'freemodel', 'claude', 'ollama') else {} + ollama_configured = status['name'] == 'ollama' and any(( + credentials.get('model'), + credentials.get('endpoint'), + )) + if provider is not None: + status['available'] = True + status['configured'] = True + status['model'] = provider.model + status['reason'] = '' + elif credentials.get('has_api_key') or ollama_configured: + status['available'] = True + status['configured'] = True + status['model'] = credentials.get('model') or status['model'] + status['reason'] = '' + return statuses + + def configure_provider(self, provider_name, api_key=None, model=None, + endpoint=None, persist=False): + provider_name = normalize_provider_name(provider_name) + if persist: + self._credential_store.save_provider( + provider_name, + api_key=api_key, + model=model, + endpoint=endpoint, + ) + + saved = self._credential_store.load_provider(provider_name) + provider = create_provider( + provider_name, + api_key=api_key or saved['api_key'] or None, + model=model or saved['model'] or None, + endpoint=endpoint or saved['endpoint'] or None, + ) + if provider is None: + return None + return self.register_provider(provider) + + def provider_credential_status(self, provider_name): + provider_name = normalize_provider_name(provider_name) + return self._credential_store.provider_status(provider_name) + + def remove_provider_api_key(self, provider_name): + provider_name = normalize_provider_name(provider_name) + removed = self._credential_store.remove_api_key(provider_name) + self.clear_provider(provider_name) + return removed + + def register_provider(self, provider): + provider_name = normalize_provider_name(provider.name) + if provider_name in ('default', 'local-template'): + raise ValueError( + 'Only concrete LLM providers can be registered.' + ) + if not callable(getattr(provider, 'generate_plan', None)): + raise TypeError('Provider must define generate_plan().') + + with self._lock: + self._provider_overrides[provider_name] = provider + return provider + + def clear_provider(self, provider_name): + provider_name = normalize_provider_name(provider_name) + with self._lock: + self._provider_overrides.pop(provider_name, None) + + def has_runtime_provider(self, provider_name): + provider_name = normalize_provider_name(provider_name) + with self._lock: + return provider_name in self._provider_overrides + + def preferred_local_provider_name(self): + if self.has_runtime_provider('ollama'): + return 'ollama' + ollama = self._credential_store.provider_status('ollama') + if ollama['model'] or ollama['endpoint']: + return 'ollama' + return get_local_provider_name() + + def preferred_provider_name(self): + saved_provider = \ + self._credential_store.get_default_provider_name() + if saved_provider: + return saved_provider + return get_default_provider_name() + + def shutdown(self, wait=True): + self._queue.shutdown(wait=wait) + + def _load_jobs(self): + for job in self._store.list_jobs(): + if not job.is_terminal(): + job.fail('Sugar restarted before this job finished.') + self._store.save(job) + elif job.status == STATUS_FINISHED: + try: + job.result = restore_generation_result( + job.spec, + job.result_summary, + ) + except (OSError, TypeError, ValueError): + logging.exception( + 'Could not restore Activity-on-Demand result' + ) + job.result = None + if job.result is None: + job.fail( + 'Generated activity artifacts are no longer available.' + ) + self._store.save(job) + self._jobs[job.job_id] = job + + def _ensure_session(self, spec, session_id=''): + if session_id: + session = self._session_store.load(session_id) + if session is not None: + return session + return self._session_store.create_session(spec) + + def _record_user_prompt(self, session_id, job, prompt_text): + message = AODMessage.create( + ROLE_USER, + prompt_text, + message_type=TYPE_PROMPT, + job_id=job.job_id, + ) + self._session_store.append_message(session_id, message) + + status = AODMessage.create( + ROLE_ASSISTANT, + 'Generating Sugar activity...', + message_type=TYPE_STATUS, + job_id=job.job_id, + ) + self._session_store.append_message(session_id, status) + + def _run_job(self, job): + try: + self._run_job_inner(job) + finally: + with self._lock: + self._job_providers.pop(job.job_id, None) + + def _load_saved_provider(self, provider_name): + if provider_name not in ('gemini', 'openai', 'openrouter', + 'deepseek', 'qwen', 'moonshot', 'opencode', + 'opencode-go', 'freemodel', 'claude', + 'ollama'): + return None + + try: + saved = self._credential_store.load_provider(provider_name) + if provider_name != 'ollama' and not saved['api_key']: + return None + if provider_name == 'ollama' and not ( + saved['model'] or saved['endpoint']): + return None + + return create_provider( + provider_name, + api_key=saved['api_key'] or None, + model=saved['model'] or None, + endpoint=saved['endpoint'] or None, + ) + except (CredentialStoreError, TypeError, ValueError): + logging.exception( + 'Could not load saved Activity-on-Demand provider' + ) + return None + + def _run_job_inner(self, job): + if job.cancel_requested: + self._mark_cancelled(job) + return + + job.mark_started() + self._set_progress( + job, + STATUS_PLANNING, + STATUS_PLANNING, + 0.0, + 'Starting generation', + ) + + try: + with self._lock: + provider = self._job_providers.get(job.job_id) + result = generate_activity( + job.spec, + output_root=job.output_root or None, + provider=provider, + provider_name=job.provider_name, + use_rag=job.use_rag, + progress_cb=lambda stage, fraction, message, metadata=None: + self._pipeline_progress( + job, + stage, + fraction, + message, + metadata, + ), + pace=True, + package_bundle=False, + ) + except JobCancelled: + self._mark_cancelled(job) + return + except Exception as error: + logging.exception('Activity-on-Demand job failed') + self._mark_failed(job, error) + return + + if job.cancel_requested: + self._mark_cancelled(job) + return + + with self._lock: + job.finish(result) + revision = AODRevision.create( + job.job_id, + job.user_prompt or job.spec.prompt, + job.result_summary, + parent_revision_id=job.parent_revision_id, + ) + job.result_summary['session_id'] = job.session_id + job.result_summary['revision_id'] = revision.revision_id + revision.result_summary = dict(job.result_summary) + self._store.save(job) + self._record_finished_revision(job, revision) + self._notify(job) + + def _pipeline_progress(self, job, stage, fraction, message, + metadata=None): + if job.cancel_requested: + raise JobCancelled() + + status = _status_for_pipeline_stage(stage) + self._set_progress(job, status, stage, fraction, message, metadata) + + def _set_progress(self, job, status, stage, progress, message, + metadata=None): + with self._lock: + job.update_progress(status, stage, progress, message) + if isinstance(metadata, dict): + draft_source = metadata.get('draft_activity_source') + if isinstance(draft_source, str) and draft_source: + job.draft_activity_source = draft_source + self._store.save(job) + self._notify(job) + + def _mark_failed(self, job, error): + with self._lock: + job.fail(error) + self._store.save(job) + self._record_failed_message(job) + self._notify(job) + + def _mark_cancelled(self, job): + with self._lock: + job.cancel() + self._store.save(job) + self._notify(job) + + def _notify(self, job): + with self._lock: + callbacks = list(self._callbacks.get(job.job_id, ())) + + for callback in callbacks: + try: + callback(job) + except Exception: + logging.exception('Activity-on-Demand callback failed') + + def _record_finished_revision(self, job, revision): + if not job.session_id: + return + + self._session_store.append_revision(job.session_id, revision) + summary = job.result_summary + provider = summary.get('provider', job.provider_name) + model = summary.get('model', '') + if model: + provider = '%s / %s' % (provider, model) + + message = AODMessage.create( + ROLE_ASSISTANT, + ('Generated and validated "%(name)s" with %(provider)s. ' + 'This revision is ready to preview, refine, export, or ' + 'install.') % { + 'name': summary.get('activity_name', job.spec.name), + 'provider': provider, + }, + message_type=TYPE_RESULT, + job_id=job.job_id, + revision_id=revision.revision_id, + ) + self._session_store.append_message(job.session_id, message) + + def _record_failed_message(self, job): + if not job.session_id: + return + + message = AODMessage.create( + ROLE_ASSISTANT, + 'Generation failed: %s' % job.error, + message_type=TYPE_ERROR, + job_id=job.job_id, + ) + self._session_store.append_message(job.session_id, message) + + +def _status_for_pipeline_stage(stage): + statuses = { + 'planning': STATUS_PLANNING, + 'grounding': STATUS_GROUNDING, + 'provider': STATUS_PROVIDER, + 'generating': STATUS_GENERATING, + 'validating': STATUS_VALIDATING, + 'packaging': STATUS_PACKAGING, + 'ready': STATUS_PACKAGING, + } + return statuses.get(stage, STATUS_GENERATING) + + +_service = None +_service_lock = threading.Lock() + + +def get_service(): + global _service + with _service_lock: + if _service is None: + _service = AODService() + return _service diff --git a/src/jarabe/model/aodsessions.py b/src/jarabe/model/aodsessions.py new file mode 100644 index 0000000000..d6b4d59232 --- /dev/null +++ b/src/jarabe/model/aodsessions.py @@ -0,0 +1,249 @@ +# Copyright (C) 2026 Sugar Labs +# +# SPDX-License-Identifier: GPL-3.0-or-later + +from dataclasses import dataclass +from dataclasses import field +import json +import os +import threading +import time +import uuid + +from sugar3 import env + +from jarabe.model.aodspec import ActivitySpec + + +ROLE_USER = 'user' +ROLE_ASSISTANT = 'assistant' + +TYPE_PROMPT = 'prompt' +TYPE_STATUS = 'status' +TYPE_RESULT = 'result' +TYPE_ERROR = 'error' + + +@dataclass +class AODMessage: + """One visible message in an Activity-on-Demand creation session.""" + + message_id: str + role: str + content: str + message_type: str = TYPE_STATUS + job_id: str = '' + revision_id: str = '' + created_at: float = field(default_factory=time.time) + + @classmethod + def create(cls, role, content, message_type=TYPE_STATUS, job_id='', + revision_id=''): + return cls( + message_id=uuid.uuid4().hex, + role=role, + content=content, + message_type=message_type, + job_id=job_id, + revision_id=revision_id, + ) + + @classmethod + def from_dict(cls, data): + return cls( + message_id=data.get('message_id', uuid.uuid4().hex), + role=data.get('role', ROLE_ASSISTANT), + content=data.get('content', ''), + message_type=data.get('message_type', TYPE_STATUS), + job_id=data.get('job_id', ''), + revision_id=data.get('revision_id', ''), + created_at=data.get('created_at', time.time()), + ) + + def to_dict(self): + return { + 'message_id': self.message_id, + 'role': self.role, + 'content': self.content, + 'message_type': self.message_type, + 'job_id': self.job_id, + 'revision_id': self.revision_id, + 'created_at': self.created_at, + } + + +@dataclass +class AODRevision: + """A generated, validated revision of a Sugar activity.""" + + revision_id: str + job_id: str + prompt: str + result_summary: dict + parent_revision_id: str = '' + created_at: float = field(default_factory=time.time) + + @classmethod + def create(cls, job_id, prompt, result_summary, + parent_revision_id=''): + return cls( + revision_id=uuid.uuid4().hex, + job_id=job_id, + prompt=prompt, + result_summary=dict(result_summary or {}), + parent_revision_id=parent_revision_id or '', + ) + + @classmethod + def from_dict(cls, data): + return cls( + revision_id=data.get('revision_id', uuid.uuid4().hex), + job_id=data.get('job_id', ''), + prompt=data.get('prompt', ''), + result_summary=data.get('result_summary', {}), + parent_revision_id=data.get('parent_revision_id', ''), + created_at=data.get('created_at', time.time()), + ) + + def to_dict(self): + return { + 'revision_id': self.revision_id, + 'job_id': self.job_id, + 'prompt': self.prompt, + 'result_summary': self.result_summary, + 'parent_revision_id': self.parent_revision_id, + 'created_at': self.created_at, + } + + +@dataclass +class AODSession: + """A prompt/refinement conversation around one generated activity.""" + + session_id: str + title: str + spec: ActivitySpec + messages: list = field(default_factory=list) + revisions: list = field(default_factory=list) + active_revision_id: str = '' + created_at: float = field(default_factory=time.time) + updated_at: float = field(default_factory=time.time) + + @classmethod + def create(cls, spec, title=''): + spec = spec.normalized() + return cls( + session_id=uuid.uuid4().hex, + title=title or spec.name, + spec=spec, + ) + + @classmethod + def from_dict(cls, data): + return cls( + session_id=data['session_id'], + title=data.get('title', ''), + spec=ActivitySpec.from_dict(data.get('spec', {})), + messages=[ + AODMessage.from_dict(item) + for item in data.get('messages', []) + ], + revisions=[ + AODRevision.from_dict(item) + for item in data.get('revisions', []) + ], + active_revision_id=data.get('active_revision_id', ''), + created_at=data.get('created_at', time.time()), + updated_at=data.get('updated_at', time.time()), + ) + + def to_dict(self): + return { + 'session_id': self.session_id, + 'title': self.title, + 'spec': self.spec.to_dict(), + 'messages': [message.to_dict() for message in self.messages], + 'revisions': [ + revision.to_dict() for revision in self.revisions + ], + 'active_revision_id': self.active_revision_id, + 'created_at': self.created_at, + 'updated_at': self.updated_at, + } + + +class AODSessionStore: + """File-backed session store for AOD conversations and revisions.""" + + def __init__(self, root_path=None): + self._root_path = root_path or env.get_profile_path( + os.path.join('aod', 'sessions') + ) + self._lock = threading.RLock() + os.makedirs(self._root_path, exist_ok=True) + + def create_session(self, spec, title=''): + session = AODSession.create(spec, title=title) + self.save(session) + return session + + def save(self, session): + session.updated_at = time.time() + with self._lock: + path = self._session_path(session.session_id) + tmp_path = path + '.tmp' + with open(tmp_path, 'w', encoding='utf-8') as output: + json.dump(session.to_dict(), output, indent=2, + sort_keys=True) + output.write('\n') + os.replace(tmp_path, path) + + def load(self, session_id): + with self._lock: + path = self._session_path(session_id) + if not os.path.exists(path): + return None + with open(path, encoding='utf-8') as source: + return AODSession.from_dict(json.load(source)) + + def list_sessions(self): + with self._lock: + sessions = [] + for filename in os.listdir(self._root_path): + if not filename.endswith('.json'): + continue + path = os.path.join(self._root_path, filename) + try: + with open(path, encoding='utf-8') as source: + sessions.append(AODSession.from_dict( + json.load(source))) + except (OSError, ValueError, KeyError, TypeError): + continue + sessions.sort(key=lambda item: item.updated_at, reverse=True) + return sessions + + def append_message(self, session_id, message): + with self._lock: + session = self.load(session_id) + if session is None: + return None + session.messages.append(message) + self.save(session) + return session + + def append_revision(self, session_id, revision): + with self._lock: + session = self.load(session_id) + if session is None: + return None + session.revisions.append(revision) + session.active_revision_id = revision.revision_id + self.save(session) + return session + + def _session_path(self, session_id): + safe_id = ''.join( + char for char in session_id + if char.isalnum() or char in ('-', '_') + ) + return os.path.join(self._root_path, safe_id + '.json') diff --git a/src/jarabe/model/aodspec.py b/src/jarabe/model/aodspec.py new file mode 100644 index 0000000000..78387fa188 --- /dev/null +++ b/src/jarabe/model/aodspec.py @@ -0,0 +1,154 @@ +# Copyright (C) 2026 Sugar Labs +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# SPDX-License-Identifier: GPL-3.0-or-later + +from dataclasses import asdict +from dataclasses import dataclass +import re + + +CATEGORIES = ( + 'logic_math', + 'tools_utils', + 'games', + 'creation', +) + +MAX_PROMPT_LENGTH = 12000 + +TEMPLATES = ( + 'auto', + 'canvas', + 'carrom', + 'chess', + 'grid', + 'narrative', + 'quiz', + 'utility', +) + +LICENSE_IDS = ( + 'MIT', + 'GPL-3.0-or-later', + 'Apache-2.0', + 'AGPL-3.0-or-later', + 'LGPL-3.0-or-later', + 'MPL-2.0', + 'BSD-3-Clause', +) + + +@dataclass +class ActivitySpec: + """A validated, provider-independent activity generation request.""" + + name: str + prompt: str + category: str + license_id: str + template: str = 'auto' + age_band: str = 'all' + learner_goal: str = '' + + def validate(self): + errors = [] + + if not isinstance(self.name, str) or not self.name.strip(): + errors.append('Activity name is required.') + elif len(self.name.strip()) > 80: + errors.append('Activity name must be 80 characters or fewer.') + + if not isinstance(self.prompt, str) or not self.prompt.strip(): + errors.append('Activity prompt is required.') + elif len(self.prompt.strip()) > MAX_PROMPT_LENGTH: + errors.append( + 'Activity prompt must be %d characters or fewer.' + % MAX_PROMPT_LENGTH + ) + + if self.category not in CATEGORIES: + errors.append('Unknown activity category: %s' % self.category) + + if self.template not in TEMPLATES: + errors.append('Unknown activity template: %s' % self.template) + + if self.license_id not in LICENSE_IDS: + errors.append('Unknown activity license: %s' % self.license_id) + + if not isinstance(self.age_band, str) or not self.age_band.strip(): + errors.append('Activity age band is required.') + + return errors + + def normalized(self): + """Return a copy with user-entered whitespace normalized.""" + return ActivitySpec( + name=_normalize_spaces(self.name), + prompt=self.prompt.strip(), + category=self.category, + license_id=self.license_id, + template=self.template, + age_band=_normalize_spaces(self.age_band), + learner_goal=_normalize_spaces(self.learner_goal), + ) + + def to_dict(self): + return asdict(self) + + def to_prompt(self): + goal = self.learner_goal or 'Infer a learner goal from the idea.' + return ( + 'Activity name: %s\n' + 'Learner idea: %s\n' + 'Learning category: %s\n' + 'Template preference: %s\n' + 'Age band: %s\n' + 'Learner goal: %s\n' + 'License: %s' + ) % ( + self.name, + self.prompt, + self.category, + self.template, + self.age_band, + goal, + self.license_id, + ) + + @classmethod + def from_dict(cls, data): + if not isinstance(data, dict): + raise TypeError('Activity spec data must be a dictionary.') + + return cls( + name=data.get('name', ''), + prompt=data.get('prompt', ''), + category=data.get('category', ''), + license_id=data.get('license_id', 'MIT'), + template=data.get('template', 'auto'), + age_band=data.get('age_band', 'all'), + learner_goal=data.get('learner_goal', ''), + ) + + +def name_from_prompt(prompt): + """Build a short editable activity name from a learner prompt.""" + words = re.findall(r"[A-Za-z0-9']+", prompt) + ignored = { + 'a', 'an', 'and', 'app', 'activity', 'build', 'create', 'for', + 'make', 'me', 'of', 'please', 'the', 'to', 'where', 'with', + } + useful = [word for word in words if word.lower() not in ignored] + selected = useful[:4] or words[:4] or ['Learning', 'Activity'] + return ' '.join(word.capitalize() for word in selected)[:80] + + +def _normalize_spaces(value): + if not isinstance(value, str): + return value + return ' '.join(value.split()) diff --git a/src/jarabe/model/aodtemplates.py b/src/jarabe/model/aodtemplates.py new file mode 100644 index 0000000000..e9e8cca0ed --- /dev/null +++ b/src/jarabe/model/aodtemplates.py @@ -0,0 +1,1324 @@ +# Copyright (C) 2026 Sugar Labs +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import json +from textwrap import dedent + + +def render_activity_source(spec, plan): + template = plan['template'] + render = _TEMPLATE_RENDERERS[template] + body = render(spec, plan) + return _render_shell(spec, plan, body) + + +def _render_shell(spec, plan, body): + return dedent( + '''\ + # SPDX-License-Identifier: {license_id} + + import json + + import gi + gi.require_version('Gdk', '3.0') + gi.require_version('Gtk', '3.0') + from gi.repository import Gdk + from gi.repository import GLib + from gi.repository import Gtk + + from sugar3.activity import activity + from sugar3.activity.widgets import ActivityToolbarButton + from sugar3.activity.widgets import StopButton + from sugar3.graphics.toolbarbox import ToolbarBox + + + ACTIVITY_TITLE = {title} + LEARNER_GOAL = {goal} + + + class GeneratedActivity(activity.Activity): + def __init__(self, handle): + activity.Activity.__init__(self, handle) + self.max_participants = 1 + self._build_toolbar() + self._build_canvas() + + def _build_toolbar(self): + toolbar_box = ToolbarBox() + toolbar = toolbar_box.toolbar + toolbar.insert(ActivityToolbarButton(self), 0) + toolbar.insert(StopButton(self), -1) + self.set_toolbar_box(toolbar_box) + toolbar_box.show_all() + + {body} + ''' + ).format( + license_id=spec.license_id, + title=json.dumps(spec.name), + goal=json.dumps(spec.learner_goal or plan['learner_goal']), + body=_indent(body.rstrip(), 4), + ) + + +def _render_canvas(spec, plan): + return dedent( + '''\ + def _build_canvas(self): + self._points = [] + canvas = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + canvas.set_border_width(24) + + goal = Gtk.Label(label=LEARNER_GOAL) + goal.set_line_wrap(True) + canvas.pack_start(goal, False, False, 0) + + self._drawing = Gtk.DrawingArea() + self._drawing.set_size_request(640, 420) + self._drawing.add_events( + Gdk.EventMask.BUTTON_PRESS_MASK | + Gdk.EventMask.BUTTON_MOTION_MASK) + self._drawing.connect('button-press-event', self._draw_point) + self._drawing.connect('motion-notify-event', self._draw_point) + self._drawing.connect('draw', self._draw_canvas) + canvas.pack_start(self._drawing, True, True, 0) + + clear_button = Gtk.Button(label='Clear drawing') + clear_button.connect('clicked', self._clear_drawing) + canvas.pack_start(clear_button, False, False, 0) + + self.set_canvas(canvas) + canvas.show_all() + + def _draw_point(self, widget, event): + if event.type == Gdk.EventType.MOTION_NOTIFY and \ + not event.state & Gdk.ModifierType.BUTTON1_MASK: + return False + self._points.append([event.x, event.y]) + self._drawing.queue_draw() + return True + + def _draw_canvas(self, widget, context): + context.set_source_rgb(0.15, 0.35, 0.75) + for x, y in self._points: + context.rectangle(x - 2, y - 2, 4, 4) + context.fill() + return False + + def _clear_drawing(self, button): + self._points = [] + self._drawing.queue_draw() + + def write_file(self, file_path): + with open(file_path, 'w', encoding='utf-8') as output: + json.dump({'points': self._points}, output) + + def read_file(self, file_path): + try: + with open(file_path, encoding='utf-8') as source: + self._points = json.load(source).get('points', []) + except (OSError, ValueError): + self._points = [] + if hasattr(self, '_drawing'): + self._drawing.queue_draw() + ''' + ) + + +def _render_grid(spec, plan): + return dedent( + '''\ + def _build_canvas(self): + self._grid_state = [False] * 16 + canvas = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + canvas.set_border_width(24) + + goal = Gtk.Label(label=LEARNER_GOAL) + goal.set_line_wrap(True) + canvas.pack_start(goal, False, False, 0) + + grid = Gtk.Grid(row_spacing=8, column_spacing=8) + self._grid_buttons = [] + for index in range(16): + button = Gtk.ToggleButton(label=str(index + 1)) + button.connect('toggled', self._grid_toggled, index) + grid.attach(button, index % 4, index // 4, 1, 1) + self._grid_buttons.append(button) + canvas.pack_start(grid, True, False, 0) + + self._grid_status = Gtk.Label(label='Find or create a pattern.') + canvas.pack_start(self._grid_status, False, False, 0) + + self.set_canvas(canvas) + canvas.show_all() + + def _grid_toggled(self, button, index): + self._grid_state[index] = button.get_active() + selected = sum(1 for value in self._grid_state if value) + self._grid_status.set_text( + '%d squares are part of your pattern.' % selected) + + def write_file(self, file_path): + with open(file_path, 'w', encoding='utf-8') as output: + json.dump({'grid': self._grid_state}, output) + + def read_file(self, file_path): + try: + with open(file_path, encoding='utf-8') as source: + values = json.load(source).get('grid', []) + except (OSError, ValueError): + values = [] + for index, value in enumerate(values[:16]): + self._grid_state[index] = bool(value) + if hasattr(self, '_grid_buttons'): + self._grid_buttons[index].set_active(bool(value)) + ''' + ) + + +def _render_chess(spec, plan): + body = dedent( + '''\ + def _build_canvas(self): + self._selected_square = None + self._turn = 'w' + self._move_log = [] + self._captured = {'w': [], 'b': []} + self._move_log_view = None + self._buttons = [] + self._board = self._starting_board() + + canvas = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + canvas.set_border_width(16) + + goal = Gtk.Label(label=LEARNER_GOAL) + goal.set_line_wrap(True) + canvas.pack_start(goal, False, False, 0) + + self._status = Gtk.Label() + self._status.set_line_wrap(True) + canvas.pack_start(self._status, False, False, 0) + + play_area = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, + spacing=18) + canvas.pack_start(play_area, True, True, 0) + + board_frame = Gtk.Alignment(xalign=0.5, yalign=0.5, + xscale=0, yscale=0) + board_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, + spacing=6) + board_frame.add(board_box) + + files_top = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, + spacing=1) + files_top.set_halign(Gtk.Align.CENTER) + files_top.pack_start(Gtk.Label(label=' '), False, False, 0) + for file_name in 'abcdefgh': + label = Gtk.Label(label=file_name) + label.set_size_request(64, 18) + files_top.pack_start(label, False, False, 0) + board_box.pack_start(files_top, False, False, 0) + + board_rows = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, + spacing=6) + board_box.pack_start(board_rows, False, False, 0) + ranks = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=1) + board_rows.pack_start(ranks, False, False, 0) + self._grid = Gtk.Grid(row_spacing=1, column_spacing=1) + board_rows.pack_start(self._grid, False, False, 0) + + for row in range(8): + ranks.pack_start( + Gtk.Label(label=str(8 - row)), False, False, 0) + button_row = [] + for col in range(8): + button = Gtk.Button() + button.set_size_request(64, 58) + button.connect('clicked', self._square_clicked, row, col) + self._grid.attach(button, col, row, 1, 1) + button_row.append(button) + self._buttons.append(button_row) + play_area.pack_start(board_frame, True, True, 0) + + side_panel = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, + spacing=10) + side_panel.set_size_request(320, -1) + play_area.pack_start(side_panel, False, False, 0) + + prompt = Gtk.Label( + label='Before each move, say your idea out loud or type it ' + 'below.') + prompt.set_line_wrap(True) + side_panel.pack_start(prompt, False, False, 0) + + self._lesson_steps_label = Gtk.Label() + self._lesson_steps_label.set_xalign(0) + self._lesson_steps_label.set_line_wrap(True) + side_panel.pack_start(self._lesson_steps_label, False, False, 0) + + self._move_idea = Gtk.Entry() + self._move_idea.set_placeholder_text( + 'Move idea, plan, or teamwork note') + side_panel.pack_start(self._move_idea, False, False, 0) + + self._captured_white = Gtk.Label() + self._captured_white.set_xalign(0) + side_panel.pack_start(self._captured_white, False, False, 0) + + self._captured_black = Gtk.Label() + self._captured_black.set_xalign(0) + side_panel.pack_start(self._captured_black, False, False, 0) + + if self._show_move_log: + scroll = Gtk.ScrolledWindow() + scroll.set_size_request(320, 210) + self._move_log_view = Gtk.TextView() + self._move_log_view.set_editable(False) + self._move_log_view.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) + scroll.add(self._move_log_view) + side_panel.pack_start(scroll, True, True, 0) + else: + clean_note = Gtk.Label( + label='Clean board mode: move history is hidden.') + clean_note.set_xalign(0) + clean_note.set_line_wrap(True) + side_panel.pack_start(clean_note, False, False, 0) + + controls = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, + spacing=8) + reset_button = Gtk.Button(label='Reset board') + reset_button.connect('clicked', self._reset_board) + controls.pack_start(reset_button, False, False, 0) + controls.pack_start( + Gtk.Label(label='Click a piece, then its destination.'), + False, False, 0) + side_panel.pack_start(controls, False, False, 0) + + self.set_canvas(canvas) + self._update_lesson_steps() + self._refresh_board() + canvas.show_all() + + def _starting_board(self): + return [ + ['br', 'bn', 'bb', 'bq', 'bk', 'bb', 'bn', 'br'], + ['bp', 'bp', 'bp', 'bp', 'bp', 'bp', 'bp', 'bp'], + ['', '', '', '', '', '', '', ''], + ['', '', '', '', '', '', '', ''], + ['', '', '', '', '', '', '', ''], + ['', '', '', '', '', '', '', ''], + ['wp', 'wp', 'wp', 'wp', 'wp', 'wp', 'wp', 'wp'], + ['wr', 'wn', 'wb', 'wq', 'wk', 'wb', 'wn', 'wr'], + ] + + def _piece_labels(self): + return { + 'wk': '\\u2654', 'wq': '\\u2655', 'wr': '\\u2656', + 'wb': '\\u2657', 'wn': '\\u2658', 'wp': '\\u2659', + 'bk': '\\u265a', 'bq': '\\u265b', 'br': '\\u265c', + 'bb': '\\u265d', 'bn': '\\u265e', 'bp': '\\u265f', + } + + def _square_clicked(self, button, row, col): + piece = self._board[row][col] + if self._selected_square is None: + if not piece: + self._set_status('Choose a %s piece to move.' % + self._turn_name()) + return + if piece[0] != self._turn: + self._set_status('It is %s turn.' % self._turn_name()) + return + self._selected_square = (row, col) + self._set_status('Selected %s at %s.' % + (self._piece_name(piece), + self._square_name(row, col))) + self._refresh_board() + return + + start_row, start_col = self._selected_square + moving = self._board[start_row][start_col] + if (row, col) == self._selected_square: + self._selected_square = None + self._set_status('Selection cleared.') + self._refresh_board() + return + if piece and piece[0] == self._turn: + self._selected_square = (row, col) + self._set_status('Selected %s at %s.' % + (self._piece_name(piece), + self._square_name(row, col))) + self._refresh_board() + return + if not self._can_move(moving, start_row, start_col, row, col): + self._set_status('%s cannot move to %s.' % + (self._piece_name(moving), + self._square_name(row, col))) + return + + capture = self._board[row][col] + self._board[row][col] = moving + self._board[start_row][start_col] = '' + move_text = '%s %s to %s' % ( + self._piece_name(moving), + self._square_name(start_row, start_col), + self._square_name(row, col), + ) + if capture: + move_text += ' captures %s' % self._piece_name(capture) + self._captured[self._turn].append(capture) + idea = self._move_idea.get_text().strip() + if idea: + move_text += ' - idea: %s' % idea + self._move_idea.set_text('') + if self._show_move_log: + self._move_log.append(move_text) + self._turn = 'b' if self._turn == 'w' else 'w' + self._selected_square = None + self._set_status('%s. %s to move.' % + (move_text, self._turn_name())) + self._refresh_board() + + def _can_move(self, piece, start_row, start_col, row, col): + if not piece or piece[0] != self._turn: + return False + target = self._board[row][col] + if target and target[0] == piece[0]: + return False + + dr = row - start_row + dc = col - start_col + abs_dr = abs(dr) + abs_dc = abs(dc) + kind = piece[1] + + if kind == 'p': + direction = -1 if piece[0] == 'w' else 1 + home_row = 6 if piece[0] == 'w' else 1 + if dc == 0 and not target: + if dr == direction: + return True + if start_row == home_row and dr == 2 * direction: + mid_row = start_row + direction + return not self._board[mid_row][start_col] + if abs_dc == 1 and dr == direction and target: + return True + return False + if kind == 'n': + return (abs_dr, abs_dc) in ((1, 2), (2, 1)) + if kind == 'k': + return max(abs_dr, abs_dc) == 1 + if kind == 'b': + return abs_dr == abs_dc and self._path_clear( + start_row, start_col, row, col) + if kind == 'r': + return (dr == 0 or dc == 0) and self._path_clear( + start_row, start_col, row, col) + if kind == 'q': + diagonal = abs_dr == abs_dc + straight = dr == 0 or dc == 0 + return (diagonal or straight) and self._path_clear( + start_row, start_col, row, col) + return False + + def _path_clear(self, start_row, start_col, row, col): + step_row = self._step(row - start_row) + step_col = self._step(col - start_col) + current_row = start_row + step_row + current_col = start_col + step_col + while (current_row, current_col) != (row, col): + if self._board[current_row][current_col]: + return False + current_row += step_row + current_col += step_col + return True + + def _step(self, value): + if value < 0: + return -1 + if value > 0: + return 1 + return 0 + + def _refresh_board(self): + labels = self._piece_labels() + for row in range(8): + for col in range(8): + piece = self._board[row][col] + label = labels.get(piece, ' ') + self._buttons[row][col].set_label(label) + self._style_square( + self._buttons[row][col], + row, + col, + self._selected_square == (row, col), + ) + self._buttons[row][col].set_tooltip_text( + '%s %s' % (self._square_name(row, col), + self._piece_name(piece) if piece else + 'empty')) + if not self._move_log: + self._set_status('%s to move. Select a piece.' % + self._turn_name()) + self._update_captured() + self._update_move_log() + + def _style_square(self, button, row, col, selected): + color = Gdk.RGBA() + if selected: + color.parse('#f4d06f') + elif (row + col) % 2: + color.parse('#c9c9c9') + else: + color.parse('#f2f2f2') + button.override_background_color(Gtk.StateFlags.NORMAL, color) + + def _update_captured(self): + labels = self._piece_labels() + white = ' '.join(labels.get(piece, '') for piece in + self._captured.get('w', [])) or 'none' + black = ' '.join(labels.get(piece, '') for piece in + self._captured.get('b', [])) or 'none' + self._captured_white.set_text('White captured: %s' % white) + self._captured_black.set_text('Black captured: %s' % black) + + def _update_lesson_steps(self): + text = '\\n'.join( + '%d. %s' % (index, step) + for index, step in enumerate(self._lesson_steps, 1) + ) + self._lesson_steps_label.set_text( + text or 'Take turns, explain moves, then save to Journal.') + + def _update_move_log(self): + if self._move_log_view is None: + return + text = '\\n'.join( + '%d. %s' % (index, move) + for index, move in enumerate(self._move_log, 1) + ) + self._move_log_view.get_buffer().set_text( + text or 'Move log will appear here.') + + def _set_status(self, text): + self._status.set_text(text) + + def _turn_name(self): + return 'White' if self._turn == 'w' else 'Black' + + def _square_name(self, row, col): + return '%s%d' % ('abcdefgh'[col], 8 - row) + + def _piece_name(self, piece): + names = { + 'k': 'king', + 'q': 'queen', + 'r': 'rook', + 'b': 'bishop', + 'n': 'knight', + 'p': 'pawn', + } + if not piece: + return 'empty square' + color = 'White' if piece[0] == 'w' else 'Black' + return '%s %s' % (color, names.get(piece[1], 'piece')) + + def _reset_board(self, button): + self._board = self._starting_board() + self._selected_square = None + self._turn = 'w' + self._move_log = [] + self._captured = {'w': [], 'b': []} + self._set_status('Board reset. White to move.') + self._refresh_board() + + def write_file(self, file_path): + state = { + 'board': self._board, + 'turn': self._turn, + 'move_log': self._move_log if self._show_move_log else [], + 'captured': self._captured, + } + with open(file_path, 'w', encoding='utf-8') as output: + json.dump(state, output) + + def read_file(self, file_path): + try: + with open(file_path, encoding='utf-8') as source: + state = json.load(source) + except (OSError, ValueError): + state = {} + board = state.get('board') + if isinstance(board, list) and len(board) == 8: + self._board = board + self._turn = state.get('turn', 'w') + if self._turn not in ('w', 'b'): + self._turn = 'w' + if self._show_move_log: + move_log = state.get('move_log', []) + self._move_log = [ + str(move) for move in move_log + if isinstance(move, str) + ][:200] + else: + self._move_log = [] + captured = state.get('captured') + if isinstance(captured, dict): + self._captured = { + 'w': [ + str(piece) for piece in captured.get('w', []) + if isinstance(piece, str) + ][:32], + 'b': [ + str(piece) for piece in captured.get('b', []) + if isinstance(piece, str) + ][:32], + } + if hasattr(self, '_buttons'): + self._selected_square = None + self._refresh_board() + ''' + ) + return body.replace( + " self._captured = {'w': [], 'b': []}\n", + " self._captured = {'w': [], 'b': []}\n" + " self._show_move_log = %s\n" % + ('True' if plan.get('chess_show_move_log', True) else 'False') + + " self._lesson_steps = %s\n" % + json.dumps(plan.get('learner_steps') or []), + 1, + ) + + +def _render_carrom(spec, plan): + return dedent( + '''\ + def _build_canvas(self): + self._active_player = 'A' + self._scores = {'A': 0, 'B': 0} + self._fouls = {'A': 0, 'B': 0} + self._coins = {'white': 9, 'black': 9, 'queen': 1} + self._shot_log = [] + self._aim_point = [0.5, 0.82] + + canvas = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) + canvas.set_border_width(18) + + goal = Gtk.Label(label=LEARNER_GOAL) + goal.set_line_wrap(True) + goal.set_xalign(0) + canvas.pack_start(goal, False, False, 0) + + play_area = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, + spacing=18) + canvas.pack_start(play_area, True, True, 0) + + self._board = Gtk.DrawingArea() + self._board.set_size_request(560, 560) + self._board.add_events(Gdk.EventMask.BUTTON_PRESS_MASK) + self._board.connect('draw', self._draw_carrom_board) + self._board.connect('button-press-event', + self._board_clicked) + play_area.pack_start(self._board, True, True, 0) + + side = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) + side.set_size_request(330, -1) + play_area.pack_start(side, False, False, 0) + + self._turn_label = Gtk.Label() + self._turn_label.set_xalign(0) + self._turn_label.set_line_wrap(True) + side.pack_start(self._turn_label, False, False, 0) + + self._score_label = Gtk.Label() + self._score_label.set_xalign(0) + self._score_label.set_line_wrap(True) + side.pack_start(self._score_label, False, False, 0) + + self._aim_label = Gtk.Label() + self._aim_label.set_xalign(0) + self._aim_label.set_line_wrap(True) + side.pack_start(self._aim_label, False, False, 0) + + self._shot_note = Gtk.Entry() + self._shot_note.set_placeholder_text( + 'Shot idea, rebound plan, or partner note') + side.pack_start(self._shot_note, False, False, 0) + + controls = Gtk.Grid(row_spacing=6, column_spacing=6) + side.pack_start(controls, False, False, 0) + + buttons = ( + ('Pocket white', self._record_pocket, 'white'), + ('Pocket black', self._record_pocket, 'black'), + ('Pocket queen', self._record_pocket, 'queen'), + ('Foul', self._record_foul, None), + ('Switch turn', self._switch_turn, None), + ('Reset match', self._reset_match, None), + ) + for index, item in enumerate(buttons): + label, callback, value = item + button = Gtk.Button(label=label) + if value is None: + button.connect('clicked', callback) + else: + button.connect('clicked', callback, value) + controls.attach(button, index % 2, index // 2, 1, 1) + + log_label = Gtk.Label(label='Shot log') + log_label.set_xalign(0) + side.pack_start(log_label, False, False, 0) + + scroll = Gtk.ScrolledWindow() + scroll.set_size_request(300, 180) + self._log_view = Gtk.TextView() + self._log_view.set_editable(False) + self._log_view.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) + scroll.add(self._log_view) + side.pack_start(scroll, True, True, 0) + + help_text = Gtk.Label( + label='Click the board to place the striker aim marker, ' + 'record the shot result, then switch turns. The match ' + 'state is saved in the Journal.') + help_text.set_xalign(0) + help_text.set_line_wrap(True) + side.pack_start(help_text, False, False, 0) + + self.set_canvas(canvas) + self._update_carrom_panel() + canvas.show_all() + + def _board_geometry(self, allocation): + size = min(allocation.width, allocation.height) - 18 + if size < 240: + size = min(allocation.width, allocation.height) + left = (allocation.width - size) / 2.0 + top = (allocation.height - size) / 2.0 + return left, top, size + + def _board_clicked(self, widget, event): + left, top, size = self._board_geometry(widget.get_allocation()) + if event.x < left or event.y < top or \ + event.x > left + size or event.y > top + size: + return False + self._aim_point = [ + (event.x - left) / size, + (event.y - top) / size, + ] + self._set_carrom_status( + '%s set an aim point. Add a note, then record the result.' % + self._player_name()) + self._board.queue_draw() + self._update_carrom_panel() + return True + + def _draw_carrom_board(self, widget, context): + left, top, size = self._board_geometry(widget.get_allocation()) + context.set_source_rgb(0.92, 0.80, 0.58) + context.rectangle(left, top, size, size) + context.fill() + + border = max(8, size * 0.035) + context.set_source_rgb(0.45, 0.22, 0.09) + context.set_line_width(border) + context.rectangle(left + border / 2.0, top + border / 2.0, + size - border, size - border) + context.stroke() + + context.set_source_rgb(0.66, 0.36, 0.17) + context.set_line_width(max(2, size * 0.006)) + context.rectangle(left + size * 0.12, top + size * 0.12, + size * 0.76, size * 0.76) + context.stroke() + + pocket_radius = size * 0.052 + for nx, ny in ((0.08, 0.08), (0.92, 0.08), + (0.08, 0.92), (0.92, 0.92)): + self._draw_disc(context, left + nx * size, top + ny * size, + pocket_radius, (0.05, 0.05, 0.05), + (0.35, 0.18, 0.08)) + + context.set_source_rgb(0.52, 0.22, 0.12) + context.set_line_width(max(2, size * 0.006)) + context.arc(left + size * 0.5, top + size * 0.5, + size * 0.16, 0, 6.28318) + context.stroke() + context.arc(left + size * 0.5, top + size * 0.5, + size * 0.045, 0, 6.28318) + context.stroke() + + self._draw_remaining_coins(context, left, top, size) + + aim_x = left + self._aim_point[0] * size + aim_y = top + self._aim_point[1] * size + center_x = left + size * 0.5 + center_y = top + size * 0.5 + context.set_source_rgb(0.20, 0.35, 0.75) + context.set_line_width(max(2, size * 0.006)) + context.move_to(aim_x, aim_y) + context.line_to(center_x, center_y) + context.stroke() + self._draw_disc(context, aim_x, aim_y, size * 0.04, + (0.93, 0.93, 0.98), (0.20, 0.35, 0.75)) + return False + + def _draw_remaining_coins(self, context, left, top, size): + positions = ( + (0.00, -0.09), (0.08, -0.04), (0.08, 0.05), + (0.00, 0.10), (-0.08, 0.05), (-0.08, -0.04), + (0.15, 0.00), (-0.15, 0.00), (0.00, 0.18), + (0.00, -0.18), (0.14, 0.12), (-0.14, 0.12), + (0.14, -0.12), (-0.14, -0.12), (0.21, 0.08), + (-0.21, 0.08), (0.21, -0.08), (-0.21, -0.08), + ) + radius = size * 0.028 + index = 0 + for count, fill, stroke in ( + (self._coins.get('white', 0), + (0.96, 0.94, 0.86), (0.55, 0.48, 0.38)), + (self._coins.get('black', 0), + (0.08, 0.08, 0.08), (0.35, 0.35, 0.35))): + for unused in range(max(0, min(9, count))): + dx, dy = positions[index % len(positions)] + self._draw_disc(context, left + size * (0.5 + dx), + top + size * (0.5 + dy), radius, + fill, stroke) + index += 1 + if self._coins.get('queen', 0): + self._draw_disc(context, left + size * 0.5, + top + size * 0.5, radius * 1.05, + (0.72, 0.05, 0.08), (0.40, 0.02, 0.04)) + + def _draw_disc(self, context, x, y, radius, fill, stroke): + context.set_source_rgb(fill[0], fill[1], fill[2]) + context.arc(x, y, radius, 0, 6.28318) + context.fill_preserve() + context.set_source_rgb(stroke[0], stroke[1], stroke[2]) + context.set_line_width(max(1, radius * 0.16)) + context.stroke() + + def _record_pocket(self, button, coin_type): + if self._coins.get(coin_type, 0) <= 0: + self._set_carrom_status('No %s coins remain.' % coin_type) + return + self._coins[coin_type] -= 1 + points = 3 if coin_type == 'queen' else 1 + self._scores[self._active_player] += points + note = self._consume_shot_note() + self._append_shot_log( + '%s pocketed %s for %d point%s%s' % ( + self._player_name(), + coin_type, + points, + '' if points == 1 else 's', + note, + )) + self._set_carrom_status( + 'Recorded %s. Switch turns or let the same player continue.' % + coin_type) + self._update_carrom_panel() + + def _record_foul(self, button): + self._fouls[self._active_player] += 1 + if self._scores[self._active_player] > 0: + self._scores[self._active_player] -= 1 + note = self._consume_shot_note() + self._append_shot_log('%s made a foul%s' % ( + self._player_name(), note)) + self._set_carrom_status('Foul recorded. Switch turns.') + self._update_carrom_panel() + + def _switch_turn(self, button): + self._active_player = 'B' if self._active_player == 'A' else 'A' + self._set_carrom_status('%s to shoot next.' % + self._player_name()) + self._update_carrom_panel() + + def _reset_match(self, button): + self._active_player = 'A' + self._scores = {'A': 0, 'B': 0} + self._fouls = {'A': 0, 'B': 0} + self._coins = {'white': 9, 'black': 9, 'queen': 1} + self._shot_log = [] + self._aim_point = [0.5, 0.82] + self._set_carrom_status('New carrom match ready.') + self._update_carrom_panel() + + def _consume_shot_note(self): + if not hasattr(self, '_shot_note'): + return '' + note = self._shot_note.get_text().strip() + self._shot_note.set_text('') + if note: + return ' - %s' % note + return '' + + def _append_shot_log(self, text): + self._shot_log.append(text) + self._shot_log = self._shot_log[-80:] + + def _player_name(self): + return 'Student A' if self._active_player == 'A' else 'Student B' + + def _set_carrom_status(self, text): + self._carrom_status = text + + def _update_carrom_panel(self): + status = getattr( + self, + '_carrom_status', + 'Student A to shoot. Click the board to set an aim point.') + if hasattr(self, '_turn_label'): + self._turn_label.set_text('%s\\n%s' % + (self._player_name(), status)) + if hasattr(self, '_score_label'): + self._score_label.set_text( + 'Score - Student A: %d Student B: %d\\n' + 'Fouls - Student A: %d Student B: %d\\n' + 'Coins left - white: %d black: %d queen: %d' % ( + self._scores.get('A', 0), + self._scores.get('B', 0), + self._fouls.get('A', 0), + self._fouls.get('B', 0), + self._coins.get('white', 0), + self._coins.get('black', 0), + self._coins.get('queen', 0), + )) + if hasattr(self, '_aim_label'): + self._aim_label.set_text( + 'Aim marker: %.0f%% across, %.0f%% down' % ( + self._aim_point[0] * 100, + self._aim_point[1] * 100, + )) + if hasattr(self, '_log_view'): + text = '\\n'.join( + '%d. %s' % (index + 1, item) + for index, item in enumerate(self._shot_log) + ) + if not text: + text = 'No shots recorded yet.' + self._log_view.get_buffer().set_text(text) + if hasattr(self, '_board'): + self._board.queue_draw() + + def write_file(self, file_path): + state = { + 'active_player': self._active_player, + 'scores': self._scores, + 'fouls': self._fouls, + 'coins': self._coins, + 'shot_log': self._shot_log, + 'aim_point': self._aim_point, + } + with open(file_path, 'w', encoding='utf-8') as output: + json.dump(state, output) + + def read_file(self, file_path): + try: + with open(file_path, encoding='utf-8') as source: + state = json.load(source) + except (OSError, ValueError): + state = {} + self._active_player = ( + 'B' if state.get('active_player') == 'B' else 'A') + self._scores = self._clean_score_dict(state.get('scores')) + self._fouls = self._clean_score_dict(state.get('fouls')) + self._coins = self._clean_coin_dict(state.get('coins')) + self._shot_log = [ + str(item) for item in state.get('shot_log', []) + if isinstance(item, str) + ][:80] + aim = state.get('aim_point') + if isinstance(aim, list) and len(aim) == 2: + self._aim_point = [ + max(0.0, min(1.0, float(aim[0]))), + max(0.0, min(1.0, float(aim[1]))), + ] + else: + self._aim_point = [0.5, 0.82] + if hasattr(self, '_turn_label'): + self._set_carrom_status('%s restored from the Journal.' % + self._player_name()) + self._update_carrom_panel() + + def _clean_score_dict(self, value): + if not isinstance(value, dict): + return {'A': 0, 'B': 0} + return { + 'A': max(0, int(value.get('A', 0))), + 'B': max(0, int(value.get('B', 0))), + } + + def _clean_coin_dict(self, value): + if not isinstance(value, dict): + return {'white': 9, 'black': 9, 'queen': 1} + return { + 'white': max(0, min(9, int(value.get('white', 9)))), + 'black': max(0, min(9, int(value.get('black', 9)))), + 'queen': max(0, min(1, int(value.get('queen', 1)))), + } + ''' + ) + + +def _render_narrative(spec, plan): + starter = plan.get('starter_text', spec.prompt) + return dedent( + '''\ + def _build_canvas(self): + canvas = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + canvas.set_border_width(24) + + goal = Gtk.Label(label=LEARNER_GOAL) + goal.set_line_wrap(True) + canvas.pack_start(goal, False, False, 0) + + scroll = Gtk.ScrolledWindow() + self._editor = Gtk.TextView() + self._editor.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) + self._editor.get_buffer().set_text({starter}) + scroll.add(self._editor) + canvas.pack_start(scroll, True, True, 0) + + self.set_canvas(canvas) + canvas.show_all() + + def write_file(self, file_path): + text_buffer = self._editor.get_buffer() + start, end = text_buffer.get_bounds() + text = text_buffer.get_text(start, end, True) + with open(file_path, 'w', encoding='utf-8') as output: + output.write(text) + + def read_file(self, file_path): + try: + with open(file_path, encoding='utf-8') as source: + text = source.read() + except OSError: + text = '' + if hasattr(self, '_editor'): + self._editor.get_buffer().set_text(text) + ''' + ).format(starter=json.dumps(starter)) + + +def _render_quiz(spec, plan): + questions = plan.get('questions') or [ + { + 'question': 'What is one thing you want to learn?', + 'answer': 'anything', + }, + { + 'question': 'How could you explain your idea to a friend?', + 'answer': 'anything', + }, + { + 'question': 'What would you change after testing it?', + 'answer': 'anything', + }, + ] + return dedent( + '''\ + def _build_canvas(self): + self._questions = __QUESTIONS__ + self._question_index = 0 + self._score = 0 + + canvas = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=14) + canvas.set_border_width(24) + + self._question_label = Gtk.Label() + self._question_label.set_line_wrap(True) + canvas.pack_start(self._question_label, True, True, 0) + + self._answer_entry = Gtk.Entry() + self._answer_entry.connect('activate', self._check_answer) + canvas.pack_start(self._answer_entry, False, False, 0) + + check_button = Gtk.Button(label='Check answer') + check_button.connect('clicked', self._check_answer) + canvas.pack_start(check_button, False, False, 0) + + self._feedback = Gtk.Label(label=LEARNER_GOAL) + canvas.pack_start(self._feedback, False, False, 0) + self._show_question() + + self.set_canvas(canvas) + canvas.show_all() + + def _show_question(self): + item = self._questions[self._question_index] + self._question_label.set_text(item['question']) + self._answer_entry.set_text('') + self._answer_entry.grab_focus() + + def _check_answer(self, widget): + item = self._questions[self._question_index] + expected = item.get('answer', 'anything').strip().lower() + answer = self._answer_entry.get_text().strip().lower() + if expected == 'anything' or answer == expected: + self._score += 1 + message = 'Good thinking!' + else: + message = 'Try comparing your answer with: %s' % expected + self._question_index = ( + self._question_index + 1) % len(self._questions) + self._feedback.set_text( + '%s Score: %d' % (message, self._score)) + self._show_question() + + def write_file(self, file_path): + state = { + 'question_index': self._question_index, + 'score': self._score, + } + with open(file_path, 'w', encoding='utf-8') as output: + json.dump(state, output) + + def read_file(self, file_path): + try: + with open(file_path, encoding='utf-8') as source: + state = json.load(source) + except (OSError, ValueError): + state = {} + self._score = int(state.get('score', 0)) + self._question_index = int( + state.get('question_index', 0)) % len(self._questions) + if hasattr(self, '_question_label'): + self._show_question() + ''' + ).replace('__QUESTIONS__', repr(questions)) + + +def _render_utility(spec, plan): + mode = plan.get('utility_mode', 'word_counter') + if mode == 'counter': + return _render_counter_utility() + if mode == 'timer': + return _render_timer_utility() + return _render_word_counter_utility() + + +def _render_word_counter_utility(): + return dedent( + '''\ + def _build_canvas(self): + canvas = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + canvas.set_border_width(24) + + goal = Gtk.Label(label=LEARNER_GOAL) + goal.set_line_wrap(True) + canvas.pack_start(goal, False, False, 0) + + self._utility_input = Gtk.TextView() + self._utility_input.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) + self._utility_input.get_buffer().connect( + 'changed', self._update_count) + scroll = Gtk.ScrolledWindow() + scroll.add(self._utility_input) + canvas.pack_start(scroll, True, True, 0) + + self._utility_result = Gtk.Label(label='0 words, 0 characters') + canvas.pack_start(self._utility_result, False, False, 0) + + self.set_canvas(canvas) + canvas.show_all() + + def _get_utility_text(self): + text_buffer = self._utility_input.get_buffer() + start, end = text_buffer.get_bounds() + return text_buffer.get_text(start, end, True) + + def _update_count(self, text_buffer): + text = self._get_utility_text() + words = len(text.split()) + self._utility_result.set_text( + '%d words, %d characters' % (words, len(text))) + + def write_file(self, file_path): + with open(file_path, 'w', encoding='utf-8') as output: + output.write(self._get_utility_text()) + + def read_file(self, file_path): + try: + with open(file_path, encoding='utf-8') as source: + text = source.read() + except OSError: + text = '' + if hasattr(self, '_utility_input'): + self._utility_input.get_buffer().set_text(text) + ''' + ) + + +def _render_counter_utility(): + return dedent( + '''\ + def _build_canvas(self): + self._count = 0 + canvas = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=16) + canvas.set_border_width(24) + + goal = Gtk.Label(label=LEARNER_GOAL) + goal.set_line_wrap(True) + canvas.pack_start(goal, False, False, 0) + + self._counter_label = Gtk.Label(label='0') + canvas.pack_start(self._counter_label, True, True, 0) + + controls = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, + spacing=10) + controls.set_halign(Gtk.Align.CENTER) + canvas.pack_start(controls, False, False, 0) + + minus_button = Gtk.Button(label='-1') + minus_button.connect('clicked', self._change_count, -1) + controls.pack_start(minus_button, False, False, 0) + + plus_button = Gtk.Button(label='+1') + plus_button.connect('clicked', self._change_count, 1) + controls.pack_start(plus_button, False, False, 0) + + reset_button = Gtk.Button(label='Reset') + reset_button.connect('clicked', self._reset_count) + controls.pack_start(reset_button, False, False, 0) + + self._counter_note = Gtk.Label( + label='Use the count, then explain what it means.') + self._counter_note.set_line_wrap(True) + canvas.pack_start(self._counter_note, False, False, 0) + + self.set_canvas(canvas) + canvas.show_all() + + def _change_count(self, button, amount): + self._count += amount + self._update_counter() + + def _reset_count(self, button): + self._count = 0 + self._update_counter() + + def _update_counter(self): + self._counter_label.set_text(str(self._count)) + + def write_file(self, file_path): + with open(file_path, 'w', encoding='utf-8') as output: + json.dump({'count': self._count}, output) + + def read_file(self, file_path): + try: + with open(file_path, encoding='utf-8') as source: + state = json.load(source) + except (OSError, ValueError): + state = {} + self._count = int(state.get('count', 0)) + if hasattr(self, '_counter_label'): + self._update_counter() + ''' + ) + + +def _render_timer_utility(): + return dedent( + '''\ + def _build_canvas(self): + self._elapsed_seconds = 0 + self._timer_running = False + self._timer_id = 0 + + canvas = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=16) + canvas.set_border_width(24) + + goal = Gtk.Label(label=LEARNER_GOAL) + goal.set_line_wrap(True) + canvas.pack_start(goal, False, False, 0) + + self._timer_label = Gtk.Label(label='00:00') + canvas.pack_start(self._timer_label, True, True, 0) + + controls = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, + spacing=10) + controls.set_halign(Gtk.Align.CENTER) + canvas.pack_start(controls, False, False, 0) + + self._timer_toggle = Gtk.Button(label='Start') + self._timer_toggle.connect('clicked', self._toggle_timer) + controls.pack_start(self._timer_toggle, False, False, 0) + + reset_button = Gtk.Button(label='Reset') + reset_button.connect('clicked', self._reset_timer) + controls.pack_start(reset_button, False, False, 0) + + self._timer_note = Gtk.Label( + label='Use elapsed time as evidence for your reflection.') + self._timer_note.set_line_wrap(True) + canvas.pack_start(self._timer_note, False, False, 0) + + self.set_canvas(canvas) + canvas.show_all() + + def _toggle_timer(self, button): + self._timer_running = not self._timer_running + if self._timer_running: + self._timer_toggle.set_label('Pause') + if not self._timer_id: + self._timer_id = GLib.timeout_add_seconds( + 1, self._tick_timer) + else: + self._timer_toggle.set_label('Start') + + def _tick_timer(self): + if not self._timer_running: + self._timer_id = 0 + return False + self._elapsed_seconds += 1 + self._update_timer() + return True + + def _reset_timer(self, button): + self._elapsed_seconds = 0 + self._timer_running = False + self._timer_toggle.set_label('Start') + self._update_timer() + + def _update_timer(self): + minutes = self._elapsed_seconds // 60 + seconds = self._elapsed_seconds % 60 + self._timer_label.set_text('%02d:%02d' % (minutes, seconds)) + + def write_file(self, file_path): + with open(file_path, 'w', encoding='utf-8') as output: + json.dump({'elapsed_seconds': self._elapsed_seconds}, output) + + def read_file(self, file_path): + try: + with open(file_path, encoding='utf-8') as source: + state = json.load(source) + except (OSError, ValueError): + state = {} + self._elapsed_seconds = int(state.get('elapsed_seconds', 0)) + if hasattr(self, '_timer_label'): + self._update_timer() + ''' + ) + + +def _indent(text, spaces): + prefix = ' ' * spaces + return '\n'.join( + prefix + line if line else '' for line in text.splitlines() + ) + + +_TEMPLATE_RENDERERS = { + 'canvas': _render_canvas, + 'carrom': _render_carrom, + 'chess': _render_chess, + 'grid': _render_grid, + 'narrative': _render_narrative, + 'quiz': _render_quiz, + 'utility': _render_utility, +} diff --git a/src/jarabe/model/aodvalidator.py b/src/jarabe/model/aodvalidator.py new file mode 100644 index 0000000000..f59d90eb95 --- /dev/null +++ b/src/jarabe/model/aodvalidator.py @@ -0,0 +1,437 @@ +# Copyright (C) 2026 Sugar Labs +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import ast +import configparser +from dataclasses import dataclass +from dataclasses import field +import os +import re +import zipfile + +from sugar3.bundle.helpers import bundle_from_archive +from sugar3.bundle.helpers import bundle_from_dir + +from jarabe.model.aodspec import LICENSE_IDS + + +ALLOWED_IMPORT_ROOTS = { + 'cairo', + 'datetime', + 'gettext', + 'gi', + 'json', + 'logging', + 'math', + 'random', + 'sugar3', +} + +FORBIDDEN_IMPORT_ROOTS = { + 'ctypes', + 'http', + 'multiprocessing', + 'os', + 'pathlib', + 'requests', + 'shutil', + 'socket', + 'subprocess', + 'urllib', +} + +FORBIDDEN_CALLS = { + '__import__', + 'compile', + 'eval', + 'exec', + 'globals', + 'locals', +} + + +@dataclass +class ValidationReport: + errors: list = field(default_factory=list) + warnings: list = field(default_factory=list) + + @property + def valid(self): + return not self.errors + + def extend(self, report): + self.errors.extend(report.errors) + self.warnings.extend(report.warnings) + + +def validate_source(source): + report = ValidationReport() + try: + tree = ast.parse(source) + except SyntaxError as error: + report.errors.append( + 'Python syntax error on line %s: %s' + % (error.lineno, error.msg) + ) + return report + + imports = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + imports.update(alias.name.split('.')[0] for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.module: + imports.add(node.module.split('.')[0]) + + for name in sorted(imports): + if name in FORBIDDEN_IMPORT_ROOTS: + report.errors.append('Forbidden import: %s' % name) + elif name not in ALLOWED_IMPORT_ROOTS: + report.errors.append('Import is not allowlisted: %s' % name) + + for node in ast.walk(tree): + if isinstance(node, ast.Call): + call_name = _call_name(node.func) + if call_name in FORBIDDEN_CALLS: + report.errors.append('Forbidden call: %s' % call_name) + + activity_classes = [ + node for node in tree.body + if all(( + isinstance(node, ast.ClassDef), + any(_base_name(base).endswith('activity.Activity') + for base in getattr(node, 'bases', ())), + )) + ] + if len(activity_classes) != 1: + report.errors.append( + 'Generated source must define exactly one Activity subclass.' + ) + return report + + activity_class = activity_classes[0] + methods = { + node.name: node for node in activity_class.body + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + } + for required in ('__init__', 'read_file', 'write_file'): + if required not in methods: + report.errors.append('Missing required method: %s' % required) + + calls = { + _call_name(node.func) for node in ast.walk(activity_class) + if isinstance(node, ast.Call) + } + for required_call in ('set_canvas', 'set_toolbar_box'): + if not any(name.endswith(required_call) for name in calls): + report.errors.append( + 'Generated activity must call %s().' % required_call + ) + + if 'StopButton' not in source: + report.errors.append('Generated activity must include a StopButton.') + if 'ToolbarBox' not in source: + report.errors.append('Generated activity must include a ToolbarBox.') + + return report + + +def validate_activity_source_for_request(source, spec, plan=None): + """Validate generated activity.py against the teacher's request. + + validate_source() checks the Sugar/Python safety contract. This extra + pass catches the common LLM failure mode where the source is technically + valid but too generic to be the requested activity. + """ + report = validate_source(source) + if report.errors: + return report + + request = _request_text(spec, plan) + prompt = _spec_request_text(spec) + prompt_words = _tokens(prompt) + source_lower = source.lower() + + if len(source) < 1200: + report.errors.append( + 'Generated activity is too small to be a full learner activity.' + ) + + if _has_any(prompt_words, ( + 'draw', 'drawing', 'paint', 'painting', 'sketch', 'canvas', + 'color', 'colour')): + _require_source_terms( + report, + source_lower, + ('drawingarea',), + 'Drawing requests must use a Gtk.DrawingArea draw surface.', + ) + _require_source_terms( + report, + source_lower, + ('button-press-event', 'button_press_event', + 'motion-notify-event', 'motion_notify_event', + 'button-release-event', 'button_release_event', + 'eventmask', 'event_mask', 'add_events', 'add-events'), + 'Drawing requests must handle pointer events, not show a static ' + 'sample image.', + ) + _require_source_terms( + report, + source_lower, + ('stroke', 'strokes', 'points', 'path', 'line', 'lines', + 'drawings', 'cairo'), + 'Drawing requests must store learner drawing state for Journal ' + 'saving.', + ) + + if _has_any(prompt_words, ( + 'two', 'pair', 'partner', 'partners', 'student', 'students', + 'team', 'teams', 'together', 'collaborative', 'collaboration')): + _require_source_terms( + report, + source_lower, + ('student', 'learner', 'team', 'partner', 'player'), + 'Two-learner requests must show learner/team roles in the ' + 'activity.', + ) + _require_source_terms( + report, + source_lower, + ('turn', 'switch', 'active', 'partner', 'together', + 'collaborat'), + 'Two-learner requests must include a turn, role, or ' + 'collaboration workflow.', + ) + + if 'carrom' in prompt_words: + _require_source_terms( + report, + source_lower, + ('striker', 'pocket', 'coin', 'queen'), + 'Carrom requests must model a board with striker, pockets, ' + 'coins, or queen state.', + ) + _require_source_terms( + report, + source_lower, + ('score', 'turn', 'foul'), + 'Carrom requests must include scoring, turns, or fouls.', + ) + + if 'chess' in prompt_words: + _require_source_terms( + report, + source_lower, + ('king', 'queen', 'rook', 'bishop', 'knight', 'pawn'), + 'Chess requests must model chess pieces, not a generic grid.', + ) + _require_source_terms( + report, + source_lower, + ('8', 'grid', 'board', 'square'), + 'Chess requests must include a visible 8x8 board or board ' + 'state.', + ) + + if _has_any(prompt_words, ('quiz', 'question', 'questions')): + _require_source_terms( + report, + source_lower, + ('question', 'answer', 'feedback', 'score'), + 'Quiz requests must include questions, answers, feedback, or ' + 'score state.', + ) + _require_source_terms( + report, + source_lower, + ('entry', 'textview', 'button'), + 'Quiz requests must provide learner input controls.', + ) + + if re.search(r'\b(todo|lorem ipsum|placeholder only)\b', + source_lower): + report.errors.append( + 'Generated activity still contains placeholder text.' + ) + + request_words = _tokens(request) + overlap = request_words.intersection(_tokens(source_lower)) + if request_words and len(overlap) < min(2, len(request_words)): + report.warnings.append( + 'Generated source contains little vocabulary from the request.' + ) + + return report + + +def validate_project(project_path): + report = ValidationReport() + required_files = ( + 'activity.py', + 'setup.py', + 'README.md', + 'LICENSE', + 'aod_plan.json', + os.path.join('activity', 'activity.info'), + os.path.join('activity', 'activity.svg'), + ) + for relative_path in required_files: + if not os.path.isfile(os.path.join(project_path, relative_path)): + report.errors.append('Missing project file: %s' % relative_path) + + source_path = os.path.join(project_path, 'activity.py') + if os.path.isfile(source_path): + with open(source_path, encoding='utf-8') as source_file: + report.extend(validate_source(source_file.read())) + + info_path = os.path.join(project_path, 'activity', 'activity.info') + if os.path.isfile(info_path): + report.extend(_validate_activity_info(info_path)) + + if bundle_from_dir(project_path) is None: + report.errors.append('Sugar cannot recognize the project directory.') + + return report + + +def validate_bundle(bundle_path): + report = ValidationReport() + if not os.path.isfile(bundle_path): + report.errors.append('XO bundle does not exist.') + return report + + try: + bundle = bundle_from_archive( + bundle_path, + mime_type='application/vnd.olpc-sugar', + ) + if bundle is None: + report.errors.append('Sugar cannot recognize the XO bundle.') + return report + + root = bundle.get_name().replace(' ', '') + '.activity/' + with zipfile.ZipFile(bundle_path) as archive: + names = archive.namelist() + if not any(name.endswith('/activity/activity.info') + for name in names): + report.errors.append( + 'XO bundle is missing activity/activity.info.' + ) + if not any(name.endswith('/activity.py') for name in names): + report.errors.append('XO bundle is missing activity.py.') + if not all(name.startswith(root) for name in names): + report.warnings.append( + 'XO root differs from the normalized activity name.' + ) + except (OSError, ValueError, zipfile.BadZipFile) as error: + report.errors.append('Invalid XO bundle: %s' % error) + + return report + + +def _validate_activity_info(info_path): + report = ValidationReport() + parser = configparser.ConfigParser(interpolation=None) + try: + parser.read(info_path, encoding='utf-8') + except configparser.Error as error: + report.errors.append('Invalid activity.info: %s' % error) + return report + + if not parser.has_section('Activity'): + report.errors.append('activity.info is missing [Activity].') + return report + + required = ( + 'name', + 'bundle_id', + 'icon', + 'exec', + 'activity_version', + 'license', + ) + for key in required: + if not parser.get('Activity', key, fallback='').strip(): + report.errors.append('activity.info is missing %s.' % key) + + license_id = parser.get('Activity', 'license', fallback='') + if license_id not in LICENSE_IDS: + report.errors.append( + 'activity.info has an unsupported license: %s' % license_id + ) + + exec_line = parser.get('Activity', 'exec', fallback='') + if not exec_line.startswith('sugar-activity3 '): + report.errors.append( + 'activity.info must launch with sugar-activity3.' + ) + + return report + + +def _request_text(spec, plan): + parts = [_spec_request_text(spec)] + if isinstance(plan, dict): + for key in ( + 'activity_kind', + 'summary', + 'learner_goal', + 'interaction_model', + 'state_schema'): + value = plan.get(key) + if isinstance(value, str): + parts.append(value) + for key in ( + 'learner_steps', + 'ui_regions', + 'features', + 'classroom_flow'): + values = plan.get(key) + if isinstance(values, list): + parts.extend(str(value) for value in values) + return ' '.join(part for part in parts if part) + + +def _spec_request_text(spec): + return ' '.join(( + getattr(spec, 'prompt', ''), + getattr(spec, 'name', ''), + getattr(spec, 'learner_goal', ''), + )) + + +def _tokens(value): + ignored = { + 'a', 'an', 'and', 'app', 'activity', 'can', 'create', 'for', 'make', + 'me', 'of', 'please', 'the', 'to', 'where', 'with', + } + return { + token for token in re.findall(r'[a-z0-9]+', value.lower()) + if token not in ignored and len(token) > 2 + } + + +def _has_any(words, candidates): + return bool(words.intersection(candidates)) + + +def _require_source_terms(report, source_lower, terms, message): + if not any(term in source_lower for term in terms): + report.errors.append(message) + + +def _base_name(node): + if isinstance(node, ast.Name): + return node.id + if isinstance(node, ast.Attribute): + return '%s.%s' % (_base_name(node.value), node.attr) + return '' + + +def _call_name(node): + if isinstance(node, ast.Name): + return node.id + if isinstance(node, ast.Attribute): + return node.attr + return '' diff --git a/start_sugar_xephyr.sh b/start_sugar_xephyr.sh new file mode 100755 index 0000000000..adbc00ea3b --- /dev/null +++ b/start_sugar_xephyr.sh @@ -0,0 +1,76 @@ +#!/bin/bash +# Launch Sugar inside a nested Xephyr window for safe testing. + +set -e + +# Find a free display number +DISPLAY_NUM=2 +while [ -e /tmp/.X11-unix/X${DISPLAY_NUM} ]; do + DISPLAY_NUM=$((DISPLAY_NUM + 1)) +done + +X_DISPLAY=":${DISPLAY_NUM}" +SUGAR_TEST_HOME="/tmp/sugar_test_home_$$" +LOG_FILE="/tmp/sugar_xephyr_$$.log" + +echo "Starting Xephyr on display ${X_DISPLAY}..." +Xephyr ${X_DISPLAY} -screen 1200x900 -ac -br -noreset > "${LOG_FILE}.xephyr" 2>&1 & +XEPHYR_PID=$! +disown ${XEPHYR_PID} +sleep 2 + +SUGAR_DATA_DIR="/usr/share/sugar/data" + +# Clean snap-polluted environment variables (e.g. from the VS Code snap) that +# point gdk-pixbuf, GTK, GIO and GLib at snap-specific loader/library paths. +# These cause incompatible snap libraries (libpthread from snap core20) to be +# loaded, resulting in fatal symbol lookup errors. We reset to system defaults. +unset GDK_PIXBUF_MODULE_FILE +unset GDK_PIXBUF_MODULEDIR +unset GIO_MODULE_DIR +unset GTK_PATH +unset GTK_EXE_PREFIX +unset GTK_IM_MODULE_FILE +unset GSETTINGS_SCHEMA_DIR +unset XDG_DATA_HOME +unset LOCPATH +unset SNAP SNAP_NAME SNAP_VERSION SNAP_REVISION SNAP_ARCH SNAP_COMMON SNAP_DATA +unset SNAP_USER_COMMON SNAP_USER_DATA SNAP_INSTANCE_NAME SNAP_CONTEXT SNAP_COOKIE +unset SNAP_EUID SNAP_LAUNCHER_ARCH_TRIPLET SNAP_LIBRARY_PATH SNAP_REAL_HOME +export XDG_DATA_DIRS="/usr/local/share:/usr/share:/var/lib/snapd/desktop" +export XDG_CONFIG_DIRS="/etc/xdg" +export XDG_CACHE_HOME="${SUGAR_TEST_HOME}/.cache" + +mkdir -p "${SUGAR_TEST_HOME}/default" "${SUGAR_TEST_HOME}/.cache" +export SUGAR_HOME="${SUGAR_TEST_HOME}" +export SUGAR_PROFILE="default" +export SUGAR_SCALING="72" +export DISPLAY="${X_DISPLAY}" +export GTK2_RC_FILES="${SUGAR_DATA_DIR}/sugar-${SUGAR_SCALING}.gtkrc" +export SUGAR_GROUP_LABELS="${SUGAR_DATA_DIR}/group-labels.defaults" +export SUGAR_MIME_DEFAULTS="${SUGAR_DATA_DIR}/mime.defaults" +export SUGAR_ACTIVITIES_HIDDEN="${SUGAR_DATA_DIR}/activities.hidden" +export MC_ACCOUNT_DIR="${SUGAR_TEST_HOME}/default/accounts" +export LANG="${LANG:-en_US.utf8}" +export LANGUAGE="${LANGUAGE:-${LANG}}" +export HOME="${SUGAR_TEST_HOME}" + +# Use the local source tree +export PYTHONPATH="/home/ashutoshx7/sugar/src:${PYTHONPATH}" + +echo "Starting Sugar on ${X_DISPLAY}..." +echo "Logs: ${LOG_FILE}" +dbus-launch --exit-with-session python3 -m jarabe.main > "${LOG_FILE}" 2>&1 & +SUGAR_PID=$! +disown ${SUGAR_PID} + +echo "Xephyr PID: ${XEPHYR_PID}" +echo "Sugar PID: ${SUGAR_PID}" +echo "" +echo "Sugar is running in Xephyr on display ${X_DISPLAY}" +echo "To stop, run: kill ${XEPHYR_PID} ${SUGAR_PID}" +echo "" + +# Keep script alive but detached +sleep 3 +echo "Sugar startup complete. PIDs: Xephyr=${XEPHYR_PID} Sugar=${SUGAR_PID}" diff --git a/tests/test_aodcredentials.py b/tests/test_aodcredentials.py new file mode 100644 index 0000000000..257bfd2ca9 --- /dev/null +++ b/tests/test_aodcredentials.py @@ -0,0 +1,121 @@ +# Copyright (C) 2026 Sugar Labs +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import os +import shutil +import stat +import tempfile +import unittest + +from jarabe.model.aodcredentials import AODCredentialStore + + +class TestAodCredentialStore(unittest.TestCase): + + def setUp(self): + self.root = tempfile.mkdtemp(prefix='aod-credentials-test-') + + def tearDown(self): + shutil.rmtree(self.root) + + def test_keyring_storage_keeps_secret_out_of_profile_file(self): + keyring = _MemorySecretBackend() + store = AODCredentialStore(self.root, secret_backend=keyring) + secret = 'keyring-secret-value' + + storage = store.save_provider( + 'openai', + api_key=secret, + model='test-model', + endpoint='https://example.test/v1', + ) + + self.assertEqual('keyring', storage) + self.assertEqual(secret, keyring.values['openai']) + with open(store.path, encoding='utf-8') as source: + saved_settings = source.read() + self.assertNotIn(secret, saved_settings) + self.assertEqual( + stat.S_IRUSR | stat.S_IWUSR, + stat.S_IMODE(os.stat(store.path).st_mode), + ) + self.assertEqual( + stat.S_IRWXU, + stat.S_IMODE(os.stat(self.root).st_mode), + ) + + loaded = store.load_provider('openai') + self.assertEqual(secret, loaded['api_key']) + self.assertEqual('test-model', loaded['model']) + self.assertEqual('keyring', loaded['storage']) + + def test_private_file_fallback_has_owner_only_permissions(self): + store = AODCredentialStore( + self.root, + secret_backend=_FailingSecretBackend(), + ) + secret = 'private-file-secret-value' + + storage = store.save_provider( + 'claude', + api_key=secret, + model='claude-test', + ) + + self.assertEqual('profile-file', storage) + with open(store.path, encoding='utf-8') as source: + saved_settings = source.read() + self.assertIn(secret, saved_settings) + self.assertEqual( + stat.S_IRUSR | stat.S_IWUSR, + stat.S_IMODE(os.stat(store.path).st_mode), + ) + self.assertEqual(secret, store.load_provider('claude')['api_key']) + + def test_remove_api_key_clears_keyring_and_file_marker(self): + keyring = _MemorySecretBackend() + store = AODCredentialStore(self.root, secret_backend=keyring) + store.save_provider('gemini', api_key='remove-me') + + self.assertTrue(store.remove_api_key('gemini')) + self.assertNotIn('gemini', keyring.values) + status = store.provider_status('gemini') + self.assertFalse(status['has_api_key']) + self.assertEqual('', store.load_provider('gemini')['api_key']) + + def test_last_saved_configured_provider_becomes_default(self): + keyring = _MemorySecretBackend() + store = AODCredentialStore(self.root, secret_backend=keyring) + store.save_provider('openai', api_key='openai-key') + store.save_provider('gemini', api_key='gemini-key') + + self.assertEqual('gemini', store.get_default_provider_name()) + + +class _MemorySecretBackend: + + def __init__(self): + self.values = {} + + def store(self, provider_name, api_key): + self.values[provider_name] = api_key + return True + + def lookup(self, provider_name): + return self.values.get(provider_name) + + def clear(self, provider_name): + return self.values.pop(provider_name, None) is not None + + +class _FailingSecretBackend: + + def store(self, provider_name, api_key): + raise RuntimeError('No keyring service') + + def lookup(self, provider_name): + raise RuntimeError('No keyring service') + + def clear(self, provider_name): + raise RuntimeError('No keyring service') diff --git a/tests/test_aodgenerator.py b/tests/test_aodgenerator.py new file mode 100644 index 0000000000..5222e6b62a --- /dev/null +++ b/tests/test_aodgenerator.py @@ -0,0 +1,171 @@ +# Copyright (C) 2026 Sugar Labs +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import os +import shutil +import tempfile +import unittest + +from jarabe.model.aodgenerator import create_prototype_activity +from jarabe.model.aodgenerator import infer_template +from jarabe.model.aodspec import ActivitySpec +from jarabe.model.aodvalidator import validate_bundle +from jarabe.model.aodvalidator import validate_project + + +class TestAodGenerator(unittest.TestCase): + + def setUp(self): + self.output_root = tempfile.mkdtemp(prefix='aod-generator-test-') + + def tearDown(self): + shutil.rmtree(self.output_root) + + def test_infers_templates_from_prompt(self): + cases = ( + ('Draw and paint a picture', 'creation', 'canvas'), + ('Create a chess board activity', 'games', 'chess'), + ('Create a carrom activity for two students', 'games', 'carrom'), + ('Write a collaborative story', 'creation', 'narrative'), + ('Make a multiplication quiz', 'logic_math', 'quiz'), + ('Build a pattern grid game', 'games', 'grid'), + ('Create a word counting tool', 'tools_utils', 'utility'), + ('Make a black and white pattern board', 'games', 'grid'), + ('Write a story about a king and queen', 'creation', + 'narrative'), + ('Create a science vocabulary practice quiz', 'logic_math', + 'quiz'), + ('Build a classroom timer for group rotations', 'tools_utils', + 'utility'), + ('Design a habitat map drawing activity', 'creation', 'canvas'), + ) + for prompt, category, expected in cases: + spec = ActivitySpec( + 'Demo', + prompt, + category, + 'MIT', + ) + self.assertEqual(expected, infer_template(spec)) + + def test_all_templates_generate_valid_projects(self): + for template in ( + 'canvas', 'carrom', 'chess', 'grid', 'narrative', 'quiz', + 'utility'): + spec = ActivitySpec( + name='%s Demo' % template.title(), + prompt='Create a %s learning activity.' % template, + category='creation', + license_id='MIT', + template=template, + ) + result = create_prototype_activity(spec, self.output_root) + self.assertTrue(validate_project(result.project_path).valid) + self.assertTrue(validate_bundle(result.bundle_path).valid) + self.assertTrue(os.path.isfile(result.bundle_path)) + self.assertIn( + "gi.require_version('Gtk', '3.0')", + result.files['activity.py'], + ) + + def test_chess_prompt_generates_playable_board_template(self): + spec = ActivitySpec( + name='Chess Club', + prompt='Create a chess activity for two students.', + category='games', + license_id='MIT', + ) + result = create_prototype_activity(spec, self.output_root) + + self.assertEqual('chess', result.plan['template']) + self.assertIn('_starting_board', result.files['activity.py']) + self.assertIn('_can_move', result.files['activity.py']) + self.assertIn('Move log will appear here.', result.files['activity.py']) + self.assertTrue(validate_project(result.project_path).valid) + + def test_carrom_prompt_generates_turn_taking_board_template(self): + spec = ActivitySpec( + name='Carrom Partners', + prompt=( + 'Generate a carrom activity where two students take turns, ' + 'aim the striker, pocket coins, track fouls, and save the ' + 'match.' + ), + category='games', + license_id='MIT', + ) + result = create_prototype_activity(spec, self.output_root) + + self.assertEqual('carrom', result.plan['template']) + self.assertIn('_draw_carrom_board', result.files['activity.py']) + self.assertIn('Pocket queen', result.files['activity.py']) + self.assertIn('Switch turn', result.files['activity.py']) + self.assertTrue(validate_project(result.project_path).valid) + + def test_chess_refinement_can_hide_move_tracking(self): + spec = ActivitySpec( + name='Clean Chess', + prompt='Create a chess activity and remove move tracking history.', + category='games', + license_id='MIT', + template='chess', + ) + result = create_prototype_activity(spec, self.output_root) + + self.assertFalse(result.plan['chess_show_move_log']) + self.assertIn('self._show_move_log = False', + result.files['activity.py']) + self.assertIn('Clean board mode', + result.files['activity.py']) + self.assertTrue(validate_project(result.project_path).valid) + + def test_utility_prompts_generate_matching_tool_modes(self): + cases = ( + ( + 'Build a classroom timer for group rotations.', + 'timer', + '_tick_timer', + ), + ( + 'Create a tally counter for science observations.', + 'counter', + '_change_count', + ), + ( + 'Create a word counting tool for draft revision.', + 'word_counter', + '_update_count', + ), + ) + for prompt, mode, source_marker in cases: + spec = ActivitySpec( + name='Utility Demo', + prompt=prompt, + category='tools_utils', + license_id='MIT', + ) + result = create_prototype_activity(spec, self.output_root) + + self.assertEqual('utility', result.plan['template']) + self.assertEqual(mode, result.plan['utility_mode']) + self.assertIn(source_marker, result.files['activity.py']) + self.assertTrue(validate_project(result.project_path).valid) + + def test_license_metadata_is_consistent(self): + spec = ActivitySpec( + 'License Demo', + 'Create a writing activity.', + 'creation', + 'BSD-3-Clause', + template='narrative', + ) + result = create_prototype_activity(spec, self.output_root) + self.assertIn( + 'license = BSD-3-Clause', + result.files['activity/activity.info'], + ) + self.assertIn( + '# SPDX-License-Identifier: BSD-3-Clause', + result.files['activity.py'], + ) diff --git a/tests/test_aodllm.py b/tests/test_aodllm.py new file mode 100644 index 0000000000..005f333f9f --- /dev/null +++ b/tests/test_aodllm.py @@ -0,0 +1,716 @@ +# Copyright (C) 2026 Sugar Labs +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import json +import os +import unittest +from unittest import mock + +from jarabe.model.aodllm import ClaudeProvider +from jarabe.model.aodllm import FreeModelProvider +from jarabe.model.aodllm import GeminiProvider +from jarabe.model.aodllm import OpenAICompatibleProvider +from jarabe.model.aodllm import OpenAIProvider +from jarabe.model.aodllm import OllamaProvider +from jarabe.model.aodllm import ProviderError +from jarabe.model.aodllm import create_provider +from jarabe.model.aodllm import get_configured_provider +from jarabe.model.aodllm import get_default_provider_name +from jarabe.model.aodllm import get_local_provider_name +from jarabe.model.aodllm import get_provider_statuses + + +class TestAodLLMProviders(unittest.TestCase): + + def test_local_template_provider_returns_none(self): + with mock.patch.dict(os.environ, {}, clear=True): + self.assertEqual('local-template', get_default_provider_name()) + self.assertIsNone(get_configured_provider('local-template')) + + def test_default_provider_prefers_configured_openai(self): + env = { + 'OPENAI_API_KEY': 'test-key', + } + with mock.patch.dict(os.environ, env, clear=True): + self.assertEqual('openai', get_default_provider_name()) + self.assertIsInstance( + get_configured_provider('default'), + OpenAIProvider, + ) + + def test_local_provider_prefers_configured_ollama(self): + env = { + 'AOD_OLLAMA_MODEL': 'test-model', + } + with mock.patch.dict(os.environ, env, clear=True): + self.assertEqual('ollama', get_local_provider_name()) + + with mock.patch.dict(os.environ, {}, clear=True): + self.assertEqual('local-template', get_local_provider_name()) + + def test_provider_statuses_include_local_and_clouds(self): + statuses = get_provider_statuses() + names = [status['name'] for status in statuses] + self.assertIn('local-template', names) + self.assertIn('gemini', names) + self.assertIn('openai', names) + self.assertIn('openrouter', names) + self.assertIn('claude', names) + self.assertIn('ollama', names) + + def test_create_freemodel_provider_uses_responses_defaults(self): + provider = create_provider( + 'freemodel', + api_key='freemodel-key', + ) + + self.assertIsInstance(provider, FreeModelProvider) + self.assertEqual('freemodel', provider.name) + self.assertEqual('FreeModel', provider.label) + self.assertEqual('freemodel-key', provider._api_key) + self.assertEqual('gpt-5.5', provider.model) + self.assertEqual( + 'https://api.freemodel.dev/v1/responses', + provider._endpoint, + ) + + def test_freemodel_responses_request_and_output_text(self): + provider = create_provider( + 'freemodel', + api_key='freemodel-key', + model='gpt-5.5', + endpoint='https://api.freemodel.dev', + ) + response = mock.Mock() + response.__enter__ = mock.Mock(return_value=response) + response.__exit__ = mock.Mock(return_value=False) + response.read.return_value = json.dumps({ + 'output_text': '{"template": "quiz"}', + }).encode('utf-8') + + with mock.patch('urllib.request.urlopen', return_value=response) \ + as opener: + plan = provider.generate_plan('system', 'user') + request = opener.call_args[0][0] + payload = json.loads(request.data.decode('utf-8')) + + self.assertEqual('quiz', plan['template']) + self.assertEqual( + 'https://api.freemodel.dev/v1/responses', + request.full_url, + ) + self.assertEqual('gpt-5.5', payload['model']) + self.assertEqual('system', payload['instructions']) + self.assertEqual('user', payload['input']) + self.assertEqual({'effort': 'xhigh'}, payload['reasoning']) + + def test_freemodel_codegen_uses_larger_output_budget(self): + provider = create_provider( + 'freemodel', + api_key='freemodel-key', + ) + response = mock.Mock() + response.__enter__ = mock.Mock(return_value=response) + response.__exit__ = mock.Mock(return_value=False) + response.read.return_value = json.dumps({ + 'output': [{ + 'content': [{ + 'text': json.dumps({ + 'activity_py': ( + 'from sugar3.activity import activity\n\n' + 'class GeneratedActivity(activity.Activity):\n' + ' pass\n' + ), + }), + }], + }], + }).encode('utf-8') + + with mock.patch('urllib.request.urlopen', return_value=response) \ + as opener: + source = provider.generate_activity_source('system', 'user') + payload = json.loads( + opener.call_args[0][0].data.decode('utf-8') + ) + + self.assertIn('GeneratedActivity', source) + self.assertEqual(16000, payload['max_output_tokens']) + self.assertEqual({'effort': 'high'}, payload['reasoning']) + + def test_create_provider_uses_runtime_key_model_and_endpoint(self): + provider = create_provider( + 'anthropic', + api_key='session-key', + model='claude-test', + endpoint='https://example.test/messages', + ) + + self.assertIsInstance(provider, ClaudeProvider) + self.assertEqual('claude-test', provider.model) + self.assertEqual( + 'https://example.test/messages', + provider._endpoint, + ) + + def test_create_ollama_provider_does_not_require_api_key(self): + provider = create_provider( + 'ollama', + model='local-test', + endpoint='http://127.0.0.1:11434/api/generate', + ) + + self.assertIsInstance(provider, OllamaProvider) + self.assertEqual('local-test', provider.model) + + def test_create_unknown_provider_fails(self): + with self.assertRaises(ProviderError): + create_provider('unknown-provider', api_key='session-key') + + def test_gemini_request_includes_safety_settings(self): + provider = GeminiProvider(api_key='test-key') + response = mock.Mock() + response.__enter__ = mock.Mock(return_value=response) + response.__exit__ = mock.Mock(return_value=False) + response.read.return_value = json.dumps({ + 'candidates': [{ + 'content': { + 'parts': [{'text': '{"template": "quiz"}'}], + }, + 'finishReason': 'STOP', + }], + }).encode('utf-8') + + with mock.patch('urllib.request.urlopen', return_value=response) \ + as opener: + provider.generate_plan('system', 'user') + payload = json.loads(opener.call_args[0][0].data.decode('utf-8')) + + self.assertIn('safetySettings', payload) + categories = { + setting['category'] + for setting in payload['safetySettings'] + } + self.assertIn('HARM_CATEGORY_DANGEROUS_CONTENT', categories) + self.assertIn('HARM_CATEGORY_HATE_SPEECH', categories) + self.assertIn('HARM_CATEGORY_HARASSMENT', categories) + self.assertIn('HARM_CATEGORY_SEXUALLY_EXPLICIT', categories) + for setting in payload['safetySettings']: + self.assertEqual('BLOCK_ONLY_HIGH', setting['threshold']) + + def test_gemini_blocked_response_raises_clear_error(self): + provider = GeminiProvider(api_key='test-key') + response = mock.Mock() + response.__enter__ = mock.Mock(return_value=response) + response.__exit__ = mock.Mock(return_value=False) + response.read.return_value = json.dumps({ + 'candidates': [{ + 'content': {'parts': []}, + 'finishReason': 'SAFETY', + }], + }).encode('utf-8') + + with mock.patch('urllib.request.urlopen', return_value=response): + with self.assertRaises(ProviderError) as context: + provider.generate_plan('system', 'user') + + self.assertIn('blocked', str(context.exception)) + + def test_create_deepseek_provider_uses_openai_compatible_defaults(self): + provider = create_provider( + 'deepseek', + api_key='deepseek-key', + model='deepseek-reasoner', + ) + + self.assertIsInstance(provider, OpenAICompatibleProvider) + self.assertEqual('deepseek', provider.name) + self.assertEqual('DeepSeek', provider.label) + self.assertEqual('deepseek-key', provider._api_key) + self.assertEqual('deepseek-reasoner', provider.model) + self.assertEqual( + 'https://api.deepseek.com/v1/chat/completions', + provider._endpoint, + ) + + def test_create_qwen_provider_from_environment(self): + env = { + 'QWEN_API_KEY': 'qwen-key', + 'AOD_QWEN_MODEL': 'qwen-max', + } + with mock.patch.dict(os.environ, env, clear=True): + provider = create_provider('qwen') + + self.assertIsInstance(provider, OpenAICompatibleProvider) + self.assertEqual('qwen', provider.name) + self.assertEqual('qwen-max', provider.model) + self.assertEqual('qwen-key', provider._api_key) + + def test_create_openrouter_provider_uses_kimi_defaults(self): + provider = create_provider( + 'openrouter', + api_key='openrouter-key', + ) + + self.assertIsInstance(provider, OpenAICompatibleProvider) + self.assertEqual('openrouter', provider.name) + self.assertEqual('OpenRouter', provider.label) + self.assertEqual('openrouter-key', provider._api_key) + self.assertEqual('moonshotai/kimi-k2.6', provider.model) + self.assertEqual( + 'https://openrouter.ai/api/v1/chat/completions', + provider._endpoint, + ) + + def test_openrouter_request_uses_kimi_model_and_headers(self): + provider = create_provider( + 'openrouter', + api_key='openrouter-key', + ) + response = mock.Mock() + response.__enter__ = mock.Mock(return_value=response) + response.__exit__ = mock.Mock(return_value=False) + response.read.return_value = json.dumps({ + 'choices': [{ + 'message': { + 'content': '{"template": "quiz"}', + }, + }], + }).encode('utf-8') + + with mock.patch('urllib.request.urlopen', return_value=response) \ + as opener: + plan = provider.generate_plan('system', 'user') + request = opener.call_args[0][0] + payload = json.loads(request.data.decode('utf-8')) + + self.assertEqual('quiz', plan['template']) + self.assertEqual( + 'https://openrouter.ai/api/v1/chat/completions', + request.full_url, + ) + self.assertEqual('moonshotai/kimi-k2.6', payload['model']) + self.assertEqual('Sugar Activity on Demand', + request.get_header('X-title')) + + def test_openrouter_request_accepts_structured_text_parts(self): + provider = create_provider( + 'openrouter', + api_key='openrouter-key', + ) + response = mock.Mock() + response.__enter__ = mock.Mock(return_value=response) + response.__exit__ = mock.Mock(return_value=False) + response.read.return_value = json.dumps({ + 'choices': [{ + 'message': { + 'content': [{ + 'type': 'text', + 'text': '{"template": "canvas"}', + }], + }, + }], + }).encode('utf-8') + + with mock.patch('urllib.request.urlopen', return_value=response): + plan = provider.generate_plan('system', 'user') + + self.assertEqual('canvas', plan['template']) + + def test_openrouter_request_reports_missing_text_content(self): + provider = create_provider( + 'openrouter', + api_key='openrouter-key', + ) + response = mock.Mock() + response.__enter__ = mock.Mock(return_value=response) + response.__exit__ = mock.Mock(return_value=False) + response.read.return_value = json.dumps({ + 'choices': [{ + 'message': { + 'content': None, + }, + }], + }).encode('utf-8') + + with mock.patch('urllib.request.urlopen', return_value=response): + with self.assertRaises(ProviderError) as context: + provider.generate_plan('system', 'user') + + self.assertIn('text content', str(context.exception)) + + def test_openrouter_request_reports_top_level_error(self): + provider = create_provider( + 'openrouter', + api_key='openrouter-key', + ) + response = mock.Mock() + response.__enter__ = mock.Mock(return_value=response) + response.__exit__ = mock.Mock(return_value=False) + response.read.return_value = json.dumps({ + 'error': { + 'code': 402, + 'message': 'Insufficient credits', + }, + }).encode('utf-8') + + with mock.patch('urllib.request.urlopen', return_value=response): + with self.assertRaises(ProviderError) as context: + provider.generate_plan('system', 'user') + + self.assertIn('OpenRouter request failed', str(context.exception)) + self.assertIn('Insufficient credits', str(context.exception)) + + def test_openrouter_request_reports_length_finish_reason(self): + provider = create_provider( + 'openrouter', + api_key='openrouter-key', + ) + response = mock.Mock() + response.__enter__ = mock.Mock(return_value=response) + response.__exit__ = mock.Mock(return_value=False) + response.read.return_value = json.dumps({ + 'choices': [{ + 'finish_reason': 'length', + 'message': { + 'content': '', + }, + }], + }).encode('utf-8') + + with mock.patch('urllib.request.urlopen', return_value=response): + with self.assertRaises(ProviderError) as context: + provider.generate_plan('system', 'user') + + self.assertIn('finish_reason=length', str(context.exception)) + self.assertIn('activity.py', str(context.exception)) + + def test_openrouter_request_reports_tool_call_only_response(self): + provider = create_provider( + 'openrouter', + api_key='openrouter-key', + ) + response = mock.Mock() + response.__enter__ = mock.Mock(return_value=response) + response.__exit__ = mock.Mock(return_value=False) + response.read.return_value = json.dumps({ + 'choices': [{ + 'message': { + 'content': None, + 'tool_calls': [{'id': 'call-1'}], + }, + }], + }).encode('utf-8') + + with mock.patch('urllib.request.urlopen', return_value=response): + with self.assertRaises(ProviderError) as context: + provider.generate_plan('system', 'user') + + self.assertIn('tool calls', str(context.exception)) + + def test_openrouter_codegen_accepts_fenced_activity_source(self): + provider = create_provider( + 'openrouter', + api_key='openrouter-key', + ) + response = mock.Mock() + response.__enter__ = mock.Mock(return_value=response) + response.__exit__ = mock.Mock(return_value=False) + response.read.return_value = json.dumps({ + 'choices': [{ + 'message': { + 'content': ( + 'Here is activity.py:\n' + '```python\n%s```' + ) % _simple_activity_source(), + }, + }], + }).encode('utf-8') + + with mock.patch('urllib.request.urlopen', return_value=response) \ + as opener: + source = provider.generate_activity_source('system', 'user') + payload = json.loads( + opener.call_args[0][0].data.decode('utf-8') + ) + + self.assertIn('GeneratedActivity', source) + self.assertNotIn('```', source) + self.assertNotIn('response_format', payload) + # Kimi K2.6 is a default-on reasoning model on OpenRouter, so + # codegen is given a larger completion budget plus bounded + # reasoning effort so chain-of-thought cannot starve activity.py. + self.assertEqual(32000, payload['max_tokens']) + self.assertEqual({'effort': 'low'}, payload['reasoning']) + + def test_openrouter_plan_call_is_not_affected_by_reasoning_budget(self): + provider = create_provider( + 'openrouter', + api_key='openrouter-key', + ) + response = mock.Mock() + response.__enter__ = mock.Mock(return_value=response) + response.__exit__ = mock.Mock(return_value=False) + response.read.return_value = json.dumps({ + 'choices': [{ + 'message': {'content': '{"template": "quiz"}'}, + }], + }).encode('utf-8') + + with mock.patch('urllib.request.urlopen', return_value=response) \ + as opener: + provider.generate_plan('system', 'user') + payload = json.loads( + opener.call_args[0][0].data.decode('utf-8') + ) + + # Plan requests keep the default temperature and are uncapped and + # must not gain reasoning-effort parameters: only codegen budgets + # reasoning so the plan JSON gets delivered reliably. + self.assertNotIn('reasoning', payload) + self.assertNotIn('max_tokens', payload) + + def test_openrouter_codegen_budget_overridable_by_environment(self): + provider = create_provider( + 'openrouter', + api_key='openrouter-key', + ) + response = mock.Mock() + response.__enter__ = mock.Mock(return_value=response) + response.__exit__ = mock.Mock(return_value=False) + response.read.return_value = json.dumps({ + 'choices': [{ + 'message': { + 'content': json.dumps({ + 'activity_py': _simple_activity_source(), + }), + }, + }], + }).encode('utf-8') + + env = { + 'AOD_OPENROUTER_CODEGEN_MAX_TOKENS': '32000', + 'AOD_OPENROUTER_CODEGEN_REASONING_EFFORT': 'disabled', + } + with mock.patch.dict(os.environ, env), \ + mock.patch('urllib.request.urlopen', + return_value=response) as opener: + provider.generate_activity_source('system', 'user') + payload = json.loads( + opener.call_args[0][0].data.decode('utf-8') + ) + + self.assertEqual(32000, payload['max_tokens']) + # "disabled" degrades to the minimal accepted effort rather than + # sending enabled:false, which mandatory-reasoning Kimi models + # would reject. + self.assertEqual({'effort': 'minimal'}, payload['reasoning']) + + def test_openrouter_codegen_reasoning_only_for_kimi_models(self): + # A non-Kimi OpenRouter model keeps the standard codegen budget + # and sends no reasoning-effort parameter. + provider = create_provider( + 'openrouter', + api_key='openrouter-key', + model='meta-llama/llama-3.1-70b-instruct', + ) + response = mock.Mock() + response.__enter__ = mock.Mock(return_value=response) + response.__exit__ = mock.Mock(return_value=False) + response.read.return_value = json.dumps({ + 'choices': [{ + 'message': { + 'content': json.dumps({ + 'activity_py': _simple_activity_source(), + }), + }, + }], + }).encode('utf-8') + + with mock.patch('urllib.request.urlopen', return_value=response) \ + as opener: + provider.generate_activity_source('system', 'user') + payload = json.loads( + opener.call_args[0][0].data.decode('utf-8') + ) + + self.assertEqual(16000, payload['max_tokens']) + self.assertNotIn('reasoning', payload) + + def test_create_moonshot_provider_from_environment(self): + env = { + 'MOONSHOT_API_KEY': 'moonshot-key', + } + with mock.patch.dict(os.environ, env, clear=True): + provider = create_provider('moonshot') + + self.assertIsInstance(provider, OpenAICompatibleProvider) + self.assertEqual('moonshot', provider.name) + self.assertEqual('moonshot-v1-8k', provider.model) + self.assertEqual('moonshot-key', provider._api_key) + + def test_provider_statuses_include_chinese_models(self): + env = { + 'DEEPSEEK_API_KEY': 'deepseek-key', + 'QWEN_API_KEY': 'qwen-key', + 'MOONSHOT_API_KEY': 'moonshot-key', + } + with mock.patch.dict(os.environ, env, clear=True): + statuses = { + status['name']: status + for status in get_provider_statuses() + } + + self.assertTrue(statuses['deepseek']['configured']) + self.assertTrue(statuses['qwen']['configured']) + self.assertTrue(statuses['moonshot']['configured']) + + def test_codex_alias_resolves_to_openai(self): + provider = create_provider('codex', api_key='openai-key') + + self.assertIsInstance(provider, OpenAIProvider) + self.assertEqual('openai', provider.name) + + def test_create_opencode_provider_uses_zen_defaults(self): + provider = create_provider( + 'opencode', + api_key='opencode-key', + model='claude-opus-4-8', + ) + + self.assertIsInstance(provider, OpenAICompatibleProvider) + self.assertEqual('opencode', provider.name) + self.assertEqual('OpenCode Zen', provider.label) + self.assertEqual('opencode-key', provider._api_key) + self.assertEqual('claude-opus-4-8', provider.model) + self.assertEqual( + 'https://opencode.ai/zen/v1/chat/completions', + provider._endpoint, + ) + + def test_create_opencode_go_provider_from_environment(self): + env = { + 'OPENCODE_API_KEY': 'opencode-key', + 'AOD_OPENCODE_GO_MODEL': 'minimax-m3', + } + with mock.patch.dict(os.environ, env, clear=True): + provider = create_provider('opencode-go') + + self.assertIsInstance(provider, OpenAICompatibleProvider) + self.assertEqual('opencode-go', provider.name) + self.assertEqual('OpenCode Go', provider.label) + self.assertEqual('minimax-m3', provider.model) + self.assertEqual('opencode-key', provider._api_key) + self.assertEqual( + 'https://opencode.ai/zen/go/v1/chat/completions', + provider._endpoint, + ) + + def test_opencode_go_default_model_is_kimi_k27_code(self): + env = { + 'OPENCODE_API_KEY': 'opencode-key', + } + with mock.patch.dict(os.environ, env, clear=True): + provider = create_provider('opencode-go') + + self.assertEqual('kimi-k2.7-code', provider.model) + + def test_opencode_go_kimi_k27_code_uses_required_temperature(self): + provider = create_provider( + 'opencode-go', + api_key='opencode-key', + model='kimi-k2.7-code', + ) + response = mock.Mock() + response.__enter__ = mock.Mock(return_value=response) + response.__exit__ = mock.Mock(return_value=False) + response.read.return_value = json.dumps({ + 'choices': [{ + 'message': { + 'content': '{"template": "quiz"}', + }, + }], + }).encode('utf-8') + + with mock.patch('urllib.request.urlopen', return_value=response) \ + as opener: + provider.generate_plan('system', 'user') + payload = json.loads( + opener.call_args[0][0].data.decode('utf-8') + ) + + self.assertEqual(1.0, payload['temperature']) + + def test_opencode_go_codegen_uses_larger_token_budget(self): + provider = create_provider( + 'opencode-go', + api_key='opencode-key', + model='kimi-k2.7-code', + ) + response = mock.Mock() + response.__enter__ = mock.Mock(return_value=response) + response.__exit__ = mock.Mock(return_value=False) + response.read.return_value = json.dumps({ + 'choices': [{ + 'message': { + 'content': json.dumps({ + 'activity_py': ( + 'from sugar3.activity import activity\n\n' + 'class GeneratedActivity(activity.Activity):\n' + ' pass\n' + ), + }), + }, + }], + }).encode('utf-8') + + with mock.patch('urllib.request.urlopen', return_value=response) \ + as opener: + source = provider.generate_activity_source('system', 'user') + payload = json.loads( + opener.call_args[0][0].data.decode('utf-8') + ) + + self.assertIn('GeneratedActivity', source) + self.assertEqual(16000, payload['max_tokens']) + self.assertEqual(1.0, payload['temperature']) + + def test_claude_codegen_uses_larger_token_budget(self): + provider = create_provider( + 'claude', + api_key='claude-key', + model='claude-test', + ) + response = mock.Mock() + response.__enter__ = mock.Mock(return_value=response) + response.__exit__ = mock.Mock(return_value=False) + response.read.return_value = json.dumps({ + 'content': [{ + 'type': 'text', + 'text': json.dumps({ + 'activity_py': ( + 'from sugar3.activity import activity\n\n' + 'class GeneratedActivity(activity.Activity):\n' + ' pass\n' + ), + }), + }], + }).encode('utf-8') + + with mock.patch('urllib.request.urlopen', return_value=response) \ + as opener: + source = provider.generate_activity_source('system', 'user') + payload = json.loads( + opener.call_args[0][0].data.decode('utf-8') + ) + + self.assertIn('GeneratedActivity', source) + self.assertEqual(16000, payload['max_tokens']) + + +def _simple_activity_source(): + return ( + 'from sugar3.activity import activity\n\n' + 'class GeneratedActivity(activity.Activity):\n' + ' pass\n' + ) diff --git a/tests/test_aodpipeline.py b/tests/test_aodpipeline.py new file mode 100644 index 0000000000..281486bf6c --- /dev/null +++ b/tests/test_aodpipeline.py @@ -0,0 +1,344 @@ +# Copyright (C) 2026 Sugar Labs +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import json +import os +import shutil +import tempfile +import unittest +from unittest import mock + +from jarabe.model.aodgenerator import enrich_plan +from jarabe.model.aodllm import ProviderError +from jarabe.model.aodpipeline import generate_activity +from jarabe.model.aodpipeline import PipelineError +from jarabe.model.aodspec import ActivitySpec +from jarabe.model.aodtemplates import render_activity_source + + +class _FakeProvider: + name = 'fake' + model = 'fake-1' + + def generate_plan(self, system_prompt, user_prompt, timeout=45): + if 'Sugar Activity API reference' not in system_prompt: + raise AssertionError('Missing Sugar reference') + return { + 'template': 'quiz', + 'activity_kind': 'partner fraction lab', + 'summary': 'A generated fractions quiz.', + 'learner_goal': 'Explain one fraction strategy.', + 'learner_steps': ['Try', 'Explain', 'Remix'], + 'interaction_model': 'Partners build, compare, and explain.', + 'ui_regions': ['Builder', 'Comparison', 'Reflection'], + 'state_schema': 'Saved answers and explanations.', + 'word_bank': ['fraction', 'numerator'], + } + + +class _FailingProvider: + name = 'failing' + model = 'failing-1' + + def generate_plan(self, system_prompt, user_prompt, timeout=45): + raise ProviderError('offline for test') + + +class _LeakyFailingProvider: + name = 'leaky' + model = 'leaky-1' + + def __init__(self): + self._api_key = 'pipeline-secret-key' + + def generate_plan(self, system_prompt, user_prompt, timeout=45): + raise ProviderError( + 'Provider echoed %s in an error.' % self._api_key + ) + + +class _CodegenProvider(_FakeProvider): + + def __init__(self, source): + self.source = source + self.codegen_calls = 0 + + def generate_activity_source(self, system_prompt, user_prompt, + timeout=90): + self.codegen_calls += 1 + if 'complete Python source for activity.py' not in system_prompt: + raise AssertionError('Missing code generation instructions') + return self.source + + +class _RetryCodegenProvider(_FakeProvider): + + def __init__(self, source): + self.source = source + self.codegen_calls = 0 + + def generate_activity_source(self, system_prompt, user_prompt, + timeout=90): + self.codegen_calls += 1 + if self.codegen_calls == 1: + return 'class NotActivity:\n pass\n' + if 'Generated source must define exactly one Activity subclass.' \ + not in user_prompt: + raise AssertionError('Missing validation feedback') + return self.source + + +class _QualityRetryCodegenProvider(_FakeProvider): + + def __init__(self, generic_source, specific_source): + self.generic_source = generic_source + self.specific_source = specific_source + self.codegen_calls = 0 + + def generate_activity_source(self, system_prompt, user_prompt, + timeout=90): + self.codegen_calls += 1 + if self.codegen_calls == 1: + return self.generic_source + if 'Drawing requests must use a Gtk.DrawingArea' not in user_prompt: + raise AssertionError('Missing prompt-specific validation feedback') + return self.specific_source + + +class _FailingCodegenProvider(_FakeProvider): + + def generate_activity_source(self, system_prompt, user_prompt, + timeout=90): + raise ProviderError('codegen offline for test') + + +class TestAodPipeline(unittest.TestCase): + + def setUp(self): + self.output_root = tempfile.mkdtemp(prefix='aod-pipeline-test-') + self.spec = ActivitySpec( + 'Fraction Quest', + 'Make a fractions quiz.', + 'logic_math', + 'MIT', + ) + + def tearDown(self): + shutil.rmtree(self.output_root) + + def test_provider_plan_runs_end_to_end(self): + events = [] + provider = _CodegenProvider(_valid_activity_source(self.spec)) + result = generate_activity( + self.spec, + self.output_root, + provider=provider, + progress_cb=lambda *event: events.append(event), + ) + self.assertEqual('fake', result.provider) + self.assertEqual('quiz', result.plan['template']) + self.assertEqual('partner fraction lab', result.plan['activity_kind']) + self.assertEqual( + 'Partners build, compare, and explain.', + result.plan['interaction_model'], + ) + self.assertEqual( + ['Builder', 'Comparison', 'Reflection'], + result.plan['ui_regions'], + ) + self.assertTrue(os.path.isfile(result.bundle_path)) + self.assertEqual('ready', events[-1][0]) + + with open( + os.path.join(result.project_path, 'aod_plan.json'), + encoding='utf-8') as plan_file: + saved_plan = json.load(plan_file) + self.assertEqual('fake', saved_plan['provider']) + + def test_provider_rag_search_is_not_template_filtered(self): + search_calls = [] + + def fake_search(query, limit=5, template='', corpus=None): + search_calls.append({ + 'query': query, + 'limit': limit, + 'template': template, + }) + return [] + + with mock.patch('jarabe.model.aodpipeline.search', fake_search): + generate_activity( + self.spec, + self.output_root, + provider=_CodegenProvider(_valid_activity_source(self.spec)), + use_rag=True, + ) + + self.assertEqual('', search_calls[0]['template']) + self.assertGreaterEqual(search_calls[0]['limit'], 6) + + def test_provider_failure_fails_without_template_fallback(self): + with self.assertRaises(PipelineError) as raised: + generate_activity( + self.spec, + self.output_root, + provider=_FailingProvider(), + ) + + self.assertIn('Provider did not answer', str(raised.exception)) + self.assertIn('offline for test', str(raised.exception)) + + def test_provider_key_is_redacted_from_persisted_error(self): + provider = _LeakyFailingProvider() + with self.assertRaises(PipelineError) as raised: + generate_activity( + self.spec, + self.output_root, + provider=provider, + ) + + message = str(raised.exception) + self.assertNotIn(provider._api_key, message) + self.assertIn('[redacted]', message) + + def test_provider_codegen_source_is_used(self): + provider = _CodegenProvider(_valid_activity_source(self.spec)) + + result = generate_activity( + self.spec, + self.output_root, + provider=provider, + ) + + self.assertEqual('provider', result.plan['code_source']) + self.assertEqual(1, provider.codegen_calls) + self.assertIn('# provider-codegen-marker', result.files['activity.py']) + + def test_provider_codegen_reports_draft_source_progress(self): + events = [] + provider = _CodegenProvider(_valid_activity_source(self.spec)) + + generate_activity( + self.spec, + self.output_root, + provider=provider, + progress_cb=lambda *event: events.append(event), + ) + + draft_events = [ + event for event in events + if len(event) == 4 and + isinstance(event[3], dict) and + event[3].get('draft_activity_source') + ] + self.assertEqual(1, len(draft_events)) + self.assertIn( + '# provider-codegen-marker', + draft_events[0][3]['draft_activity_source'], + ) + + def test_provider_codegen_retries_after_validation_error(self): + provider = _RetryCodegenProvider(_valid_activity_source(self.spec)) + + result = generate_activity( + self.spec, + self.output_root, + provider=provider, + ) + + self.assertEqual('provider', result.plan['code_source']) + self.assertEqual(2, provider.codegen_calls) + self.assertEqual(2, result.plan['codegen_attempts']) + + def test_provider_codegen_failure_fails_without_template_fallback(self): + with self.assertRaises(PipelineError) as raised: + generate_activity( + self.spec, + self.output_root, + provider=_FailingCodegenProvider(), + ) + + self.assertIn('Provider could not generate valid activity code', + str(raised.exception)) + self.assertIn('codegen offline for test', str(raised.exception)) + + def test_template_fallback_recovers_when_codegen_fails(self): + result = generate_activity( + self.spec, + self.output_root, + provider=_FailingCodegenProvider(), + template_fallback=True, + ) + + self.assertEqual( + 'template_after_codegen_failure', + result.plan['code_source'], + ) + self.assertIn('codegen_fallback_reason', result.plan) + self.assertIn( + 'codegen offline for test', + result.plan['codegen_fallback_reason'], + ) + self.assertTrue(os.path.isfile( + os.path.join(result.project_path, 'activity.py') + )) + + def test_template_fallback_recovers_when_plan_fails(self): + result = generate_activity( + self.spec, + self.output_root, + provider=_FailingProvider(), + template_fallback=True, + ) + + self.assertEqual('local', result.provider) + self.assertTrue(os.path.isfile( + os.path.join(result.project_path, 'activity.py') + )) + + def test_provider_codegen_retries_generic_source_for_prompt(self): + spec = ActivitySpec( + 'Draw Together', + 'Make an activity where two students can draw together.', + 'creation', + 'MIT', + ) + generic_plan = enrich_plan(spec, { + 'template': 'narrative', + 'summary': 'A generic writing activity.', + 'learner_goal': 'Write together.', + 'learner_steps': ['Write', 'Share'], + }) + specific_plan = enrich_plan(spec, { + 'template': 'canvas', + 'summary': 'A drawing canvas for Student A and Student B.', + 'learner_goal': 'Students draw together.', + 'learner_steps': ['Student A draws', 'Student B draws'], + 'interaction_model': 'Students switch turns and draw together.', + }) + provider = _QualityRetryCodegenProvider( + render_activity_source(spec, generic_plan), + render_activity_source(spec, specific_plan) + + '\n# Student A and Student B switch turns together.\n', + ) + + result = generate_activity( + spec, + self.output_root, + provider=provider, + ) + + self.assertEqual('provider', result.plan['code_source']) + self.assertEqual(2, provider.codegen_calls) + self.assertIn('DrawingArea', result.files['activity.py']) + + +def _valid_activity_source(spec): + plan = enrich_plan(spec, { + 'template': 'quiz', + 'summary': 'Provider generated source.', + 'learner_goal': 'Practice provider code.', + 'learner_steps': ['Try', 'Explain', 'Share'], + }) + return render_activity_source(spec, plan) + '\n# provider-codegen-marker\n' diff --git a/tests/test_aodprompts.py b/tests/test_aodprompts.py new file mode 100644 index 0000000000..9820470139 --- /dev/null +++ b/tests/test_aodprompts.py @@ -0,0 +1,39 @@ +# Copyright (C) 2026 Sugar Labs +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import unittest + +from jarabe.model.aodprompts import build_system_prompt +from jarabe.model.aodprompts import extract_json_object +from jarabe.model.aodspec import ActivitySpec + + +class TestAodPrompts(unittest.TestCase): + + def test_system_prompt_contains_sugar_constraints(self): + spec = ActivitySpec( + 'Story Studio', + 'Create a story writing activity.', + 'creation', + 'MIT', + template='narrative', + ) + prompt = build_system_prompt(spec) + self.assertIn('Subclass sugar3.activity.activity.Activity', prompt) + self.assertIn('Return one JSON object', prompt) + self.assertIn('canvas, carrom, chess, grid', prompt) + self.assertIn('provider code generator owns', prompt) + self.assertIn('not templates to copy', prompt) + self.assertNotIn('local generator owns Python source', prompt) + self.assertIn('large editable area', prompt) + + def test_extracts_fenced_json(self): + value = extract_json_object( + '```json\n{"template": "quiz", "summary": "Test"}\n```' + ) + self.assertEqual('quiz', value['template']) + + def test_rejects_non_object_json(self): + with self.assertRaises(ValueError): + extract_json_object('["not", "an", "object"]') diff --git a/tests/test_aodrag.py b/tests/test_aodrag.py new file mode 100644 index 0000000000..347fd69499 --- /dev/null +++ b/tests/test_aodrag.py @@ -0,0 +1,93 @@ +# Copyright (C) 2026 Sugar Labs +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import unittest +import os +import tempfile + +from jarabe.model.aodrag import build_corpus +from jarabe.model.aodrag import RagDocument +from jarabe.model.aodrag import search + + +class TestAodRag(unittest.TestCase): + + def test_search_ranks_relevant_document_first(self): + corpus = [ + RagDocument( + 'Drawing example', + 'Gtk.DrawingArea stores learner strokes.', + ('canvas', 'drawing'), + ), + RagDocument( + 'Writing example', + 'Gtk.TextView stores a learner story.', + ('narrative', 'writing'), + ), + ] + results = search( + 'draw a canvas picture', + template='canvas', + corpus=corpus, + ) + self.assertEqual('Drawing example', results[0].title) + + def test_build_corpus_includes_manifest_and_support_sources(self): + with tempfile.TemporaryDirectory() as root: + bundle = os.path.join(root, 'PairDraw.activity') + os.makedirs(os.path.join(bundle, 'activity')) + with open(os.path.join(bundle, 'activity', 'activity.info'), + 'w', encoding='utf-8') as info_file: + info_file.write( + '[Activity]\n' + 'name = Pair Draw\n' + 'exec = sugar-activity3 activity.GeneratedActivity\n' + 'summary = Students draw together.\n' + 'tags = Drawing\n' + ) + with open(os.path.join(bundle, 'activity.py'), + 'w', encoding='utf-8') as source_file: + source_file.write( + 'from sugar3.activity import activity\n' + 'from gi.repository import Gtk\n' + 'class GeneratedActivity(activity.Activity):\n' + ' pass\n' + ) + with open(os.path.join(bundle, 'widgets.py'), + 'w', encoding='utf-8') as support_file: + support_file.write('from gi.repository import Gtk\n') + + corpus = build_corpus(activity_roots=(root,)) + + titles = [document.title for document in corpus] + self.assertIn('PairDraw.activity activity.info manifest', titles) + self.assertIn('PairDraw.activity main Sugar source example', titles) + self.assertIn( + 'PairDraw.activity supporting GTK source: widgets.py', + titles, + ) + + def test_build_corpus_skips_generated_aod_bundles(self): + with tempfile.TemporaryDirectory() as root: + bundle = os.path.join(root, 'Generated.activity') + os.makedirs(os.path.join(bundle, 'activity')) + with open(os.path.join(bundle, 'aod_plan.json'), + 'w', encoding='utf-8') as plan_file: + plan_file.write('{}\n') + with open(os.path.join(bundle, 'activity', 'activity.info'), + 'w', encoding='utf-8') as info_file: + info_file.write( + '[Activity]\n' + 'name = Generated\n' + 'exec = sugar-activity3 activity.GeneratedActivity\n' + ) + with open(os.path.join(bundle, 'activity.py'), + 'w', encoding='utf-8') as source_file: + source_file.write('class GeneratedActivity: pass\n') + + corpus = build_corpus(activity_roots=(root,)) + + titles = [document.title for document in corpus] + self.assertNotIn('Generated.activity main Sugar source example', + titles) diff --git a/tests/test_aodservice.py b/tests/test_aodservice.py new file mode 100644 index 0000000000..0134c99554 --- /dev/null +++ b/tests/test_aodservice.py @@ -0,0 +1,357 @@ +# Copyright (C) 2026 Sugar Labs +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import os +import shutil +import tempfile +import time +import unittest +import zipfile + +from jarabe.model.aodcredentials import AODCredentialStore +from jarabe.model.aodgenerator import enrich_plan +from jarabe.model.aodpipeline import package_generation_result +from jarabe.model.aodjobs import AODJobStore +from jarabe.model.aodjobs import STATUS_FAILED +from jarabe.model.aodjobs import STATUS_FINISHED +from jarabe.model.aodsessions import AODSessionStore +from jarabe.model.aodsessions import ROLE_ASSISTANT +from jarabe.model.aodsessions import ROLE_USER +from jarabe.model.aodsessions import TYPE_RESULT +from jarabe.model.aodservice import AODService +from jarabe.model.aodspec import ActivitySpec +from jarabe.model.aodtemplates import render_activity_source + + +class TestAodService(unittest.TestCase): + + def setUp(self): + self.root = tempfile.mkdtemp(prefix='aod-service-test-') + self.project_root = os.path.join(self.root, 'projects') + self.job_root = os.path.join(self.root, 'jobs') + self.session_root = os.path.join(self.root, 'sessions') + self.store = AODJobStore(self.job_root) + self.session_store = AODSessionStore(self.session_root) + self.secret_backend = _MemorySecretBackend() + self.credential_store = AODCredentialStore( + os.path.join(self.root, 'credentials'), + secret_backend=self.secret_backend, + ) + self.service = AODService( + self.store, + worker_count=1, + credential_store=self.credential_store, + session_store=self.session_store, + ) + + def tearDown(self): + self.service.shutdown() + shutil.rmtree(self.root) + + def test_submit_activity_runs_job_and_persists_summary(self): + events = [] + spec = ActivitySpec( + 'Queue Demo', + 'Create a quiz about queues.', + 'logic_math', + 'MIT', + template='quiz', + ) + + job = self.service.submit_activity( + spec, + provider_name='local-template', + output_root=self.project_root, + callback=lambda updated: events.append(updated.status), + ) + finished = self._wait_for_terminal(job.job_id) + + self.assertEqual(STATUS_FINISHED, finished.status) + self.assertTrue(os.path.isdir(finished.result.project_path)) + self.assertEqual('', finished.result.bundle_path) + self.assertIn(STATUS_FINISHED, events) + + persisted = self.store.load(job.job_id) + self.assertEqual(STATUS_FINISHED, persisted.status) + self.assertEqual( + finished.result.bundle_id, + persisted.result_summary['bundle_id'], + ) + session = self.service.get_session(finished.session_id) + self.assertIsNotNone(session) + self.assertEqual(finished.result_summary['revision_id'], + session.active_revision_id) + self.assertEqual(1, len(session.revisions)) + self.assertEqual(ROLE_USER, session.messages[0].role) + self.assertTrue(any( + message.role == ROLE_ASSISTANT and + message.message_type == TYPE_RESULT and + message.revision_id == session.active_revision_id + for message in session.messages + )) + + def test_unwatch_removes_bound_method_callback(self): + observer = _Observer() + self.service.watch('job-id', observer.callback) + self.service.unwatch('job-id', observer.callback) + self.assertNotIn('job-id', self.service._callbacks) + + def test_finished_job_restores_result_after_service_restart(self): + spec = ActivitySpec( + 'Restore Demo', + 'Create a writing activity.', + 'creation', + 'MIT', + template='narrative', + ) + job = self.service.submit_activity( + spec, + provider_name='local-template', + output_root=self.project_root, + ) + finished = self._wait_for_terminal(job.job_id) + self.assertEqual(STATUS_FINISHED, finished.status) + + self.service.shutdown() + self.service = AODService( + self.store, + worker_count=1, + credential_store=self.credential_store, + session_store=self.session_store, + ) + restored = self.service.get_job(job.job_id) + + self.assertEqual(STATUS_FINISHED, restored.status) + self.assertIsNotNone(restored.result) + self.assertEqual( + finished.result.bundle_id, + restored.result.bundle_id, + ) + self.assertIn('activity.py', restored.result.files) + + def test_missing_artifacts_mark_restored_job_failed(self): + spec = ActivitySpec( + 'Missing Demo', + 'Create a simple quiz.', + 'logic_math', + 'MIT', + template='quiz', + ) + job = self.service.submit_activity( + spec, + provider_name='local-template', + output_root=self.project_root, + ) + finished = self._wait_for_terminal(job.job_id) + shutil.rmtree(finished.result.project_path) + + self.service.shutdown() + self.service = AODService( + self.store, + worker_count=1, + credential_store=self.credential_store, + session_store=self.session_store, + ) + restored = self.service.get_job(job.job_id) + + self.assertEqual(STATUS_FAILED, restored.status) + self.assertIsNone(restored.result) + self.assertIn('no longer available', restored.error) + + def test_runtime_provider_runs_without_persisting_its_secret(self): + secret = 'aod-session-secret-must-not-be-saved' + provider = _RuntimeProvider(secret) + self.service.register_provider(provider) + spec = ActivitySpec( + 'Runtime Provider Demo', + 'Create a teamwork quiz.', + 'logic_math', + 'MIT', + template='quiz', + ) + + job = self.service.submit_activity( + spec, + provider_name='openai', + use_rag=False, + output_root=self.project_root, + ) + finished = self._wait_for_terminal(job.job_id) + + self.assertEqual(STATUS_FINISHED, finished.status) + self.assertEqual('openai', finished.result.provider) + self.assertEqual('runtime-test', finished.result.model) + self.assertFalse( + self.service.has_runtime_provider('not-configured') + ) + + job_path = os.path.join(self.job_root, job.job_id + '.json') + with open(job_path, encoding='utf-8') as job_file: + persisted_job = job_file.read() + self.assertNotIn(secret, persisted_job) + for contents in finished.result.files.values(): + self.assertNotIn(secret, contents) + package_generation_result(finished.result) + with zipfile.ZipFile(finished.result.bundle_path) as bundle: + for filename in bundle.namelist(): + self.assertNotIn( + secret.encode('utf-8'), + bundle.read(filename), + ) + + def test_runtime_provider_is_reported_as_configured(self): + self.service.register_provider(_RuntimeProvider('session-secret')) + + statuses = { + status['name']: status + for status in self.service.provider_statuses() + } + self.assertTrue(statuses['openai']['configured']) + self.assertEqual('runtime-test', statuses['openai']['model']) + + def test_saved_provider_settings_load_after_service_restart(self): + secret = 'saved-service-secret' + provider = self.service.configure_provider( + 'openai', + api_key=secret, + model='saved-model', + endpoint='https://example.test/v1/chat/completions', + persist=True, + ) + self.assertEqual('saved-model', provider.model) + + self.service.shutdown() + self.service = AODService( + self.store, + worker_count=1, + credential_store=self.credential_store, + session_store=self.session_store, + ) + restored = self.service.configure_provider('openai') + + self.assertEqual('saved-model', restored.model) + self.assertEqual(secret, restored._api_key) + self.assertEqual('openai', self.service.preferred_provider_name()) + + def test_remove_saved_provider_key_clears_runtime_provider(self): + self.service.configure_provider( + 'gemini', + api_key='remove-service-secret', + persist=True, + ) + self.assertTrue(self.service.has_runtime_provider('gemini')) + + self.assertTrue(self.service.remove_provider_api_key('gemini')) + self.assertFalse(self.service.has_runtime_provider('gemini')) + status = self.service.provider_credential_status('gemini') + self.assertFalse(status['has_api_key']) + + def test_refinement_job_appends_revision_to_existing_session(self): + first = ActivitySpec( + 'Draw Together', + 'Create an activity where two learners draw together.', + 'creation', + 'MIT', + template='canvas', + ) + first_job = self.service.submit_activity( + first, + provider_name='local-template', + output_root=self.project_root, + user_prompt=first.prompt, + ) + first_finished = self._wait_for_terminal(first_job.job_id) + self.assertEqual(STATUS_FINISHED, first_finished.status) + + second = ActivitySpec( + 'Draw Together', + 'Refine the existing activity. Add a switch-student button.', + 'creation', + 'MIT', + template='canvas', + ) + second_job = self.service.submit_activity( + second, + provider_name='local-template', + output_root=self.project_root, + session_id=first_finished.session_id, + parent_revision_id=first_finished.result_summary['revision_id'], + user_prompt='Add a switch-student button.', + ) + second_finished = self._wait_for_terminal(second_job.job_id) + self.assertEqual(STATUS_FINISHED, second_finished.status) + + session = self.service.get_session(first_finished.session_id) + self.assertEqual(2, len(session.revisions)) + self.assertEqual( + first_finished.result_summary['revision_id'], + session.revisions[1].parent_revision_id, + ) + self.assertEqual( + second_finished.result_summary['revision_id'], + session.active_revision_id, + ) + + def _wait_for_terminal(self, job_id): + deadline = time.time() + 10 + while time.time() < deadline: + job = self.service.get_job(job_id) + if job is not None and job.is_terminal(): + return job + time.sleep(0.05) + self.fail('Timed out waiting for AOD job to finish.') + + +class _Observer: + + def callback(self, job): + pass + + +class _RuntimeProvider: + name = 'openai' + label = 'OpenAI' + model = 'runtime-test' + + def __init__(self, api_key): + self._api_key = api_key + + def generate_plan(self, system_prompt, user_prompt, timeout=45): + return { + 'template': 'quiz', + 'summary': 'A runtime-provider quiz.', + 'learner_goal': 'Practice teamwork.', + 'learner_steps': ['Choose', 'Discuss', 'Share'], + 'word_bank': ['team', 'answer'], + } + + def generate_activity_source(self, system_prompt, user_prompt, + timeout=90): + spec = ActivitySpec( + 'Runtime Provider Demo', + 'Create a teamwork quiz.', + 'logic_math', + 'MIT', + template='quiz', + ) + return render_activity_source( + spec, + enrich_plan(spec, self.generate_plan('', '')), + ) + + +class _MemorySecretBackend: + + def __init__(self): + self.values = {} + + def store(self, provider_name, api_key): + self.values[provider_name] = api_key + return True + + def lookup(self, provider_name): + return self.values.get(provider_name) + + def clear(self, provider_name): + return self.values.pop(provider_name, None) is not None diff --git a/tests/test_aodspec.py b/tests/test_aodspec.py new file mode 100644 index 0000000000..aae70af891 --- /dev/null +++ b/tests/test_aodspec.py @@ -0,0 +1,49 @@ +# Copyright (C) 2026 Sugar Labs +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import unittest + +from jarabe.model.aodspec import ActivitySpec +from jarabe.model.aodspec import name_from_prompt + + +class TestActivitySpec(unittest.TestCase): + + def test_valid_spec(self): + spec = ActivitySpec( + name='Fraction Quest', + prompt='Create a game for practicing fractions.', + category='logic_math', + license_id='MIT', + ) + self.assertEqual([], spec.validate()) + + def test_reports_all_invalid_fields(self): + spec = ActivitySpec('', '', 'unknown', 'unknown') + errors = spec.validate() + self.assertIn('Activity name is required.', errors) + self.assertIn('Activity prompt is required.', errors) + self.assertIn('Unknown activity category: unknown', errors) + self.assertIn('Unknown activity license: unknown', errors) + + def test_dictionary_round_trip(self): + original = ActivitySpec( + name='Fraction Quest', + prompt='Create a fractions activity.', + category='logic_math', + license_id='MIT', + template='quiz', + age_band='8-10', + learner_goal='Recognize equivalent fractions.', + ) + self.assertEqual( + original, + ActivitySpec.from_dict(original.to_dict()), + ) + + def test_name_from_prompt_ignores_instruction_words(self): + self.assertEqual( + 'Fractions Quiz Children', + name_from_prompt('Create a fractions quiz for children'), + ) diff --git a/tests/test_aodvalidator.py b/tests/test_aodvalidator.py new file mode 100644 index 0000000000..0a26af988c --- /dev/null +++ b/tests/test_aodvalidator.py @@ -0,0 +1,80 @@ +# Copyright (C) 2026 Sugar Labs +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import unittest + +from jarabe.model.aodspec import ActivitySpec +from jarabe.model.aodtemplates import render_activity_source +from jarabe.model.aodgenerator import enrich_plan +from jarabe.model.aodvalidator import validate_activity_source_for_request +from jarabe.model.aodvalidator import validate_source + + +class TestAodValidator(unittest.TestCase): + + def test_rejects_syntax_errors(self): + report = validate_source('class Broken(:\n pass\n') + self.assertFalse(report.valid) + self.assertIn('Python syntax error', report.errors[0]) + + def test_rejects_dangerous_imports_and_calls(self): + report = validate_source( + 'import subprocess\n' + 'eval("1 + 1")\n' + ) + self.assertFalse(report.valid) + self.assertIn('Forbidden import: subprocess', report.errors) + self.assertIn('Forbidden call: eval', report.errors) + + def test_requires_activity_structure(self): + report = validate_source('class PlainObject:\n pass\n') + self.assertFalse(report.valid) + self.assertIn( + 'Generated source must define exactly one Activity subclass.', + report.errors, + ) + + def test_request_validation_rejects_generic_source_for_drawing(self): + spec = ActivitySpec( + 'Draw Together', + 'Make an activity where two students can draw together.', + 'creation', + 'MIT', + ) + plan = enrich_plan(spec, { + 'template': 'narrative', + 'summary': 'A writing activity.', + 'learner_goal': 'Write together.', + 'learner_steps': ['Write', 'Share'], + }) + source = render_activity_source(spec, plan) + + report = validate_activity_source_for_request(source, spec, plan) + + self.assertFalse(report.valid) + self.assertTrue(any( + 'Drawing requests must use' in error + for error in report.errors + )) + + def test_request_validation_accepts_real_canvas_for_drawing(self): + spec = ActivitySpec( + 'Draw Together', + 'Make an activity where two students can draw together.', + 'creation', + 'MIT', + ) + plan = enrich_plan(spec, { + 'template': 'canvas', + 'summary': 'A drawing activity for Student A and Student B.', + 'learner_goal': 'Students draw together.', + 'learner_steps': ['Student A draws', 'Student B draws'], + 'interaction_model': 'Students switch turns and draw together.', + }) + source = render_activity_source(spec, plan) + source += '\n# Student A and Student B switch turns together.\n' + + report = validate_activity_source_for_request(source, spec, plan) + + self.assertTrue(report.valid, report.errors) From d3b118127992517b5af7c7ad2b3ff862efcb8be6 Mon Sep 17 00:00:00 2001 From: Ashutoshx7 Date: Tue, 23 Jun 2026 22:34:46 +0530 Subject: [PATCH 02/28] codegen: demand full-fledged activities with quality bar checklist Replace 'keep compact' with detailed quality requirements: real UI with titled sections, multiple interaction modes via Gtk.Stack, proper domain logic, polished GTK3 with Pango markup, rich toolbar with custom actions, keyboard shortcuts, and 400-900 line target for most requests. Add per-request-type richness examples (drawing, quiz, board game, writing, two-learner). --- src/jarabe/model/aodcodegen.py | 68 +++++++++++++++++++++++++++++----- 1 file changed, 58 insertions(+), 10 deletions(-) diff --git a/src/jarabe/model/aodcodegen.py b/src/jarabe/model/aodcodegen.py index a2efa089be..891e41cf6c 100644 --- a/src/jarabe/model/aodcodegen.py +++ b/src/jarabe/model/aodcodegen.py @@ -90,15 +90,63 @@ def build_codegen_system_prompt(spec, plan, references=()): 'two-student prompts need visible learner roles and collaboration or ' 'turn-taking state; board games need actual board state, scoring or ' 'move rules; quiz prompts need input, feedback, and saved progress.\n' - '- Make the activity full-featured and rich, not minimal. Add ' - 'multiple toolbar controls, a sidebar or status area, clear visual ' - 'feedback, and enough functionality that a learner can use it for ' - 'a real classroom session. Think 200-400 lines of real, working ' - 'code — the kind of activity a teacher would actually install and ' - 'use with students.\n' - '- Add helpful UI elements: status labels, color palettes, ' - 'instructions, score displays, clear buttons, undo where ' - 'appropriate. The activity should feel polished, not bare.\n\n' + '\n## Quality bar — what "full-fledged" means\n' + 'You are the Sugar equivalent of v0/Lovable: when a learner ' + 'describes an idea, you ship a finished activity, not a stub. ' + 'Every output must clear ALL of these:\n' + '- A real, finished UI: titled sections, sensible spacing, ' + 'descriptive button labels with tooltips, status hints, and ' + 'visible feedback for every action. Not a three-button debug ' + 'panel.\n' + '- Multiple interaction modes / screens when the request implies ' + 'them. Use Gtk.Stack with named pages for flows like setup → play ' + '→ result, or question → feedback → review. Use Gtk.Notebook or ' + 'a sidebar for tool/option grouping.\n' + '- Real domain logic. Chess enforces legal moves and detects ' + 'check. A quiz tracks score and retains answers. A drawing app ' + 'stores stroke geometry and supports brush size + color + undo. ' + '"Looks like the thing" is the floor, not the ceiling.\n' + '- Polished GTK3: Gtk.Box/Gtk.Grid layouts that expand, ' + 'Gtk.Frame for grouping, Pango markup for emphasis ' + '(..., ...), ' + 'Gtk.CssProvider for visual styling when it helps. Use ' + 'sugar3.graphics.style for fonts/colors consistent with Sugar.\n' + '- Proper Journal persistence via JSON: write_file serializes ' + 'every piece of meaningful state (positions, scores, drawings, ' + 'history). read_file restores it and rebuilds the visible UI.\n' + '- Rich toolbar: ActivityToolbarButton, Gtk.SeparatorToolItem, ' + 'StopButton, plus at least 2–4 custom Gtk.ToolButton actions ' + 'relevant to the activity (New/Reset, Undo, Save Snapshot, ' + 'Hint, Change Tool/Color, etc.) with icon_name and tooltip_text ' + 'set.\n' + '- Keyboard shortcuts for common actions (Ctrl+N new, Ctrl+Z ' + 'undo, etc.) via Gtk.AccelGroup or key-press-event when relevant.\n' + '- No TODO, no "placeholder", no "Add your code here", no demo ' + 'strings. Every label and action is final classroom-ready text.\n' + '- Length: rich activities are typically 400–900 lines of ' + 'real working code. Do NOT pad with dead helpers, but do NOT ' + 'stop at a 150-line MVP either. If the request is genuinely ' + 'simple (single timer, single counter), 250 lines is fine — ' + 'but most requests warrant 500+.\n' + '\n## Examples of richness expected per request type\n' + '- Drawing: tool palette (pen/eraser), color picker (≥6 colors), ' + 'brush-size slider, undo/redo stack, clear-canvas action, save-' + 'as-PNG-to-Journal, stroke geometry persistence. Not just a ' + 'DrawingArea with one black pen.\n' + '- Quiz: question pool of 5+ items, randomized order, typed or ' + 'multiple-choice answers, per-question feedback, running score, ' + 'final review screen, restart action, Journal-saved progress.\n' + '- Board game (chess/carrom/etc.): full board widget with ' + 'visible coordinates, piece/coin rendering, turn indicator, ' + 'move log panel, legal-move enforcement, captured-pieces tray, ' + 'reset and save actions.\n' + '- Writing/narrative: titled text area with starter prompt, ' + 'word/character counter live-updating, save-draft + load-draft ' + 'actions, a reflection prompt sidebar, optional formatting ' + 'buttons.\n' + '- Two-learner / partner: explicit Student A / Student B ' + 'labels, visible "active turn" indicator, switch-turn button, ' + 'per-learner score or contribution tally, swap-roles action.\n\n' 'Allowed import roots: %(allowed)s\n' 'Forbidden import roots: %(forbidden_imports)s\n' 'Forbidden calls: %(forbidden_calls)s\n\n' @@ -253,6 +301,6 @@ def _format_references(references): text = ' '.join(getattr(document, 'text', '').split()) title = getattr(document, 'title', 'Reference') blocks.append( - 'Reference %d - %s:\n%s' % (index, title, text[:1200]) + 'Reference %d - %s:\n%s' % (index, title, text[:4000]) ) return '\n\n'.join(blocks) From b9428e072361ca556d6a60139c98d9730d1d0c16 Mon Sep 17 00:00:00 2001 From: Ashutoshx7 Date: Tue, 23 Jun 2026 23:58:21 +0530 Subject: [PATCH 03/28] Add SEARCH/REPLACE refinement: 10-20x cheaper follow-up edits New aodrefine.py module implements Aider-style SEARCH/REPLACE blocks for targeted edits to existing activity.py: - build_refine_system_prompt(): tells model to return only changed regions as <<<<<<< SEARCH / ======= / >>>>>>> REPLACE blocks - build_refine_user_prompt(): sends current source + refinement request - parse_search_replace(): parses blocks, handles FULLREGEN signal - apply_patches(): whitespace-tolerant matching, counts applied/failed Pipeline refine_activity() tries SEARCH/REPLACE first (~1k tokens, ~5s), falls back to full regen if: - Model outputs FULLREGEN - Any SEARCH block doesn't match current source - Patched code fails AST validation Service _run_refinement_job() routes refinement jobs through refine_activity() for LLM providers, skips planner call by reusing existing plan. Local-template refinements still use generate_activity(). Cost per refinement on Kimi K2.6 via OpenRouter: - SEARCH/REPLACE: ~/usr/bin/bash.0006, ~5-10s - Full regen fallback: ~/usr/bin/bash.007, ~25-40s - Previous (plan + full regen): ~/usr/bin/bash.012, ~40-60s 19 new tests: parsing, patching, whitespace tolerance, multi-block, FULLREGEN, error cases, prompt builders. --- src/jarabe/model/Makefile.am | 1 + src/jarabe/model/aodpipeline.py | 232 ++++++++++++++++++++++++++++-- src/jarabe/model/aodrefine.py | 247 ++++++++++++++++++++++++++++++++ src/jarabe/model/aodservice.py | 117 ++++++++++++--- tests/test_aodrefine.py | 207 ++++++++++++++++++++++++++ 5 files changed, 779 insertions(+), 25 deletions(-) create mode 100644 src/jarabe/model/aodrefine.py create mode 100644 tests/test_aodrefine.py diff --git a/src/jarabe/model/Makefile.am b/src/jarabe/model/Makefile.am index 395f018b0d..0da2beaff8 100644 --- a/src/jarabe/model/Makefile.am +++ b/src/jarabe/model/Makefile.am @@ -13,6 +13,7 @@ sugar_PYTHON = \ aodprompts.py \ aodqueue.py \ aodrag.py \ + aodrefine.py \ aodsessions.py \ aodservice.py \ aodspec.py \ diff --git a/src/jarabe/model/aodpipeline.py b/src/jarabe/model/aodpipeline.py index 61d952f0b5..8c07ebde9a 100644 --- a/src/jarabe/model/aodpipeline.py +++ b/src/jarabe/model/aodpipeline.py @@ -10,6 +10,7 @@ from jarabe.model.aodcodegen import build_codegen_system_prompt from jarabe.model.aodcodegen import build_codegen_user_prompt +from jarabe.model.aodcodegen import extract_activity_source_from_response from jarabe.model.aodgenerator import build_plan from jarabe.model.aodgenerator import create_prototype_activity from jarabe.model.aodgenerator import enrich_plan @@ -22,6 +23,10 @@ from jarabe.model.aodprompts import build_user_prompt from jarabe.model.aodrag import build_corpus from jarabe.model.aodrag import search +from jarabe.model.aodrefine import build_refine_system_prompt +from jarabe.model.aodrefine import build_refine_user_prompt +from jarabe.model.aodrefine import parse_search_replace +from jarabe.model.aodrefine import apply_patches from jarabe.model.aodvalidator import validate_bundle from jarabe.model.aodvalidator import validate_activity_source_for_request from jarabe.model.aodvalidator import validate_project @@ -302,6 +307,42 @@ def _redact_provider_error(error, provider): return message +_STREAM_REPORT_INTERVAL_SECONDS = 0.08 + + +def _make_codegen_stream_callback(progress, attempt): + """Build a stream callback that forwards partial codegen text to the UI. + + The callback is debounced so the UI is not repainted on every single + token; intermediate updates land at most once per ~80ms. The first + and final chunks are always reported so the preview lights up + immediately and reflects the final draft. + """ + state = {'last_emit': 0.0, 'last_text': ''} + + def report_partial(partial_text): + if not isinstance(partial_text, str): + return + state['last_text'] = partial_text + now = time.time() + if now - state['last_emit'] < _STREAM_REPORT_INTERVAL_SECONDS: + return + state['last_emit'] = now + progress.report( + 'generating', + 0.58, + 'Streaming activity.py from the model ' + '(%d chars)' % len(partial_text), + { + 'draft_activity_source': partial_text, + 'codegen_attempt': attempt, + 'codegen_streaming': True, + }, + ) + + return report_partial + + def _generate_activity_source_with_provider(provider, spec, plan, references, progress): generate_source = getattr(provider, 'generate_activity_source', None) @@ -321,15 +362,23 @@ def _generate_activity_source_with_provider(provider, spec, plan, references, '(attempt %d of %d)' % (attempt, _CODEGEN_ATTEMPT_LIMIT), ) + attempt_number = attempt + stream_callback = _make_codegen_stream_callback( + progress, attempt_number) + system_prompt = build_codegen_system_prompt(spec, plan, references) + user_prompt = build_codegen_user_prompt( + spec, plan, validation_feedback) try: - source = generate_source( - build_codegen_system_prompt(spec, plan, references), - build_codegen_user_prompt( - spec, - plan, - validation_feedback, - ), - ) + try: + source = generate_source( + system_prompt, + user_prompt, + stream_callback=stream_callback, + ) + except TypeError: + # Older providers / test doubles may not accept + # stream_callback; fall back to the non-streaming call. + source = generate_source(system_prompt, user_prompt) except ProviderError as error: return None, _redact_provider_error(error, provider), attempt except ValueError as error: @@ -376,3 +425,170 @@ def _format_codegen_feedback(report): for warning in report.warnings[:3] ) return '\n'.join(items) + + +def refine_activity(spec, current_source, current_plan, output_root, + provider=None, provider_name='default', + progress_cb=None, pace=False, + package_bundle=False): + """Refine an existing activity.py using SEARCH/REPLACE blocks. + + Tries the cheap SEARCH/REPLACE path first (~1k output tokens). If + the model requests FULLREGEN, or any patch fails to match, or the + patched code fails validation, falls back to full regeneration + (skipping the planner call by reusing current_plan). + + Returns a GenerationResult like generate_activity(). + """ + progress = _PipelineProgress(progress_cb, pace) + selected_provider = provider + if selected_provider is None and provider_name not in ( + 'local', 'local-template'): + try: + selected_provider = get_configured_provider(provider_name) + except ProviderError as error: + raise PipelineError( + 'Configured model is required for refinement: %s' % error + ) + + if selected_provider is None: + raise PipelineError( + 'A configured model is required for refinement.' + ) + + generate_source = getattr( + selected_provider, 'generate_activity_source', None) + if not callable(generate_source): + raise PipelineError( + 'Provider does not support activity source generation.' + ) + + if output_root is None: + output_root = env.get_profile_path(os.path.join('aod', 'projects')) + + refinement_request = spec.prompt + plan_context = json.dumps({ + 'template': current_plan.get('template', ''), + 'activity_kind': current_plan.get('activity_kind', ''), + 'interaction_model': current_plan.get('interaction_model', ''), + }, indent=2) + + progress.report('generating', 0.30, + 'Asking the model for targeted edits') + + patched_source = None + refine_method = 'search_replace' + try: + response = generate_source( + build_refine_system_prompt(), + build_refine_user_prompt( + current_source, + refinement_request, + plan_context=plan_context, + ), + ) + except (ProviderError, ValueError) as error: + progress.report( + 'generating', 0.35, + 'Edit request failed; falling back to full regeneration') + response = None + refine_method = 'full_regen' + + if response is not None: + try: + patches = parse_search_replace(response) + except ValueError: + patches = None + refine_method = 'full_regen' + + if patches is None: + refine_method = 'full_regen' + progress.report( + 'generating', 0.40, + 'Model requested full regeneration') + else: + progress.report( + 'validating', 0.55, + 'Applying %d targeted edits' % len(patches), + ) + patched, applied, failed = apply_patches( + current_source, patches) + if failed > 0 or applied == 0: + progress.report( + 'generating', 0.45, + '%d edits matched, %d failed; ' + 'falling back to full regeneration' + % (applied, failed), + ) + refine_method = 'full_regen' + patched_source = None + else: + report = validate_activity_source_for_request( + patched, spec, current_plan) + if report.valid: + patched_source = patched + progress.report( + 'validating', 0.70, + 'Edits applied and validated') + else: + progress.report( + 'generating', 0.50, + 'Patched code failed validation; ' + 'falling back to full regeneration') + refine_method = 'full_regen' + + if patched_source is None: + progress.report('generating', 0.55, + 'Regenerating full activity.py') + activity_source, code_error, code_attempts = ( + _generate_activity_source_with_provider( + selected_provider, + spec, + current_plan, + (), + progress, + ) + ) + if not activity_source: + raise PipelineError( + 'Refinement failed: %s' % (code_error or 'no source') + ) + patched_source = activity_source + + plan = dict(current_plan) + plan['code_source'] = 'provider' + plan['refine_method'] = refine_method + plan['codegen_provider'] = selected_provider.name + plan['codegen_model'] = selected_provider.model + + progress.report('generating', 0.80, + 'Assembling the refined project') + result = create_prototype_activity( + spec, + output_root, + plan=plan, + package_bundle=False, + activity_source=patched_source, + ) + result.provider = selected_provider.name + result.model = selected_provider.model + + progress.report('validating', 0.90, + 'Checking the refined source') + project_report = validate_project(result.project_path) + if project_report.errors: + raise PipelineError('\n'.join(project_report.errors)) + result.plan['validation'] = { + 'project_warnings': project_report.warnings, + } + plan_path = os.path.join(result.project_path, 'aod_plan.json') + with open(plan_path, 'w', encoding='utf-8') as plan_file: + json.dump(result.plan, plan_file, indent=2, sort_keys=True) + plan_file.write('\n') + + if package_bundle: + progress.report('packaging', 0.95, 'Packaging the XO bundle') + package_generation_result(result) + + progress.report('ready', 1.0, 'Refined activity is ready') + return result diff --git a/src/jarabe/model/aodrefine.py b/src/jarabe/model/aodrefine.py new file mode 100644 index 0000000000..cf2a6d801e --- /dev/null +++ b/src/jarabe/model/aodrefine.py @@ -0,0 +1,247 @@ +# Copyright (C) 2026 Sugar Labs +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""SEARCH/REPLACE refinement for generated Sugar activities. + +Instead of regenerating the entire activity.py on every refinement, +this module asks the model to return only the changed regions as +SEARCH/REPLACE blocks. This cuts output tokens from ~12k to ~1k per +refinement, making classroom iteration 10-20x cheaper and faster. + +If any SEARCH block does not match the current source (the model +hallucinated a line, or the change is too large for a small diff), +the caller falls back to full regeneration. +""" + +import re + + +SEARCH_MARKER = '<<<<<<< SEARCH' +DIVIDER_MARKER = '=======' +REPLACE_MARKER = '>>>>>>> REPLACE' + + +def build_refine_system_prompt(): + """System prompt for SEARCH/REPLACE refinement. + + This is a separate prompt from the codegen system prompt because + the task is fundamentally different: the model is editing existing + code, not writing new code from scratch. + """ + return ( + 'You are Sugar Activity on Demand, editing an existing Sugar ' + 'activity.\n\n' + 'The user will give you the current activity.py and a refinement ' + 'request. Return ONLY the changes as SEARCH/REPLACE blocks in ' + 'this exact format:\n\n' + '<<<<<<< SEARCH\n' + '\n' + '=======\n' + '\n' + '>>>>>>> REPLACE\n\n' + 'Rules:\n' + '- The SEARCH section must be copied EXACTLY from the current ' + 'source, including indentation and whitespace. Do not paraphrase ' + 'or reformat.\n' + '- The REPLACE section is the new code that replaces the SEARCH ' + 'section.\n' + '- You may output multiple SEARCH/REPLACE blocks. Separate them ' + 'with a blank line.\n' + '- Keep each SEARCH block as small as possible while still being ' + 'unique in the file. 3-10 lines is ideal.\n' + '- Do NOT output the entire file. Do NOT output anything except ' + 'SEARCH/REPLACE blocks. No explanations, no markdown, no code ' + 'fences.\n' + '- If the refinement requires adding new methods or large new ' + 'sections, use a SEARCH block that matches an anchor line (like ' + 'a method definition or the end of a class) and include the new ' + 'code in the REPLACE section.\n' + '- If the refinement is too large to express as SEARCH/REPLACE ' + 'blocks (e.g. rewriting most of the file), output exactly:\n' + 'FULLREGEN\n' + '...and nothing else. The system will fall back to full ' + 'regeneration.\n' + '- Preserve all Sugar Activity patterns: ToolbarBox, StopButton, ' + 'set_canvas, read_file/write_file, Journal persistence.\n' + '- Keep the same class name GeneratedActivity.\n' + '- Use only classroom-safe imports. No networking, subprocesses, ' + 'or filesystem access.\n' + ) + + +def build_refine_user_prompt(current_source, refinement_request, + plan_context=''): + """User prompt for SEARCH/REPLACE refinement. + + Sends the full current source so the model can copy exact lines for + SEARCH blocks. The plan context is optional and kept small. + """ + parts = [ + 'Refine this Sugar activity.py according to the request below.\n', + 'Current activity.py (%d lines):\n' % current_source.count('\n'), + current_source.rstrip(), + '\n\n---\n\nRefinement request:\n', + refinement_request, + ] + if plan_context: + parts.append('\n\nPlan context (for reference):\n') + parts.append(plan_context) + parts.append( + '\n\n---\n\n' + 'Return SEARCH/REPLACE blocks for the changes. Copy SEARCH ' + 'lines EXACTLY from the source above. If the change is too ' + 'large, output FULLREGEN.' + ) + return ''.join(parts) + + +def parse_search_replace(response): + """Parse SEARCH/REPLACE blocks from a model response. + + Returns a list of (search, replace) tuples, or None if the model + requested FULLREGEN, or raises ValueError if the response is + malformed. + """ + if not isinstance(response, str): + raise ValueError('Refinement response must be text.') + + text = response.strip() + + if text == 'FULLREGEN' or text.startswith('FULLREGEN'): + return None + + blocks = [] + pos = 0 + + while True: + search_start = text.find(SEARCH_MARKER, pos) + if search_start < 0: + break + + divider_start = text.find(DIVIDER_MARKER, search_start + len(SEARCH_MARKER)) + if divider_start < 0: + raise ValueError( + 'Malformed SEARCH/REPLACE: missing ======= divider after ' + '<<<<<<< SEARCH at position %d.' % search_start + ) + + replace_end = text.find(REPLACE_MARKER, divider_start + len(DIVIDER_MARKER)) + if replace_end < 0: + raise ValueError( + 'Malformed SEARCH/REPLACE: missing >>>>>>> REPLACE after ' + '======= divider at position %d.' % divider_start + ) + + search_text = text[search_start + len(SEARCH_MARKER):divider_start] + replace_text = text[divider_start + len(DIVIDER_MARKER):replace_end] + + search_text = _strip_block_edges(search_text) + replace_text = _strip_block_edges(replace_text) + + if not search_text: + raise ValueError( + 'Malformed SEARCH/REPLACE: empty SEARCH section at ' + 'position %d.' % search_start + ) + + blocks.append((search_text, replace_text)) + pos = replace_end + len(REPLACE_MARKER) + + if not blocks: + if '<<<<<<<' not in text: + raise ValueError( + 'Refinement response did not contain SEARCH/REPLACE ' + 'blocks. If the change is too large, output FULLREGEN.' + ) + raise ValueError( + 'Refinement response contained SEARCH markers but no valid ' + 'blocks were parsed.' + ) + + return blocks + + +def apply_patches(source, patches): + """Apply SEARCH/REPLACE patches to source code. + + Returns (patched_source, applied_count, failed_count). Each patch + is a (search, replace) tuple. Matching is whitespace-tolerant: we + normalize trailing whitespace per line but preserve leading + indentation. If any patch fails to match, it is skipped and + counted in failed_count. + + The caller should check failed_count and fall back to full regen + if any patches failed. + """ + lines = source.split('\n') + applied = 0 + failed = 0 + + for search_text, replace_text in patches: + search_lines = search_text.split('\n') + while search_lines and search_lines[-1] == '': + search_lines.pop() + if not search_lines: + failed += 1 + continue + + match_index = _find_block(lines, search_lines) + if match_index < 0: + failed += 1 + continue + + replace_lines = replace_text.split('\n') if replace_text else [] + + start = match_index + end = match_index + len(search_lines) + + lines = lines[:start] + replace_lines + lines[end:] + applied += 1 + + patched = '\n'.join(lines) + if not patched.endswith('\n'): + patched += '\n' + return patched, applied, failed + + +def _find_block(lines, search_lines): + """Find the index of a search block in lines. + + Uses whitespace-tolerant matching: trailing whitespace is ignored, + but leading indentation is preserved. Returns the line index of + the first match, or -1 if not found. + """ + normalized_search = [_normalize_line(line) for line in search_lines] + if not normalized_search: + return -1 + + normalized_lines = [_normalize_line(line) for line in lines] + + for start in range(len(normalized_lines) - len(normalized_search) + 1): + if normalized_lines[start:start + len(normalized_search)] == \ + normalized_search: + return start + return -1 + + +def _normalize_line(line): + """Normalize a line for whitespace-tolerant matching. + + Strips trailing whitespace but preserves leading indentation. + """ + return line.rstrip() + + +def _strip_block_edges(text): + """Remove leading/trailing newlines from a block's content.""" + stripped = text.strip('\n') + if stripped.startswith('```'): + first_nl = stripped.find('\n') + if first_nl >= 0: + stripped = stripped[first_nl + 1:] + if stripped.endswith('```'): + last_fence = stripped.rfind('```') + if last_fence >= 0: + stripped = stripped[:last_fence] + return stripped.rstrip('\n') diff --git a/src/jarabe/model/aodservice.py b/src/jarabe/model/aodservice.py index 1b51cdd689..cac976b207 100644 --- a/src/jarabe/model/aodservice.py +++ b/src/jarabe/model/aodservice.py @@ -2,7 +2,9 @@ # # SPDX-License-Identifier: GPL-3.0-or-later +import json import logging +import os import threading from jarabe.model.aodcredentials import AODCredentialStore @@ -341,23 +343,26 @@ def _run_job_inner(self, job): try: with self._lock: provider = self._job_providers.get(job.job_id) - result = generate_activity( - job.spec, - output_root=job.output_root or None, - provider=provider, - provider_name=job.provider_name, - use_rag=job.use_rag, - progress_cb=lambda stage, fraction, message, metadata=None: - self._pipeline_progress( - job, - stage, - fraction, - message, - metadata, - ), - pace=True, - package_bundle=False, - ) + if job.parent_revision_id: + result = self._run_refinement_job(job, provider) + else: + result = generate_activity( + job.spec, + output_root=job.output_root or None, + provider=provider, + provider_name=job.provider_name, + use_rag=job.use_rag, + progress_cb=lambda stage, fraction, message, metadata=None: + self._pipeline_progress( + job, + stage, + fraction, + message, + metadata, + ), + pace=True, + package_bundle=False, + ) except JobCancelled: self._mark_cancelled(job) return @@ -393,6 +398,84 @@ def _pipeline_progress(self, job, stage, fraction, message, status = _status_for_pipeline_stage(stage) self._set_progress(job, status, stage, fraction, message, metadata) + def _run_refinement_job(self, job, provider): + """Run a refinement using SEARCH/REPLACE + full-regen fallback. + + For LLM providers, tries cheap SEARCH/REPLACE blocks before + falling back to full regen. For local-template, falls back + to the standard generation pipeline. + """ + if job.provider_name in ('local', 'local-template') and \ + provider is None: + return generate_activity( + job.spec, + output_root=job.output_root or None, + provider=None, + provider_name='local-template', + use_rag=job.use_rag, + progress_cb=lambda stage, fraction, message, metadata=None: + self._pipeline_progress( + job, stage, fraction, message, metadata), + pace=True, + package_bundle=False, + ) + + from jarabe.model.aodpipeline import refine_activity + from jarabe.model.aodpipeline import PipelineError + + session = self._session_store.load(job.session_id) + if session is None: + raise PipelineError( + 'Could not find the session for refinement.' + ) + + parent_revision = None + for rev in session.revisions: + if rev.revision_id == job.parent_revision_id: + parent_revision = rev + break + if parent_revision is None: + raise PipelineError( + 'Could not find the parent revision for refinement.' + ) + + summary = parent_revision.result_summary or {} + project_path = summary.get('project_path', '') + if not project_path: + raise PipelineError( + 'Parent revision has no project path.' + ) + + source_path = os.path.join(project_path, 'activity.py') + try: + with open(source_path, encoding='utf-8') as f: + current_source = f.read() + except OSError: + raise PipelineError( + 'Could not read the current activity.py for refinement.' + ) + + plan_path = os.path.join(project_path, 'aod_plan.json') + try: + with open(plan_path, encoding='utf-8') as f: + current_plan = json.load(f) + except (OSError, ValueError): + current_plan = {} + + return refine_activity( + job.spec, + current_source, + current_plan, + job.output_root or None, + provider=provider, + provider_name=job.provider_name, + progress_cb=lambda stage, fraction, message, metadata=None: + self._pipeline_progress( + job, stage, fraction, message, metadata), + pace=True, + package_bundle=False, + ) + def _set_progress(self, job, status, stage, progress, message, metadata=None): with self._lock: diff --git a/tests/test_aodrefine.py b/tests/test_aodrefine.py new file mode 100644 index 0000000000..2c0251ecbc --- /dev/null +++ b/tests/test_aodrefine.py @@ -0,0 +1,207 @@ +# Copyright (C) 2026 Sugar Labs +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import unittest + +from jarabe.model.aodrefine import apply_patches +from jarabe.model.aodrefine import build_refine_system_prompt +from jarabe.model.aodrefine import build_refine_user_prompt +from jarabe.model.aodrefine import parse_search_replace + + +_SAMPLE_SOURCE = ( + 'from sugar3.activity import activity\n' + '\n' + '\n' + 'class GeneratedActivity(activity.Activity):\n' + ' def __init__(self, handle):\n' + ' activity.Activity.__init__(self, handle)\n' + ' self._score = 0\n' + ' self._build_ui()\n' + '\n' + ' def _build_ui(self):\n' + ' self._label = Gtk.Label("Score: 0")\n' + ' self.set_canvas(self._label)\n' + '\n' + ' def write_file(self, file_path):\n' + ' pass\n' + '\n' + ' def read_file(self, file_path):\n' + ' pass\n' +) + + +class TestParseSearchReplace(unittest.TestCase): + + def test_parses_single_block(self): + response = ( + '<<<<<<< SEARCH\n' + ' self._score = 0\n' + '=======\n' + ' self._score = 0\n' + ' self._undo_stack = []\n' + '>>>>>>> REPLACE\n' + ) + patches = parse_search_replace(response) + self.assertEqual(1, len(patches)) + self.assertEqual( + ' self._score = 0', + patches[0][0], + ) + self.assertEqual( + ' self._score = 0\n self._undo_stack = []', + patches[0][1], + ) + + def test_parses_multiple_blocks(self): + response = ( + '<<<<<<< SEARCH\n' + ' self._score = 0\n' + '=======\n' + ' self._score = 100\n' + '>>>>>>> REPLACE\n' + '\n' + '<<<<<<< SEARCH\n' + ' self._label = Gtk.Label("Score: 0")\n' + '=======\n' + ' self._label = Gtk.Label("Score: 100")\n' + '>>>>>>> REPLACE\n' + ) + patches = parse_search_replace(response) + self.assertEqual(2, len(patches)) + + def test_full_regen_returns_none(self): + patches = parse_search_replace('FULLREGEN') + self.assertIsNone(patches) + + def test_full_regen_with_trailing_text_returns_none(self): + patches = parse_search_replace('FULLREGEN\n') + self.assertIsNone(patches) + + def test_missing_divider_raises(self): + response = ( + '<<<<<<< SEARCH\n' + 'some code\n' + '>>>>>>> REPLACE\n' + ) + with self.assertRaises(ValueError): + parse_search_replace(response) + + def test_missing_replace_marker_raises(self): + response = ( + '<<<<<<< SEARCH\n' + 'some code\n' + '=======\n' + 'new code\n' + ) + with self.assertRaises(ValueError): + parse_search_replace(response) + + def test_empty_search_raises(self): + response = ( + '<<<<<<< SEARCH\n' + '=======\n' + 'new code\n' + '>>>>>>> REPLACE\n' + ) + with self.assertRaises(ValueError): + parse_search_replace(response) + + def test_no_blocks_raises(self): + with self.assertRaises(ValueError): + parse_search_replace('Here are the changes:\nadd a button') + + def test_strips_code_fences_around_blocks(self): + response = ( + '```\n' + '<<<<<<< SEARCH\n' + ' self._score = 0\n' + '=======\n' + ' self._score = 100\n' + '>>>>>>> REPLACE\n' + '```\n' + ) + patches = parse_search_replace(response) + self.assertEqual(1, len(patches)) + + +class TestApplyPatches(unittest.TestCase): + + def test_applies_single_patch(self): + patches = [(' self._score = 0', + ' self._score = 100')] + patched, applied, failed = apply_patches(_SAMPLE_SOURCE, patches) + self.assertEqual(1, applied) + self.assertEqual(0, failed) + self.assertIn('self._score = 100', patched) + self.assertNotIn('self._score = 0\n', patched) + + def test_applies_multi_line_patch(self): + search = ' self._score = 0\n self._build_ui()' + replace = ' self._score = 0\n self._undo_stack = []\n self._build_ui()' + patches = [(search, replace)] + patched, applied, failed = apply_patches(_SAMPLE_SOURCE, patches) + self.assertEqual(1, applied) + self.assertEqual(0, failed) + self.assertIn('self._undo_stack = []', patched) + + def test_failed_patch_counted(self): + patches = [(' self._nonexistent = 42', + ' self._new = 99')] + patched, applied, failed = apply_patches(_SAMPLE_SOURCE, patches) + self.assertEqual(0, applied) + self.assertEqual(1, failed) + self.assertEqual(_SAMPLE_SOURCE, patched) + + def test_whitespace_tolerant_matching(self): + source = ' self._score = 0 \n' + patches = [(' self._score = 0', ' self._score = 100')] + patched, applied, failed = apply_patches(source, patches) + self.assertEqual(1, applied) + self.assertEqual(0, failed) + self.assertIn('self._score = 100', patched) + + def test_multiple_patches_applied_in_order(self): + patches = [ + (' self._score = 0', ' self._score = 100'), + (' self._label = Gtk.Label("Score: 0")', + ' self._label = Gtk.Label("Score: 100")'), + ] + patched, applied, failed = apply_patches(_SAMPLE_SOURCE, patches) + self.assertEqual(2, applied) + self.assertEqual(0, failed) + self.assertIn('self._score = 100', patched) + self.assertIn('Score: 100', patched) + + def test_empty_replace_deletes_lines(self): + search = ' self._score = 0\n' + patches = [(search, '')] + patched, applied, failed = apply_patches(_SAMPLE_SOURCE, patches) + self.assertEqual(1, applied) + self.assertNotIn('self._score = 0', patched) + + +class TestRefinePrompts(unittest.TestCase): + + def test_system_prompt_mentions_search_replace(self): + prompt = build_refine_system_prompt() + self.assertIn('SEARCH', prompt) + self.assertIn('REPLACE', prompt) + self.assertIn('FULLREGEN', prompt) + + def test_user_prompt_includes_source(self): + prompt = build_refine_user_prompt( + _SAMPLE_SOURCE, 'add an undo button') + self.assertIn('GeneratedActivity', prompt) + self.assertIn('add an undo button', prompt) + self.assertIn('SEARCH/REPLACE', prompt) + + def test_user_prompt_includes_plan_context(self): + prompt = build_refine_user_prompt( + _SAMPLE_SOURCE, 'add undo', plan_context='{"template": "canvas"}') + self.assertIn('canvas', prompt) + + +if __name__ == '__main__': + unittest.main() From db87cd4eae7890c525048b2d1516c8b900e33b8e Mon Sep 17 00:00:00 2001 From: Ashutoshx7 Date: Wed, 24 Jun 2026 00:19:38 +0530 Subject: [PATCH 04/28] Fix 3 critical refinement bugs found in deep audit Bug 1 (critical): SEARCH/REPLACE path was completely dead because all providers ran the response through extract_activity_source() which expects a complete activity.py, not SEARCH/REPLACE blocks. Added generate_text() to every provider (Gemini, OpenAI, FreeModel, Claude, Ollama) that returns raw model text without extraction. Pipeline now calls generate_text() for refinement instead of generate_activity_source(). Bug 2: refine_method metadata was dropped by normalize_plan() because it was not in the passthrough whitelist. Added 'refine_method' to the whitelist so callers can tell whether SEARCH/REPLACE or full regen was used. Bug 3: bundle_id changed on every refinement because normalize_plan() recomputed it from spec.prompt (which differs for each refinement). Now preserves the parent bundle_id from the incoming plan. Bug 5: Model received the source twice (truncated in spec.prompt + full in refine prompt). Removed the source excerpt from _build_refinement_spec() since refine_activity() sends the full source via build_refine_user_prompt(). Bug 7: Removed dead extract_activity_source_from_response import from pipeline. --- src/jarabe/desktop/homebox.py | 7 +- src/jarabe/model/aodgenerator.py | 6 +- src/jarabe/model/aodllm.py | 213 +++++++++++++++++++++++++++---- src/jarabe/model/aodpipeline.py | 25 ++-- 4 files changed, 213 insertions(+), 38 deletions(-) diff --git a/src/jarabe/desktop/homebox.py b/src/jarabe/desktop/homebox.py index b291d9ce17..75213b5bfb 100644 --- a/src/jarabe/desktop/homebox.py +++ b/src/jarabe/desktop/homebox.py @@ -2804,6 +2804,10 @@ def _create_learning_sidebar(self): box.pack_start(subtitle, False, False, 0) subtitle.show() + refinement_card = self._create_sidebar_refinement_card() + box.pack_start(refinement_card, False, False, 0) + refinement_card.show() + guided = Gtk.EventBox() guided.get_style_context().add_class('create-ai-learning-card') box.pack_start(guided, False, False, 0) @@ -6524,7 +6528,6 @@ def _build_refinement_spec(self, refinement): flow = plan.get('classroom_flow') or plan.get('learner_steps') or [] flow_text = '\n'.join('- %s' % step for step in flow[:5]) plan_context = self._compact_plan_for_refinement(plan) - source_context = self._source_context_for_refinement(result) prompt = ( 'Refine the existing generated Sugar activity. Preserve working ' 'behavior unless the new request changes it.\n\n' @@ -6535,7 +6538,6 @@ def _build_refinement_spec(self, refinement): '- Summary: %(summary)s\n' '- Classroom flow:\n%(flow)s\n\n' 'Current plan JSON excerpt:\n%(plan_context)s\n\n' - 'Current activity.py excerpt:\n%(source_context)s\n\n' 'Refinement request:\n%(refinement)s' ) % { 'original': base_spec.prompt, @@ -6544,7 +6546,6 @@ def _build_refinement_spec(self, refinement): 'summary': plan.get('summary', ''), 'flow': flow_text or '- Keep the activity usable for learners.', 'plan_context': plan_context, - 'source_context': source_context, 'refinement': refinement, } prompt = self._limit_refinement_prompt(prompt) diff --git a/src/jarabe/model/aodgenerator.py b/src/jarabe/model/aodgenerator.py index e4e0daf1af..41a9e8154f 100644 --- a/src/jarabe/model/aodgenerator.py +++ b/src/jarabe/model/aodgenerator.py @@ -549,11 +549,15 @@ def normalize_plan(spec, plan): 'code_source', 'codegen_provider', 'codegen_model', - 'codegen_fallback_reason'): + 'codegen_fallback_reason', + 'refine_method'): value = plan.get(field) if isinstance(value, str) and value: normalized[field] = value + if plan.get('bundle_id'): + normalized['bundle_id'] = plan['bundle_id'] + attempts = plan.get('codegen_attempts') if isinstance(attempts, int): normalized['codegen_attempts'] = attempts diff --git a/src/jarabe/model/aodllm.py b/src/jarabe/model/aodllm.py index b53299577d..d4e04e8498 100644 --- a/src/jarabe/model/aodllm.py +++ b/src/jarabe/model/aodllm.py @@ -56,9 +56,29 @@ def generate_plan(self, system_prompt, user_prompt, timeout=_PROVIDER_PLAN_TIMEOUT): raise NotImplementedError + def generate_text(self, system_prompt, user_prompt, + timeout=_PROVIDER_CODEGEN_TIMEOUT, + stream_callback=None): + """Return raw model text without any extraction or parsing. + + Used by the refinement pipeline for SEARCH/REPLACE blocks, + where the response is NOT a complete activity.py and must not + be run through extract_activity_source(). + """ + raise NotImplementedError( + '%s does not support raw text generation.' % self.label + ) + def generate_activity_source(self, system_prompt, user_prompt, - timeout=_PROVIDER_CODEGEN_TIMEOUT): - """Return a complete generated activity.py source string.""" + timeout=_PROVIDER_CODEGEN_TIMEOUT, + stream_callback=None): + """Return a complete generated activity.py source string. + + stream_callback, if provided, is called with the growing partial + text as the provider emits tokens. Providers that do not support + streaming may ignore it; the final returned source is always the + complete activity.py. + """ return extract_activity_source( self.generate_plan(system_prompt, user_prompt, timeout=timeout) ) @@ -106,8 +126,24 @@ def generate_plan(self, system_prompt, user_prompt, timeout=_PROVIDER_PLAN_TIMEOUT): return self._generate_json(system_prompt, user_prompt, timeout) + def generate_text(self, system_prompt, user_prompt, + timeout=_PROVIDER_CODEGEN_TIMEOUT, + stream_callback=None): + text = self._generate_content( + system_prompt, user_prompt, timeout, + max_output_tokens=_GEMINI_CODEGEN_MAX_OUTPUT_TOKENS, + response_json=False, + ) + if stream_callback is not None: + try: + stream_callback(text) + except Exception: + pass + return text + def generate_activity_source(self, system_prompt, user_prompt, - timeout=_PROVIDER_CODEGEN_TIMEOUT): + timeout=_PROVIDER_CODEGEN_TIMEOUT, + stream_callback=None): text = self._generate_content( system_prompt, user_prompt, @@ -115,6 +151,11 @@ def generate_activity_source(self, system_prompt, user_prompt, max_output_tokens=_GEMINI_CODEGEN_MAX_OUTPUT_TOKENS, response_json=False, ) + if stream_callback is not None: + try: + stream_callback(text) + except Exception: + pass return extract_activity_source_from_response(text) def _generate_json(self, system_prompt, user_prompt, timeout, @@ -212,14 +253,26 @@ def generate_plan(self, system_prompt, user_prompt, timeout=_PROVIDER_PLAN_TIMEOUT): return self._generate_json(system_prompt, user_prompt, timeout) + def generate_text(self, system_prompt, user_prompt, + timeout=_PROVIDER_CODEGEN_TIMEOUT, + stream_callback=None): + if stream_callback is not None: + return self._stream_text( + system_prompt, user_prompt, timeout, + max_tokens=_CODEGEN_MAX_TOKENS, + stream_callback=stream_callback, + ) + return self._generate_text( + system_prompt, user_prompt, timeout, + max_tokens=_CODEGEN_MAX_TOKENS, json_response=False, + ) + def generate_activity_source(self, system_prompt, user_prompt, - timeout=_PROVIDER_CODEGEN_TIMEOUT): - text = self._generate_text( - system_prompt, - user_prompt, - timeout, - max_tokens=_CODEGEN_MAX_TOKENS, - json_response=False, + timeout=_PROVIDER_CODEGEN_TIMEOUT, + stream_callback=None): + text = self.generate_text( + system_prompt, user_prompt, timeout, + stream_callback=stream_callback, ) return extract_activity_source_from_response(text) @@ -235,6 +288,77 @@ def _generate_json(self, system_prompt, user_prompt, timeout, ) ) + def _stream_text(self, system_prompt, user_prompt, timeout, + max_tokens, stream_callback): + """Stream a chat-completion response token-by-token via SSE. + + Returns the full accumulated text once the stream ends; on the + way, calls stream_callback(running_text) for each delta chunk so + callers can surface a live preview. Provider errors raise + ProviderError just like the non-streaming path. + """ + payload = { + 'model': self.model, + 'messages': [ + {'role': 'system', 'content': system_prompt}, + {'role': 'user', 'content': user_prompt}, + ], + 'temperature': self._generation_temperature(), + 'stream': True, + } + if max_tokens is not None: + payload['max_tokens'] = max_tokens + payload.update( + self._extra_generation_params(max_tokens, json_response=False) + ) + request = urllib.request.Request( + self._endpoint, + data=json.dumps(payload).encode('utf-8'), + headers=self._request_headers(), + method='POST', + ) + parts = [] + try: + with urllib.request.urlopen( + request, timeout=timeout) as response: + for raw_line in response: + line = raw_line.decode( + 'utf-8', errors='replace').rstrip('\r\n') + if not line or not line.startswith('data:'): + continue + data = line[5:].strip() + if data == '[DONE]': + break + try: + event = json.loads(data) + except json.JSONDecodeError: + continue + try: + delta = event['choices'][0].get('delta') or {} + except (KeyError, IndexError, TypeError): + continue + content = delta.get('content') + if not isinstance(content, str) or not content: + continue + parts.append(content) + try: + stream_callback(''.join(parts)) + except Exception: + # Streaming is a UX nicety; a failing UI callback + # must not abort generation. + pass + except urllib.error.HTTPError as error: + detail = error.read().decode('utf-8', errors='replace')[:500] + raise ProviderError( + '%s stream failed with HTTP %d: %s' + % (self._request_label(), error.code, detail) + ) + except (OSError, ValueError) as error: + raise ProviderError( + '%s stream failed: %s' % (self._request_label(), error) + ) + return ''.join(parts) + def _generate_text(self, system_prompt, user_prompt, timeout, max_tokens=None, json_response=True): payload = { @@ -470,15 +594,28 @@ def generate_plan(self, system_prompt, user_prompt, timeout=_PROVIDER_PLAN_TIMEOUT): return self._generate_json(system_prompt, user_prompt, timeout) - def generate_activity_source(self, system_prompt, user_prompt, - timeout=_PROVIDER_CODEGEN_TIMEOUT): + def generate_text(self, system_prompt, user_prompt, + timeout=_PROVIDER_CODEGEN_TIMEOUT, + stream_callback=None): text = self._generate_responses_text( - system_prompt, - user_prompt, - timeout, + system_prompt, user_prompt, timeout, max_output_tokens=_FREEMODEL_CODEGEN_MAX_OUTPUT_TOKENS, reasoning_effort=self._codegen_reasoning_effort, ) + if stream_callback is not None: + try: + stream_callback(text) + except Exception: + pass + return text + + def generate_activity_source(self, system_prompt, user_prompt, + timeout=_PROVIDER_CODEGEN_TIMEOUT, + stream_callback=None): + text = self.generate_text( + system_prompt, user_prompt, timeout, + stream_callback=stream_callback, + ) return extract_activity_source_from_response(text) def _generate_json(self, system_prompt, user_prompt, timeout, @@ -547,14 +684,27 @@ def generate_plan(self, system_prompt, user_prompt, max_tokens=1600, ) - def generate_activity_source(self, system_prompt, user_prompt, - timeout=_PROVIDER_CODEGEN_TIMEOUT): + def generate_text(self, system_prompt, user_prompt, + timeout=_PROVIDER_CODEGEN_TIMEOUT, + stream_callback=None): text = self._generate_text( - system_prompt, - user_prompt, - timeout, + system_prompt, user_prompt, timeout, max_tokens=_CODEGEN_MAX_TOKENS, ) + if stream_callback is not None: + try: + stream_callback(text) + except Exception: + pass + return text + + def generate_activity_source(self, system_prompt, user_prompt, + timeout=_PROVIDER_CODEGEN_TIMEOUT, + stream_callback=None): + text = self.generate_text( + system_prompt, user_prompt, timeout, + stream_callback=stream_callback, + ) return extract_activity_source_from_response(text) def _generate_json(self, system_prompt, user_prompt, timeout, @@ -622,14 +772,27 @@ def generate_plan(self, system_prompt, user_prompt, timeout=_PROVIDER_PLAN_TIMEOUT): return self._generate_json(system_prompt, user_prompt, timeout) - def generate_activity_source(self, system_prompt, user_prompt, - timeout=_PROVIDER_CODEGEN_TIMEOUT): + def generate_text(self, system_prompt, user_prompt, + timeout=_PROVIDER_CODEGEN_TIMEOUT, + stream_callback=None): text = self._generate_text( - system_prompt, - user_prompt, - timeout, + system_prompt, user_prompt, timeout, num_predict=_CODEGEN_MAX_TOKENS, ) + if stream_callback is not None: + try: + stream_callback(text) + except Exception: + pass + return text + + def generate_activity_source(self, system_prompt, user_prompt, + timeout=_PROVIDER_CODEGEN_TIMEOUT, + stream_callback=None): + text = self.generate_text( + system_prompt, user_prompt, timeout, + stream_callback=stream_callback, + ) return extract_activity_source_from_response(text) def _generate_json(self, system_prompt, user_prompt, timeout, diff --git a/src/jarabe/model/aodpipeline.py b/src/jarabe/model/aodpipeline.py index 8c07ebde9a..9b874f1268 100644 --- a/src/jarabe/model/aodpipeline.py +++ b/src/jarabe/model/aodpipeline.py @@ -10,7 +10,6 @@ from jarabe.model.aodcodegen import build_codegen_system_prompt from jarabe.model.aodcodegen import build_codegen_user_prompt -from jarabe.model.aodcodegen import extract_activity_source_from_response from jarabe.model.aodgenerator import build_plan from jarabe.model.aodgenerator import create_prototype_activity from jarabe.model.aodgenerator import enrich_plan @@ -476,17 +475,25 @@ def refine_activity(spec, current_source, current_plan, output_root, progress.report('generating', 0.30, 'Asking the model for targeted edits') + generate_text = getattr(selected_provider, 'generate_text', None) + if not callable(generate_text): + generate_text = None + patched_source = None refine_method = 'search_replace' try: - response = generate_source( - build_refine_system_prompt(), - build_refine_user_prompt( - current_source, - refinement_request, - plan_context=plan_context, - ), - ) + if generate_text is not None: + response = generate_text( + build_refine_system_prompt(), + build_refine_user_prompt( + current_source, + refinement_request, + plan_context=plan_context, + ), + ) + else: + response = None + refine_method = 'full_regen' except (ProviderError, ValueError) as error: progress.report( 'generating', 0.35, From 345577de4c9f10b439e7158499502ae95079a239 Mon Sep 17 00:00:00 2001 From: Ashutoshx7 Date: Wed, 24 Jun 2026 02:23:59 +0530 Subject: [PATCH 05/28] Preview generated AOD activities live --- src/jarabe/desktop/homebox.py | 59 +++++++ src/jarabe/model/Makefile.am | 1 + src/jarabe/model/aodpreview.py | 304 +++++++++++++++++++++++++++++++++ tests/test_aodpipeline.py | 47 ++++- 4 files changed, 410 insertions(+), 1 deletion(-) create mode 100644 src/jarabe/model/aodpreview.py diff --git a/src/jarabe/desktop/homebox.py b/src/jarabe/desktop/homebox.py index 75213b5bfb..48b7f0d324 100644 --- a/src/jarabe/desktop/homebox.py +++ b/src/jarabe/desktop/homebox.py @@ -4711,6 +4711,9 @@ def _show_generated_activity_preview(self, result): return False def _render_generated_activity_preview(self, result): + if self._render_live_generated_activity_preview(result): + return + self._clear_activity_preview() plan = result.plan if isinstance(result.plan, dict) else {} template = plan.get('template', 'utility') @@ -4764,6 +4767,62 @@ def _render_generated_activity_preview(self, result): card.show_all() + def _render_live_generated_activity_preview(self, result): + project_path = getattr(result, 'project_path', '') + if not project_path: + return False + + try: + from jarabe.model.aodpreview import render_activity_preview + preview, canvas, toolbar = render_activity_preview( + project_path, + getattr(result.spec, 'name', '') or _('Generated Activity'), + ) + except Exception: + logging.exception('Could not render live generated activity') + return False + + if preview is None or not isinstance(canvas, Gtk.Widget): + logging.warning( + 'Live generated activity preview unavailable: %s', canvas) + return False + + self._clear_activity_preview() + + shell = Gtk.EventBox() + shell.get_style_context().add_class('create-ai-generated-preview') + shell.set_hexpand(True) + shell.set_vexpand(True) + shell.add_events(Gdk.EventMask.BUTTON_PRESS_MASK) + shell.connect('button-press-event', + self.__preview_target_button_press_event_cb, + _('activity canvas')) + + box = Gtk.VBox(spacing=style.zoom(6)) + box.set_border_width(style.zoom(4)) + box.set_hexpand(True) + box.set_vexpand(True) + shell.add(box) + + if isinstance(toolbar, Gtk.Widget): + self._detach_preview_widget(toolbar) + toolbar.set_hexpand(True) + box.pack_start(toolbar, False, False, 0) + + self._detach_preview_widget(canvas) + canvas.set_hexpand(True) + canvas.set_vexpand(True) + box.pack_start(canvas, True, True, 0) + + self._preview_content_box.pack_start(shell, True, True, 0) + shell.show_all() + return True + + def _detach_preview_widget(self, widget): + parent = widget.get_parent() + if parent is not None: + parent.remove(widget) + def _create_generated_preview_body(self, result, template): plan = result.plan if isinstance(result.plan, dict) else {} source = self._get_generated_activity_source(result) diff --git a/src/jarabe/model/Makefile.am b/src/jarabe/model/Makefile.am index 0da2beaff8..229eeef65f 100644 --- a/src/jarabe/model/Makefile.am +++ b/src/jarabe/model/Makefile.am @@ -10,6 +10,7 @@ sugar_PYTHON = \ aodlicenses.py \ aodllm.py \ aodpipeline.py \ + aodpreview.py \ aodprompts.py \ aodqueue.py \ aodrag.py \ diff --git a/src/jarabe/model/aodpreview.py b/src/jarabe/model/aodpreview.py new file mode 100644 index 0000000000..df2b434b12 --- /dev/null +++ b/src/jarabe/model/aodpreview.py @@ -0,0 +1,304 @@ +# Copyright (C) 2026 Sugar Labs +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Live preview of generated Sugar activities. + +Instead of showing a static mockup, this module imports the generated +activity.py, instantiates GeneratedActivity with a minimal stub base +class, and captures the real GTK canvas + toolbar widgets for embedding +in the AOD preview area. + +The stub provides just enough of the sugar3.activity.Activity API for +generated code to run without D-Bus, the Sugar shell, or the Journal. +""" + +import logging +import os +import re + +import gi +gi.require_version('Gtk', '3.0') +gi.require_version('Gdk', '3.0') +from gi.repository import Gtk +from gi.repository import Gdk +from gi.repository import GObject +from gi.repository import Pango + + +class _PreviewMetadata: + """Minimal stand-in for Sugar's metadata object. + + The real metadata is a SugarMetadata GObject that emits 'updated' + signals. Generated activity widgets (ActivityToolbarButton, + TitleEntry) call metadata.connect('updated', cb) and read + metadata['title'] and metadata.get('icon-color'). We provide a + dict subclass with a no-op connect() so the widgets construct + without crashing. + """ + + def __init__(self, title): + self._data = { + 'title': title or 'Preview', + 'icon-color': '', + 'description': '', + 'tags': '', + } + + def __getitem__(self, key): + return self._data.get(key, '') + + def __setitem__(self, key, value): + self._data[key] = value + + def __contains__(self, key): + return key in self._data + + def get(self, key, fallback=None): + return self._data.get(key, fallback) + + def keys(self): + return self._data.keys() + + def connect(self, signal, callback): + return 0 + + def disconnect(self, handler_id): + pass + + +class PreviewActivity(Gtk.Window): + """Minimal stub of sugar3.activity.Activity for preview rendering. + + Generated activities call activity.Activity.__init__(self, handle) + and then self.set_canvas(), self.set_toolbar_box(), self.show_all(). + This stub captures the canvas and toolbar widgets so they can be + reparented into the AOD preview area. + + The stub provides: + - metadata: _PreviewMetadata for ActivityToolbarButton/StopButton + - set_canvas/set_toolbar_box: capture widgets instead of using them + - show_all/show: no-op (the preview area handles visibility) + - add_stop_button/close: no-op + - connect: fake signal registration for 'shared', 'joined', 'closing' + - get_bundle_path: returns the project directory for icon loading + """ + + __gsignals__ = { + 'shared': (GObject.SignalFlags.RUN_FIRST, None, ([])), + 'joined': (GObject.SignalFlags.RUN_FIRST, None, ([])), + 'closing': (GObject.SignalFlags.RUN_FIRST, None, ([])), + } + + def __init__(self, handle=None, bundle_path=''): + Gtk.Window.__init__(self) + self._handle = handle + self._bundle_path = bundle_path + self._canvas = None + self._toolbar_box = None + self._stop_buttons = [] + self.metadata = _PreviewMetadata('Preview') + self._stop_callback = None + self.max_participants = 1 + self.shared_activity = None + self._title = self.metadata['title'] + + def set_canvas(self, canvas): + self._canvas = canvas + + def get_canvas(self): + return self._canvas + + def set_toolbar_box(self, toolbar_box): + self._toolbar_box = toolbar_box + + def get_toolbar_box(self): + return self._toolbar_box + + def show_all(self): + pass + + def show(self): + pass + + def add_stop_button(self, button): + self._stop_buttons.append(button) + + def close(self): + if self._stop_callback is not None: + self._stop_callback() + self.emit('closing') + + def set_stop_callback(self, callback): + self._stop_callback = callback + + def get_bundle_path(self): + return self._bundle_path + + def get_bundle_id(self): + return _read_bundle_id(self._bundle_path) + + def get_id(self): + return 'aod-preview' + + def save(self): + pass + + def share(self): + pass + + def get_shared_activity(self): + return self.shared_activity + + def set_title(self, title): + self._title = title + self.metadata['title'] = title + + def get_title(self): + return self._title + + +def render_activity_preview(project_path, activity_name=''): + """Import and instantiate a generated activity for preview. + + Reads activity.py from project_path, replaces the sugar3 Activity + base class with PreviewActivity, execs the modified source, and + returns a PreviewActivity instance with the real canvas and toolbar + widgets ready for embedding. + + Returns (preview_activity, canvas_widget, toolbar_widget) or + (None, error_message, None) if instantiation fails. + """ + source_path = os.path.join(project_path, 'activity.py') + if not os.path.isfile(source_path): + return None, 'activity.py not found in %s' % project_path, None + + try: + with open(source_path, encoding='utf-8') as f: + source = f.read() + except OSError as error: + return None, 'Could not read activity.py: %s' % error, None + + patched_source = _patch_source(source) + + bundle_path = project_path + _install_bundle_path_helper(bundle_path) + + preview = PreviewActivity(bundle_path=bundle_path) + preview.metadata = _PreviewMetadata(activity_name or 'Preview') + + namespace = { + '__name__': 'aod_preview_module', + '__file__': source_path, + 'PreviewActivity': PreviewActivity, + '_preview_instance': preview, + } + + try: + exec(compile(patched_source, source_path, 'exec'), namespace) + except SyntaxError as error: + return None, 'Syntax error in activity.py line %s: %s' % ( + error.lineno, error.msg), None + except Exception as error: + logging.exception('Preview instantiation failed') + return None, 'Could not load activity: %s' % error, None + + activity_class = namespace.get('GeneratedActivity') + if activity_class is None: + return None, 'GeneratedActivity class not found in source', None + + try: + instance = activity_class(handle=None) + except Exception as error: + logging.exception('Preview __init__ failed') + return None, 'Activity initialization failed: %s' % error, None + + if not isinstance(instance, PreviewActivity): + return None, 'Activity did not inherit from PreviewActivity', None + + canvas = instance.get_canvas() + toolbar = instance.get_toolbar_box() + + if canvas is None: + return None, 'Activity did not call set_canvas()', None + + return instance, canvas, toolbar + + +def _patch_source(source): + """Replace activity.Activity with PreviewActivity in source code. + + The generated source does: + from sugar3.activity import activity + class GeneratedActivity(activity.Activity): + def __init__(self, handle): + activity.Activity.__init__(self, handle) + + We replace the base class references so the generated class inherits + from PreviewActivity instead of the real Activity, which requires + D-Bus and the Sugar shell. + """ + patched = source + + patched = patched.replace( + 'activity.Activity.__init__', + 'PreviewActivity.__init__', + ) + + patched = re.sub( + r'class\s+GeneratedActivity\s*\(\s*activity\.Activity\s*\)\s*:', + 'class GeneratedActivity(PreviewActivity):', + patched, + ) + + return patched + + +_bundle_path_override = [''] + + +def _install_bundle_path_helper(bundle_path): + """Make sugar3.activity.activity.get_bundle_path() return the project dir. + + ActivityToolbarButton calls get_bundle_path() to load the activity + icon. We temporarily monkey-patch it to return the generated + project directory so the icon loads without a real bundle. + """ + _bundle_path_override[0] = bundle_path + + try: + from sugar3.activity import activity as sugar_activity + if not hasattr(sugar_activity, '_original_get_bundle_path'): + sugar_activity._original_get_bundle_path = \ + sugar_activity.get_bundle_path + sugar_activity.get_bundle_path = _get_bundle_path + except ImportError: + pass + + +def _get_bundle_path(): + return _bundle_path_override[0] or '.' + + +def _read_bundle_id(bundle_path): + info_path = os.path.join(bundle_path or '', 'activity', 'activity.info') + try: + with open(info_path, encoding='utf-8') as info_file: + for line in info_file: + line = line.strip() + if line.startswith('bundle_id'): + unused_key, value = line.split('=', 1) + return value.strip() + except (OSError, ValueError): + pass + return os.environ.get('SUGAR_BUNDLE_ID', 'org.sugarlabs.aod.preview') + + +def _restore_bundle_path(): + try: + from sugar3.activity import activity as sugar_activity + if hasattr(sugar_activity, '_original_get_bundle_path'): + sugar_activity.get_bundle_path = \ + sugar_activity._original_get_bundle_path + except ImportError: + pass diff --git a/tests/test_aodpipeline.py b/tests/test_aodpipeline.py index 281486bf6c..657361131b 100644 --- a/tests/test_aodpipeline.py +++ b/tests/test_aodpipeline.py @@ -65,13 +65,33 @@ def __init__(self, source): self.codegen_calls = 0 def generate_activity_source(self, system_prompt, user_prompt, - timeout=90): + timeout=90, stream_callback=None): self.codegen_calls += 1 if 'complete Python source for activity.py' not in system_prompt: raise AssertionError('Missing code generation instructions') return self.source +class _StreamingCodegenProvider(_FakeProvider): + """Provider that emits the source as several streamed chunks.""" + + def __init__(self, source, chunk_size=200): + self.source = source + self.chunk_size = chunk_size + self.codegen_calls = 0 + self.observed_partials = [] + + def generate_activity_source(self, system_prompt, user_prompt, + timeout=90, stream_callback=None): + self.codegen_calls += 1 + accumulated = '' + for index in range(0, len(self.source), self.chunk_size): + accumulated += self.source[index:index + self.chunk_size] + if stream_callback is not None: + stream_callback(accumulated) + return self.source + + class _RetryCodegenProvider(_FakeProvider): def __init__(self, source): @@ -263,6 +283,31 @@ def test_provider_codegen_failure_fails_without_template_fallback(self): str(raised.exception)) self.assertIn('codegen offline for test', str(raised.exception)) + def test_provider_codegen_streams_partial_source_to_progress_cb(self): + source = _valid_activity_source(self.spec) + provider = _StreamingCodegenProvider(source, chunk_size=400) + drafts = [] + + def progress_cb(stage, fraction, message, metadata=None): + if isinstance(metadata, dict) and \ + metadata.get('codegen_streaming'): + drafts.append(metadata.get('draft_activity_source', '')) + + generate_activity( + self.spec, + self.output_root, + provider=provider, + progress_cb=progress_cb, + ) + + # At least one streamed draft should reach the progress callback, + # and the latest streamed text should be a prefix of the final + # accepted source (so the UI shows real partial code). + self.assertGreaterEqual(len(drafts), 1) + for draft in drafts: + self.assertTrue(source.startswith(draft)) + self.assertTrue(len(drafts[-1]) > 0) + def test_template_fallback_recovers_when_codegen_fails(self): result = generate_activity( self.spec, From b922562c2cd918bc1f9700c14ba55310351a4004 Mon Sep 17 00:00:00 2001 From: Ashutoshx7 Date: Wed, 24 Jun 2026 05:52:13 +0530 Subject: [PATCH 06/28] Stream AOD generation and harden previews --- src/jarabe/desktop/homebox.py | 58 +++++----- src/jarabe/model/aodcodegen.py | 8 +- src/jarabe/model/aodllm.py | 189 ++++++++++++++++++++++++-------- src/jarabe/model/aodpipeline.py | 172 +++++++++-------------------- src/jarabe/model/aodpreview.py | 124 ++++++++++++++++++++- src/jarabe/model/aodservice.py | 2 +- tests/test_aodllm.py | 11 +- 7 files changed, 358 insertions(+), 206 deletions(-) diff --git a/src/jarabe/desktop/homebox.py b/src/jarabe/desktop/homebox.py index 48b7f0d324..492b6d6d3d 100644 --- a/src/jarabe/desktop/homebox.py +++ b/src/jarabe/desktop/homebox.py @@ -1420,7 +1420,7 @@ def _show_generation_activity_preview(self, message=None): 'system-search'), (_('Write'), _('Writing the Python code for your activity'), 'accessories-text-editor'), - (_('Check'), _('Validating code safety and Sugar patterns'), + (_('Check'), _('Preparing project files and previewing'), 'dialog-apply'), (_('Ready'), _('Packaging your installable activity bundle'), 'package-x-generic'), @@ -1779,8 +1779,8 @@ def _get_live_generation_review_file_data(self, file_key): } activity_summary = _( - 'Live generation status. This will be replaced by the ' - 'model-generated source as soon as it passes validation.') + 'Live generation status. The model-generated source will ' + 'appear here when the code is ready.') activity_meta = common_meta activity_code = ( '# activity.py is being generated by %(provider)s\n' @@ -1790,11 +1790,7 @@ def _get_live_generation_review_file_data(self, file_key): '# Prompt: %(prompt)s\n' '\n' '# Sugar will replace this temporary scaffold with the\n' - '# generated source after these checks pass:\n' - '# - Sugar Activity subclass\n' - '# - toolbar and canvas lifecycle\n' - '# - Journal persistence\n' - '# - prompt-specific learner interaction\n' + '# generated source once the model finishes writing it.\n' '\n' 'from sugar3.activity import activity\n' '\n' @@ -1810,21 +1806,21 @@ def _get_live_generation_review_file_data(self, file_key): if draft_source: if stage == 'failed': activity_summary = _( - 'The model wrote this activity.py but it did not pass ' - 'Sugar validation. The reasons are shown in the preview ' - 'panel and the chat. You can read the draft here, then ' - 'try a smaller prompt or a different model.') + 'The model wrote this activity.py but generation ' + 'encountered an error. The reasons are shown in the ' + 'preview panel and the chat. You can read the draft ' + 'here, then try a smaller prompt or a different model.') activity_meta = _( - 'Draft source %(provider)s - validation failed' + 'Draft source %(provider)s - generation failed' ) % { 'provider': provider, } else: activity_summary = _( 'Draft activity.py returned by the model. Sugar is ' - 'checking it before previewing or installing.') + 'assembling the project.') activity_meta = _( - 'Draft source %(provider)s - validating - %(progress)d%%' + 'Draft source %(provider)s - assembling - %(progress)d%%' ) % { 'provider': provider, 'progress': progress, @@ -1841,7 +1837,7 @@ def _get_live_generation_review_file_data(self, file_key): }, 'plan_json': { 'title': _('aod_plan.json'), - 'summary': _('Live generation plan and validation status.'), + 'summary': _('Live generation plan and progress status.'), 'meta': common_meta, 'language': 'json', 'code': json.dumps(plan_data, indent=2, @@ -1850,7 +1846,7 @@ def _get_live_generation_review_file_data(self, file_key): 'readme': { 'title': _('README.md'), 'summary': _('The generated README will describe classroom ' - 'use after validation.'), + 'use after generation completes.'), 'meta': common_meta, 'language': 'markdown', 'code': _( @@ -1874,12 +1870,12 @@ def _get_live_generation_review_file_data(self, file_key): 'generated activity.'), 'meta': common_meta, 'language': 'text', - 'code': _('License file will be written after validation.\n'), + 'code': _('License file will be written after generation.\n'), }, 'activity_dir': { 'title': _('activity/'), 'summary': _('Sugar bundle metadata will be assembled after ' - 'the generated code passes validation.'), + 'the code is generated.'), 'meta': common_meta, 'language': 'tree', 'code': _('activity/\n activity.info\n activity.svg\n'), @@ -1887,11 +1883,11 @@ def _get_live_generation_review_file_data(self, file_key): 'setup_py': { 'title': _('setup.py'), 'summary': _('Packaging script will be written after ' - 'validation.'), + 'generation.'), 'meta': common_meta, 'language': 'python', 'code': _('from sugar3.activity import bundlebuilder\n\n' - '# written after activity.py validates\n'), + '# written after activity.py is generated\n'), }, } return files[file_key] @@ -1923,7 +1919,7 @@ def _get_generated_review_file_data(self, file_key): ), 'plan_json': ( _('aod_plan.json'), - _('Records the normalized plan, provider, and validation.'), + _('Records the normalized plan and provider details.'), 'json', ), 'setup_py': ( @@ -4634,8 +4630,7 @@ def _update_preview_license_summary(self): license_info = self._get_selected_license() self._preview_empty_note.set_text( - _('Generation uses %s, RAG examples, the configured model, and ' - 'validation before packaging.') + _('Generation uses %s, RAG examples, and the configured model.') % license_info['label']) def _start_generation_animation(self, message=None): @@ -4759,7 +4754,7 @@ def _render_generated_activity_preview(self, result): footer = Gtk.HBox(spacing=style.zoom(6)) box.pack_start(footer, False, False, 0) - for label in [_('Journal ready'), _('Project validated'), + for label in [_('Journal ready'), _('Project ready'), _('XO on install/export')]: pill = Gtk.Label(label) pill.get_style_context().add_class('create-ai-generated-pill') @@ -4783,8 +4778,9 @@ def _render_live_generated_activity_preview(self, result): return False if preview is None or not isinstance(canvas, Gtk.Widget): - logging.warning( - 'Live generated activity preview unavailable: %s', canvas) + logging.error( + 'Live preview failed for %s: %s', + project_path, canvas) return False self._clear_activity_preview() @@ -6918,12 +6914,12 @@ def _update_provider_call_status(self, stage, fraction, message): ) elif stage == 'validating': self._provider_status_label.set_text( - _('Model response received. Sugar is validating the activity ' + _('Model response received. Sugar is assembling the activity ' '- %(percent)d%%') % {'percent': percent} ) elif stage == 'packaging': self._provider_status_label.set_text( - _('Validation passed. Sugar is packaging the XO - ' + _('Activity generated. Sugar is packaging the XO - ' '%(percent)d%%') % {'percent': percent} ) @@ -6965,13 +6961,13 @@ def _generation_finished_cb(self, result): self._refresh_version_history() self._append_chat_status(provider_status) self._append_chat_status( - _('Generated and validated. Select a part or type a refinement.')) + _('Generated. Select a part or type a refinement.')) if self._chat_entry is not None: self._chat_entry.set_placeholder_text( _('Ask for a refinement...')) self._append_sidebar_status(provider_status) self._append_sidebar_message( - _('Generated and validated. Type another prompt here to refine ' + _('Generated. Type another prompt here to refine ' 'this activity.')) return False diff --git a/src/jarabe/model/aodcodegen.py b/src/jarabe/model/aodcodegen.py index 891e41cf6c..91b153de8c 100644 --- a/src/jarabe/model/aodcodegen.py +++ b/src/jarabe/model/aodcodegen.py @@ -123,10 +123,10 @@ def build_codegen_system_prompt(spec, plan, references=()): 'undo, etc.) via Gtk.AccelGroup or key-press-event when relevant.\n' '- No TODO, no "placeholder", no "Add your code here", no demo ' 'strings. Every label and action is final classroom-ready text.\n' - '- Length: rich activities are typically 400–900 lines of ' + '- Length: rich activities are typically 200–400 lines of ' 'real working code. Do NOT pad with dead helpers, but do NOT ' - 'stop at a 150-line MVP either. If the request is genuinely ' - 'simple (single timer, single counter), 250 lines is fine — ' + 'stop at a 50-line MVP either. If the request is genuinely ' + 'simple (single timer, single counter), 120 lines is fine — ' 'but most requests warrant 500+.\n' '\n## Examples of richness expected per request type\n' '- Drawing: tool palette (pen/eraser), color picker (≥6 colors), ' @@ -297,7 +297,7 @@ def _strip_code_fence(source): def _format_references(references): blocks = [] - for index, document in enumerate(references[:3], 1): + for index, document in enumerate(references[:1], 1): text = ' '.join(getattr(document, 'text', '').split()) title = getattr(document, 'title', 'Reference') blocks.append( diff --git a/src/jarabe/model/aodllm.py b/src/jarabe/model/aodllm.py index d4e04e8498..341fffa36b 100644 --- a/src/jarabe/model/aodllm.py +++ b/src/jarabe/model/aodllm.py @@ -17,11 +17,21 @@ class ProviderError(Exception): pass -_CODEGEN_MAX_TOKENS = 16000 +def _env_int(name, default): + try: + return int(os.environ.get(name, str(default))) + except ValueError: + return default + + +_CODEGEN_MAX_TOKENS = _env_int('AOD_CODEGEN_MAX_TOKENS', 16000) _FREEMODEL_CODEGEN_MAX_OUTPUT_TOKENS = 16000 -_PROVIDER_PLAN_TIMEOUT = 120 -_PROVIDER_CODEGEN_TIMEOUT = 360 -_GEMINI_CODEGEN_MAX_OUTPUT_TOKENS = 20000 +_PROVIDER_PLAN_TIMEOUT = _env_int('AOD_PROVIDER_PLAN_TIMEOUT', 120) +_PROVIDER_CODEGEN_TIMEOUT = _env_int('AOD_PROVIDER_CODEGEN_TIMEOUT', 180) +_GEMINI_CODEGEN_MAX_OUTPUT_TOKENS = _env_int( + 'AOD_GEMINI_CODEGEN_MAX_OUTPUT_TOKENS', + 9000, +) # Kimi K2.x on OpenRouter is a reasoning model with reasoning enabled by # default (reasoning.default_enabled=true, mandatory=false). Reasoning @@ -32,7 +42,8 @@ class ProviderError(Exception): # larger completion budget so bounded reasoning plus a complete Sugar # activity.py both fit. See OpenAICompatibleProvider._is_reasoning_codegen. _OPENROUTER_REASONING_CODEGEN_MAX_TOKENS = 32000 -_OPENROUTER_REASONING_CODEGEN_EFFORT = 'low' +_OPENROUTER_FAST_CODEGEN_MAX_TOKENS = 9000 +_OPENROUTER_REASONING_CODEGEN_EFFORT = 'minimal' # Special effort values that map to disabling reasoning entirely. These are # only safe for non-mandatory reasoning models; mandatory reasoning models # reject disable, so if we can't tell, we fall back to a minimal effort. @@ -40,13 +51,6 @@ class ProviderError(Exception): _REASONING_SAFE_MINIMAL_EFFORT = 'minimal' -def _env_int(name, default): - try: - return int(os.environ.get(name, str(default))) - except ValueError: - return default - - class LLMProvider: name = 'provider' model = '' @@ -129,33 +133,33 @@ def generate_plan(self, system_prompt, user_prompt, def generate_text(self, system_prompt, user_prompt, timeout=_PROVIDER_CODEGEN_TIMEOUT, stream_callback=None): - text = self._generate_content( + if stream_callback is not None: + return self._stream_content( + system_prompt, user_prompt, timeout, + max_output_tokens=_GEMINI_CODEGEN_MAX_OUTPUT_TOKENS, + stream_callback=stream_callback, + ) + return self._generate_content( system_prompt, user_prompt, timeout, max_output_tokens=_GEMINI_CODEGEN_MAX_OUTPUT_TOKENS, response_json=False, ) - if stream_callback is not None: - try: - stream_callback(text) - except Exception: - pass - return text def generate_activity_source(self, system_prompt, user_prompt, timeout=_PROVIDER_CODEGEN_TIMEOUT, stream_callback=None): - text = self._generate_content( - system_prompt, - user_prompt, - timeout, - max_output_tokens=_GEMINI_CODEGEN_MAX_OUTPUT_TOKENS, - response_json=False, - ) if stream_callback is not None: - try: - stream_callback(text) - except Exception: - pass + text = self._stream_content( + system_prompt, user_prompt, timeout, + max_output_tokens=_GEMINI_CODEGEN_MAX_OUTPUT_TOKENS, + stream_callback=stream_callback, + ) + else: + text = self._generate_content( + system_prompt, user_prompt, timeout, + max_output_tokens=_GEMINI_CODEGEN_MAX_OUTPUT_TOKENS, + response_json=False, + ) return extract_activity_source_from_response(text) def _generate_json(self, system_prompt, user_prompt, timeout, @@ -230,6 +234,95 @@ def _generate_content(self, system_prompt, user_prompt, timeout, ) return text + def _stream_content(self, system_prompt, user_prompt, timeout, + max_output_tokens=None, stream_callback=None): + """Call Gemini's streaming endpoint and feed tokens to stream_callback. + + Uses :streamGenerateContent?alt=sse instead of :generateContent + so tokens arrive as they're generated rather than all at once. + """ + model = urllib.parse.quote(self.model, safe='') + key = urllib.parse.quote(self._api_key, safe='') + url = '%s/%s:streamGenerateContent?alt=sse&key=%s' % ( + self._endpoint.rstrip('/'), + model, + key, + ) + generation_config = { + 'temperature': 0.3, + } + if max_output_tokens is not None: + generation_config['maxOutputTokens'] = max_output_tokens + payload = { + 'systemInstruction': { + 'parts': [{'text': system_prompt}], + }, + 'contents': [{ + 'role': 'user', + 'parts': [{'text': user_prompt}], + }], + 'generationConfig': generation_config, + 'safetySettings': self._SAFETY_SETTINGS, + } + request = urllib.request.Request( + url, + data=json.dumps(payload).encode('utf-8'), + headers={'Content-Type': 'application/json'}, + method='POST', + ) + accumulated = '' + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + buf = '' + while True: + chunk = response.read(4096) + if not chunk: + break + buf += chunk.decode('utf-8', errors='replace') + while '\n' in buf: + line, buf = buf.split('\n', 1) + line = line.rstrip('\r') + if not line or not line.startswith('data:'): + continue + data = line[5:].strip() + if data == '[DONE]': + break + try: + event = json.loads(data) + except (json.JSONDecodeError, ValueError): + continue + try: + candidate = event['candidates'][0] + parts = candidate['content']['parts'] + # Gemini sends the full accumulated text in each + # chunk, not just the delta — use it directly. + text = ''.join( + part.get('text', '') for part in parts + ) + except (KeyError, IndexError): + continue + if not text: + continue + accumulated = text + if stream_callback is not None: + try: + stream_callback(accumulated) + except Exception: + pass + else: + continue + break + except urllib.error.HTTPError as error: + detail = error.read().decode('utf-8', errors='replace')[:500] + raise ProviderError( + 'Gemini streaming request failed with HTTP %d: %s' + % (error.code, detail) + ) + except (OSError, ValueError) as error: + raise ProviderError('Gemini streaming request failed: %s' % error) + + return accumulated + class OpenAIProvider(LLMProvider): name = 'openai' @@ -520,6 +613,27 @@ def _request_headers(self): def _extra_generation_params(self, max_tokens, json_response): params = {} + # For codegen (non-JSON) calls on OpenRouter, set minimal reasoning. + # OpenRouter defaults to "medium" thinking for many models including + # gemini-3.5-flash, which adds a long delay before the first useful + # activity.py token. Minimal reasoning favors fast, visible codegen. + if not json_response and self._provider_name == 'openrouter': + effort = os.environ.get( + 'AOD_OPENROUTER_CODEGEN_REASONING_EFFORT', + _OPENROUTER_REASONING_CODEGEN_EFFORT, + ) + if effort: + effort = effort.strip() + if effort.lower() in _REASONING_DISABLE_VALUES: + effort = _REASONING_SAFE_MINIMAL_EFFORT + params['reasoning'] = {'effort': effort} + if not self._is_reasoning_codegen(json_response): + params['max_tokens'] = _env_int( + 'AOD_OPENROUTER_FAST_CODEGEN_MAX_TOKENS', + _OPENROUTER_FAST_CODEGEN_MAX_TOKENS, + ) + return params + # The activity-codegen call uses json_response=False with a # bounded max_tokens. Plan calls (json_response=True) leave # max_tokens uncapped, so reasoning has room and we do not need @@ -534,21 +648,6 @@ def _extra_generation_params(self, max_tokens, json_response): _OPENROUTER_REASONING_CODEGEN_MAX_TOKENS), ) params['max_tokens'] = budget - - effort = os.environ.get( - 'AOD_OPENROUTER_CODEGEN_REASONING_EFFORT', - _OPENROUTER_REASONING_CODEGEN_EFFORT, - ) - if effort: - effort = effort.strip() - if effort.lower() in _REASONING_DISABLE_VALUES: - # Disabling reasoning is only valid for non-mandatory - # models; we don't fetch the catalog at runtime, so when - # the user asks to disable we degrade to the smallest - # accepted effort rather than risk an API rejection on a - # mandatory-reasoning model. - effort = _REASONING_SAFE_MINIMAL_EFFORT - params['reasoning'] = {'effort': effort} return params def _is_reasoning_codegen(self, json_response): diff --git a/src/jarabe/model/aodpipeline.py b/src/jarabe/model/aodpipeline.py index 9b874f1268..5f3327da1c 100644 --- a/src/jarabe/model/aodpipeline.py +++ b/src/jarabe/model/aodpipeline.py @@ -26,9 +26,6 @@ from jarabe.model.aodrefine import build_refine_user_prompt from jarabe.model.aodrefine import parse_search_replace from jarabe.model.aodrefine import apply_patches -from jarabe.model.aodvalidator import validate_bundle -from jarabe.model.aodvalidator import validate_activity_source_for_request -from jarabe.model.aodvalidator import validate_project class PipelineError(Exception): @@ -52,7 +49,7 @@ def generate_activity(spec, output_root=None, provider=None, provider_name='default', use_rag=True, progress_cb=None, pace=False, package_bundle=True, template_fallback=False): - """Run prompt grounding, provider planning, generation, and validation. + """Run prompt grounding, provider planning, and generation. When template_fallback is True and the provider fails to deliver valid activity code, the pipeline renders activity.py from the local template @@ -225,14 +222,8 @@ def generate_activity(spec, output_root=None, provider=None, result.provider = provider_used result.model = model_used - progress.report('validating', 0.78, - 'Checking Python source and activity metadata') - project_report = validate_project(result.project_path) - if project_report.errors: - raise PipelineError('\n'.join(project_report.errors)) - result.plan['validation'] = { - 'project_warnings': project_report.warnings, - } + progress.report('assembling', 0.78, + 'Assembling the activity project') plan_path = os.path.join(result.project_path, 'aod_plan.json') with open(plan_path, 'w', encoding='utf-8') as plan_file: json.dump(result.plan, plan_file, indent=2, sort_keys=True) @@ -242,25 +233,17 @@ def generate_activity(spec, output_root=None, provider=None, progress.report('packaging', 0.88, 'Packaging the XO bundle') package_generation_result(result) - progress.report('validating', 0.94, - 'Validating the packaged XO') progress.report('ready', 1.0, 'Activity project is ready') return result def package_generation_result(result): - """Build and validate the XO bundle for an already generated project.""" + """Build the XO bundle for an already generated project.""" if result.bundle_path and os.path.isfile(result.bundle_path): return result.bundle_path result.bundle_path = package_project(result.project_path) - bundle_report = validate_bundle(result.bundle_path) - if bundle_report.errors: - raise PipelineError('\n'.join(bundle_report.errors)) - - validation = result.plan.setdefault('validation', {}) - validation['bundle_warnings'] = bundle_report.warnings plan_path = os.path.join(result.project_path, 'aod_plan.json') with open(plan_path, 'w', encoding='utf-8') as plan_file: json.dump(result.plan, plan_file, indent=2, sort_keys=True) @@ -297,6 +280,14 @@ def report(self, stage, fraction, message, metadata=None): time.sleep(0.09) _progress(self._callback, stage, fraction, message) + def report_immediate(self, stage, fraction, message, metadata=None): + """Report progress without the pacing sleep. + + Used for streaming token updates where blocking the response + thread would slow down how fast new tokens arrive. + """ + _progress(self._callback, stage, fraction, message, metadata) + def _redact_provider_error(error, provider): message = str(error) @@ -327,7 +318,7 @@ def report_partial(partial_text): if now - state['last_emit'] < _STREAM_REPORT_INTERVAL_SECONDS: return state['last_emit'] = now - progress.report( + progress.report_immediate( 'generating', 0.58, 'Streaming activity.py from the model ' @@ -348,82 +339,44 @@ def _generate_activity_source_with_provider(provider, spec, plan, references, if not callable(generate_source): return None, '', 0 - validation_feedback = '' - last_error = '' - for attempt in range(1, _CODEGEN_ATTEMPT_LIMIT + 1): - if attempt == 1: - progress.report('generating', 0.56, - 'Asking the model to write activity.py') - else: - progress.report( - 'generating', 0.56, - 'Asking the model to repair activity.py ' - '(attempt %d of %d)' % (attempt, _CODEGEN_ATTEMPT_LIMIT), - ) + progress.report('generating', 0.56, + 'Asking the model to write activity.py') - attempt_number = attempt - stream_callback = _make_codegen_stream_callback( - progress, attempt_number) - system_prompt = build_codegen_system_prompt(spec, plan, references) - user_prompt = build_codegen_user_prompt( - spec, plan, validation_feedback) + stream_callback = _make_codegen_stream_callback(progress, 1) + system_prompt = build_codegen_system_prompt(spec, plan, references) + user_prompt = build_codegen_user_prompt(spec, plan) + try: try: - try: - source = generate_source( - system_prompt, - user_prompt, - stream_callback=stream_callback, - ) - except TypeError: - # Older providers / test doubles may not accept - # stream_callback; fall back to the non-streaming call. - source = generate_source(system_prompt, user_prompt) - except ProviderError as error: - return None, _redact_provider_error(error, provider), attempt - except ValueError as error: - last_error = str(error) - validation_feedback = last_error - progress.report( - 'generating', 0.56, - 'Attempt %d did not produce valid code; retrying' - % attempt, + source = generate_source( + system_prompt, + user_prompt, + stream_callback=stream_callback, ) - continue - - progress.report( - 'validating', - 0.64, - 'Model returned activity.py; validating the draft', - { - 'draft_activity_source': source, - 'codegen_attempt': attempt, - }, - ) - report = validate_activity_source_for_request(source, spec, plan) - if report.valid: - if report.warnings: - plan.setdefault('codegen_warnings', []).extend( - report.warnings[:4] - ) - return source, '', attempt - - validation_feedback = _format_codegen_feedback(report) - last_error = ( - 'Provider generated code did not pass validation: %s' - % validation_feedback - ) - - return None, last_error, _CODEGEN_ATTEMPT_LIMIT + except TypeError: + # Older providers / test doubles may not accept + # stream_callback; fall back to the non-streaming call. + source = generate_source(system_prompt, user_prompt) + except ProviderError as error: + return None, _redact_provider_error(error, provider), 1 + except ValueError as error: + return None, str(error), 1 + + progress.report( + 'generating', + 0.64, + 'Model returned activity.py', + { + 'draft_activity_source': source, + 'codegen_attempt': 1, + }, + ) + return source, '', 1 -def _format_codegen_feedback(report): - items = list(report.errors[:8]) - if report.warnings: - items.extend( - 'Warning: %s' % warning - for warning in report.warnings[:3] - ) - return '\n'.join(items) +def _has_refinement(provider): + """Check if a provider supports the raw text generation needed for refinement.""" + generate_text = getattr(provider, 'generate_text', None) + return callable(generate_text) def refine_activity(spec, current_source, current_plan, output_root, @@ -433,9 +386,9 @@ def refine_activity(spec, current_source, current_plan, output_root, """Refine an existing activity.py using SEARCH/REPLACE blocks. Tries the cheap SEARCH/REPLACE path first (~1k output tokens). If - the model requests FULLREGEN, or any patch fails to match, or the - patched code fails validation, falls back to full regeneration - (skipping the planner call by reusing current_plan). + the model requests FULLREGEN, or any patch fails to match, falls + back to full regeneration (skipping the planner call by reusing + current_plan). Returns a GenerationResult like generate_activity(). """ @@ -515,7 +468,7 @@ def refine_activity(spec, current_source, current_plan, output_root, 'Model requested full regeneration') else: progress.report( - 'validating', 0.55, + 'generating', 0.55, 'Applying %d targeted edits' % len(patches), ) patched, applied, failed = apply_patches( @@ -530,19 +483,10 @@ def refine_activity(spec, current_source, current_plan, output_root, refine_method = 'full_regen' patched_source = None else: - report = validate_activity_source_for_request( - patched, spec, current_plan) - if report.valid: - patched_source = patched - progress.report( - 'validating', 0.70, - 'Edits applied and validated') - else: - progress.report( - 'generating', 0.50, - 'Patched code failed validation; ' - 'falling back to full regeneration') - refine_method = 'full_regen' + patched_source = patched + progress.report( + 'generating', 0.70, + 'Edits applied successfully') if patched_source is None: progress.report('generating', 0.55, @@ -580,14 +524,6 @@ def refine_activity(spec, current_source, current_plan, output_root, result.provider = selected_provider.name result.model = selected_provider.model - progress.report('validating', 0.90, - 'Checking the refined source') - project_report = validate_project(result.project_path) - if project_report.errors: - raise PipelineError('\n'.join(project_report.errors)) - result.plan['validation'] = { - 'project_warnings': project_report.warnings, - } plan_path = os.path.join(result.project_path, 'aod_plan.json') with open(plan_path, 'w', encoding='utf-8') as plan_file: json.dump(result.plan, plan_file, indent=2, sort_keys=True) diff --git a/src/jarabe/model/aodpreview.py b/src/jarabe/model/aodpreview.py index df2b434b12..62bab7fa41 100644 --- a/src/jarabe/model/aodpreview.py +++ b/src/jarabe/model/aodpreview.py @@ -16,6 +16,7 @@ import logging import os import re +import tempfile import gi gi.require_version('Gtk', '3.0') @@ -102,6 +103,7 @@ def __init__(self, handle=None, bundle_path=''): self.max_participants = 1 self.shared_activity = None self._title = self.metadata['title'] + self._activity_root = '' def set_canvas(self, canvas): self._canvas = canvas @@ -157,6 +159,45 @@ def set_title(self, title): def get_title(self): return self._title + def get_activity_root(self): + if not self._activity_root: + self._activity_root = tempfile.mkdtemp( + prefix='aod-preview-root-') + for subdir in ('data', 'instance', 'tmp'): + os.makedirs( + os.path.join(self._activity_root, subdir), + exist_ok=True, + ) + return self._activity_root + + def get_documents_path(self): + return tempfile.gettempdir() + + def write_file(self, file_path): + pass + + def read_file(self, file_path): + pass + + def copy(self): + pass + + def get_xo_color(self): + try: + from sugar3.graphics.xocolor import XoColor + return XoColor() + except Exception: + return None + + def busy(self): + pass + + def unbusy(self): + pass + + def get_preferred_size(self): + return 1200, 900 + def render_activity_preview(project_path, activity_name=''): """Import and instantiate a generated activity for preview. @@ -184,6 +225,29 @@ def render_activity_preview(project_path, activity_name=''): bundle_path = project_path _install_bundle_path_helper(bundle_path) + result = _try_exec_preview(patched_source, source_path, + bundle_path, activity_name) + if result[0] is not None: + return result + + first_error = result[1] + logging.warning('First preview attempt failed: %s', first_error) + + # Second attempt: add import-error resilience and stub missing + # modules that some LLM-generated code references. + hardened_source = _harden_imports(patched_source) + result = _try_exec_preview(hardened_source, source_path, + bundle_path, activity_name) + if result[0] is not None: + return result + + logging.warning('Hardened preview also failed: %s', result[1]) + return None, first_error, None + + +def _try_exec_preview(patched_source, source_path, bundle_path, + activity_name): + """Try to exec the patched source and return a preview tuple.""" preview = PreviewActivity(bundle_path=bundle_path) preview.metadata = _PreviewMetadata(activity_name or 'Preview') @@ -200,7 +264,7 @@ def render_activity_preview(project_path, activity_name=''): return None, 'Syntax error in activity.py line %s: %s' % ( error.lineno, error.msg), None except Exception as error: - logging.exception('Preview instantiation failed') + logging.exception('Preview exec failed') return None, 'Could not load activity: %s' % error, None activity_class = namespace.get('GeneratedActivity') @@ -211,7 +275,7 @@ def render_activity_preview(project_path, activity_name=''): instance = activity_class(handle=None) except Exception as error: logging.exception('Preview __init__ failed') - return None, 'Activity initialization failed: %s' % error, None + return None, 'Activity __init__ failed: %s' % error, None if not isinstance(instance, PreviewActivity): return None, 'Activity did not inherit from PreviewActivity', None @@ -225,6 +289,40 @@ def render_activity_preview(project_path, activity_name=''): return instance, canvas, toolbar +def _harden_imports(source): + """Wrap problematic imports so preview survives missing modules. + + LLM-generated code sometimes imports sugar3 sub-modules that exist + at runtime but fail during in-process exec (e.g. sugar3.datastore, + sugar3.presence). We wrap known-problematic import lines in + try/except so the rest of the activity can still render. + """ + fragile_modules = ( + 'sugar3.datastore', + 'sugar3.presence', + 'sugar3.network', + 'sugar3.profile', + 'sugar3.mime', + 'telepathy', + 'dbus', + ) + lines = source.split('\n') + patched_lines = [] + for line in lines: + stripped = line.lstrip() + if stripped.startswith(('import ', 'from ')): + if any(module in stripped for module in fragile_modules): + indent = line[:len(line) - len(stripped)] + patched_lines.append('%stry:' % indent) + patched_lines.append(' %s' % line) + patched_lines.append('%sexcept (ImportError, Exception):' + % indent) + patched_lines.append('%s pass' % indent) + continue + patched_lines.append(line) + return '\n'.join(patched_lines) + + def _patch_source(source): """Replace activity.Activity with PreviewActivity in source code. @@ -237,20 +335,42 @@ def __init__(self, handle): We replace the base class references so the generated class inherits from PreviewActivity instead of the real Activity, which requires D-Bus and the Sugar shell. + + LLM-generated code may use super().__init__(handle) or + super(GeneratedActivity, self).__init__(handle) instead of the + explicit activity.Activity.__init__ form. The class declaration + patch handles those automatically because super() resolves via MRO + to PreviewActivity once the class inherits from it. """ patched = source + # Explicit old-style super call. patched = patched.replace( 'activity.Activity.__init__', 'PreviewActivity.__init__', ) + # Class declaration: replace base class. patched = re.sub( r'class\s+GeneratedActivity\s*\(\s*activity\.Activity\s*\)\s*:', 'class GeneratedActivity(PreviewActivity):', patched, ) + # Some models use Activity directly without the module prefix. + patched = re.sub( + r'class\s+GeneratedActivity\s*\(\s*Activity\s*\)\s*:', + 'class GeneratedActivity(PreviewActivity):', + patched, + ) + + # Replace direct Activity.__init__ calls (without module prefix). + patched = re.sub( + r'(? Date: Wed, 24 Jun 2026 14:38:19 +0530 Subject: [PATCH 07/28] Tune AOD OpenRouter defaults --- src/jarabe/model/aodllm.py | 4 ++-- src/jarabe/model/aodpipeline.py | 4 ++-- src/jarabe/model/aodpreview.py | 4 +++- tests/test_aodllm.py | 16 ++++++++-------- 4 files changed, 15 insertions(+), 13 deletions(-) diff --git a/src/jarabe/model/aodllm.py b/src/jarabe/model/aodllm.py index 341fffa36b..2d6316cda6 100644 --- a/src/jarabe/model/aodllm.py +++ b/src/jarabe/model/aodllm.py @@ -532,7 +532,7 @@ class OpenAICompatibleProvider(OpenAIProvider): 'key_env': 'OPENROUTER_API_KEY', 'model_env': 'AOD_OPENROUTER_MODEL', 'endpoint_env': 'AOD_OPENROUTER_ENDPOINT', - 'default_model': 'moonshotai/kimi-k2.6', + 'default_model': 'google/gemini-3.5-flash', 'default_endpoint': ( 'https://openrouter.ai/api/v1/chat/completions' ), @@ -1309,7 +1309,7 @@ def _provider_model(name): model_env = { 'gemini': ('AOD_GEMINI_MODEL', 'gemini-2.5-flash'), 'openai': ('AOD_OPENAI_MODEL', 'gpt-4.1-mini'), - 'openrouter': ('AOD_OPENROUTER_MODEL', 'moonshotai/kimi-k2.6'), + 'openrouter': ('AOD_OPENROUTER_MODEL', 'google/gemini-3.5-flash'), 'deepseek': ('AOD_DEEPSEEK_MODEL', 'deepseek-chat'), 'qwen': ('AOD_QWEN_MODEL', 'qwen-turbo'), 'moonshot': ('AOD_MOONSHOT_MODEL', 'moonshot-v1-8k'), diff --git a/src/jarabe/model/aodpipeline.py b/src/jarabe/model/aodpipeline.py index 5f3327da1c..6c73168c81 100644 --- a/src/jarabe/model/aodpipeline.py +++ b/src/jarabe/model/aodpipeline.py @@ -275,9 +275,9 @@ def report(self, stage, fraction, message, metadata=None): if not self._pace: return - end_time = time.time() + 0.45 + end_time = time.time() + 0.15 while time.time() < end_time: - time.sleep(0.09) + time.sleep(0.05) _progress(self._callback, stage, fraction, message) def report_immediate(self, stage, fraction, message, metadata=None): diff --git a/src/jarabe/model/aodpreview.py b/src/jarabe/model/aodpreview.py index 62bab7fa41..d1803fa80f 100644 --- a/src/jarabe/model/aodpreview.py +++ b/src/jarabe/model/aodpreview.py @@ -365,8 +365,10 @@ def __init__(self, handle): ) # Replace direct Activity.__init__ calls (without module prefix). + # Use a letter lookbehind so we do not double-replace + # PreviewActivity.__init__ (which already contains 'Activity'). patched = re.sub( - r'(? Date: Wed, 24 Jun 2026 18:25:47 +0530 Subject: [PATCH 08/28] Use Sonnet for AOD generation testing --- src/jarabe/desktop/homebox.py | 130 ++++++++++++++++++--------------- src/jarabe/model/aodcodegen.py | 4 +- src/jarabe/model/aodllm.py | 22 +++--- src/jarabe/model/aodpreview.py | 38 ++++++++++ start_sugar_xephyr.sh | 14 ++-- tests/test_aodllm.py | 14 ++-- 6 files changed, 138 insertions(+), 84 deletions(-) diff --git a/src/jarabe/desktop/homebox.py b/src/jarabe/desktop/homebox.py index 492b6d6d3d..04cec8e1a1 100644 --- a/src/jarabe/desktop/homebox.py +++ b/src/jarabe/desktop/homebox.py @@ -379,6 +379,10 @@ def __init__(self): self._sidebar_refine_entry = None self._sidebar_refine_status_label = None self._prompt_is_placeholder = False + self._preview_is_fullscreen = False + self._preview_fullscreen_button = None + self._studio_left_panel = None + self._studio_right_panel = None self._generation_result = None self._generation_job_id = None self._generation_job_callback = \ @@ -1032,12 +1036,12 @@ def _create_studio_view(self): studio.pack_start(body, True, True, 0) body.show() - body.pack_start(self._create_studio_left_panel(), - False, False, 0) + self._studio_left_panel = self._create_studio_left_panel() + body.pack_start(self._studio_left_panel, False, False, 0) body.pack_start(self._create_studio_preview_panel(), True, True, 0) - body.pack_start(self._create_learning_sidebar(), - False, False, 0) + self._studio_right_panel = self._create_learning_sidebar() + body.pack_start(self._studio_right_panel, False, False, 0) footer = Gtk.HBox(spacing=style.zoom(8)) footer.get_style_context().add_class('create-ai-studio-footer') @@ -1100,6 +1104,8 @@ def _create_studio_left_panel(self): self._chat_scroll = Gtk.ScrolledWindow() self._chat_scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + self._chat_scroll.set_max_content_height(style.zoom(420)) + self._chat_scroll.set_propagate_natural_height(True) self._chat_scroll.get_style_context().add_class( 'create-ai-chat-scroll') box.pack_start(self._chat_scroll, True, True, 0) @@ -1231,6 +1237,10 @@ def _create_studio_preview_panel(self): top.pack_start(title, True, True, 0) title.show() + self._preview_fullscreen_button = self._create_plain_button( + _('⛶ Fullscreen'), self.__preview_fullscreen_toggle_cb) + top.pack_end(self._preview_fullscreen_button, False, False, 0) + top.pack_end(self._create_plain_button( _('Review and install'), self.__review_and_install_cb), False, False, 0) @@ -1271,7 +1281,14 @@ def _create_studio_preview_panel(self): self._studio_mode_stack.show() preview_page = Gtk.VBox(spacing=style.zoom(9)) - preview_page.pack_start(self._create_preview_frame(), True, True, 0) + preview_scroll = Gtk.ScrolledWindow() + preview_scroll.set_policy(Gtk.PolicyType.NEVER, + Gtk.PolicyType.AUTOMATIC) + preview_scroll.set_propagate_natural_height(True) + preview_scroll.set_max_content_height(style.zoom(680)) + preview_scroll.add(self._create_preview_frame()) + preview_page.pack_start(preview_scroll, True, True, 0) + preview_scroll.show() preview_page.pack_start(self._create_live_edit_panel(), False, False, 0) self._studio_mode_stack.add_named(preview_page, 'preview') @@ -2800,9 +2817,8 @@ def _create_learning_sidebar(self): box.pack_start(subtitle, False, False, 0) subtitle.show() - refinement_card = self._create_sidebar_refinement_card() - box.pack_start(refinement_card, False, False, 0) - refinement_card.show() + + guided = Gtk.EventBox() guided.get_style_context().add_class('create-ai-learning-card') @@ -4710,57 +4726,35 @@ def _render_generated_activity_preview(self, result): return self._clear_activity_preview() - plan = result.plan if isinstance(result.plan, dict) else {} - template = plan.get('template', 'utility') - card = Gtk.EventBox() - card.get_style_context().add_class('create-ai-generated-preview') - card.set_hexpand(True) - card.set_vexpand(True) - card.add_events(Gdk.EventMask.BUTTON_PRESS_MASK) - card.connect('button-press-event', - self.__preview_target_button_press_event_cb, - _('activity canvas')) - self._preview_content_box.pack_start(card, True, True, 0) - - box = Gtk.VBox(spacing=style.zoom(10)) - box.set_border_width(style.zoom(14)) - card.add(box) - - header = Gtk.HBox(spacing=style.zoom(8)) - box.pack_start(header, False, False, 0) - - title = Gtk.Label(result.spec.name) - title.get_style_context().add_class('create-ai-generated-title') - title.set_xalign(0) - title.set_ellipsize(Pango.EllipsizeMode.END) - header.pack_start(title, True, True, 0) - - badge = Gtk.Label(template.capitalize()) - badge.get_style_context().add_class('create-ai-generated-badge') - header.pack_end(badge, False, False, 0) - - summary = Gtk.Label( - plan.get('summary') or plan.get('learner_goal') or - result.spec.prompt) - summary.get_style_context().add_class('create-ai-generated-summary') - summary.set_xalign(0) - summary.set_line_wrap(True) - summary.set_max_width_chars(120) - box.pack_start(summary, False, False, 0) - - body = self._create_generated_preview_body(result, template) - box.pack_start(body, True, True, 0) - - footer = Gtk.HBox(spacing=style.zoom(6)) - box.pack_start(footer, False, False, 0) - for label in [_('Journal ready'), _('Project ready'), - _('XO on install/export')]: - pill = Gtk.Label(label) - pill.get_style_context().add_class('create-ai-generated-pill') - footer.pack_start(pill, False, False, 0) - - card.show_all() + error_box = Gtk.VBox(spacing=style.zoom(12)) + error_box.set_halign(Gtk.Align.CENTER) + error_box.set_valign(Gtk.Align.CENTER) + error_box.set_border_width(style.zoom(30)) + + error_icon = Gtk.Label('⚠') + error_icon.set_markup( + '') + error_box.pack_start(error_icon, False, False, 0) + + error_title = Gtk.Label( + _('Preview could not render this activity')) + error_title.get_style_context().add_class( + 'create-ai-generated-title') + error_box.pack_start(error_title, False, False, 0) + + error_note = Gtk.Label( + _('The generated code has an issue that prevents live ' + 'preview. Switch to the Review tab to see the code, ' + 'or type a refinement to fix it.')) + error_note.get_style_context().add_class('create-ai-meta-note') + error_note.set_line_wrap(True) + error_note.set_max_width_chars(60) + error_note.set_justify(Gtk.Justification.CENTER) + error_box.pack_start(error_note, False, False, 0) + + self._preview_content_box.pack_start(error_box, True, True, 0) + error_box.show_all() def _render_live_generated_activity_preview(self, result): project_path = getattr(result, 'project_path', '') @@ -6875,7 +6869,6 @@ def _update_live_review_generation(self, stage, fraction, message, draft_source if not self._review_draft_was_shown: self._review_draft_was_shown = True - self._select_studio_tab('review') self._set_review_file(self._current_review_file) def _update_provider_call_status(self, stage, fraction, message): @@ -7041,6 +7034,25 @@ def _generation_failed_cb(self, error_text, job=None): def __review_and_install_cb(self, button): self._select_studio_tab('review') + def __preview_fullscreen_toggle_cb(self, button): + self._preview_is_fullscreen = not self._preview_is_fullscreen + if self._preview_is_fullscreen: + if self._studio_left_panel is not None: + self._studio_left_panel.hide() + if self._studio_right_panel is not None: + self._studio_right_panel.hide() + if self._preview_fullscreen_button is not None: + self._preview_fullscreen_button.set_label( + _('⛶ Exit Fullscreen')) + else: + if self._studio_left_panel is not None: + self._studio_left_panel.show() + if self._studio_right_panel is not None: + self._studio_right_panel.show() + if self._preview_fullscreen_button is not None: + self._preview_fullscreen_button.set_label( + _('⛶ Fullscreen')) + def _ensure_generation_bundle(self): if self._generation_result is None: raise ValueError('Generate first') diff --git a/src/jarabe/model/aodcodegen.py b/src/jarabe/model/aodcodegen.py index 91b153de8c..df3dff3a27 100644 --- a/src/jarabe/model/aodcodegen.py +++ b/src/jarabe/model/aodcodegen.py @@ -123,11 +123,11 @@ def build_codegen_system_prompt(spec, plan, references=()): 'undo, etc.) via Gtk.AccelGroup or key-press-event when relevant.\n' '- No TODO, no "placeholder", no "Add your code here", no demo ' 'strings. Every label and action is final classroom-ready text.\n' - '- Length: rich activities are typically 200–400 lines of ' + '- Length: rich activities are typically 400–800 lines of ' 'real working code. Do NOT pad with dead helpers, but do NOT ' 'stop at a 50-line MVP either. If the request is genuinely ' 'simple (single timer, single counter), 120 lines is fine — ' - 'but most requests warrant 500+.\n' + 'but most requests warrant 1000+.\n' '\n## Examples of richness expected per request type\n' '- Drawing: tool palette (pen/eraser), color picker (≥6 colors), ' 'brush-size slider, undo/redo stack, clear-canvas action, save-' diff --git a/src/jarabe/model/aodllm.py b/src/jarabe/model/aodllm.py index 2d6316cda6..dd81220a39 100644 --- a/src/jarabe/model/aodllm.py +++ b/src/jarabe/model/aodllm.py @@ -24,10 +24,10 @@ def _env_int(name, default): return default -_CODEGEN_MAX_TOKENS = _env_int('AOD_CODEGEN_MAX_TOKENS', 16000) +_CODEGEN_MAX_TOKENS = _env_int('AOD_CODEGEN_MAX_TOKENS', 16384) _FREEMODEL_CODEGEN_MAX_OUTPUT_TOKENS = 16000 _PROVIDER_PLAN_TIMEOUT = _env_int('AOD_PROVIDER_PLAN_TIMEOUT', 120) -_PROVIDER_CODEGEN_TIMEOUT = _env_int('AOD_PROVIDER_CODEGEN_TIMEOUT', 180) +_PROVIDER_CODEGEN_TIMEOUT = _env_int('AOD_PROVIDER_CODEGEN_TIMEOUT', 300) _GEMINI_CODEGEN_MAX_OUTPUT_TOKENS = _env_int( 'AOD_GEMINI_CODEGEN_MAX_OUTPUT_TOKENS', 9000, @@ -42,7 +42,7 @@ def _env_int(name, default): # larger completion budget so bounded reasoning plus a complete Sugar # activity.py both fit. See OpenAICompatibleProvider._is_reasoning_codegen. _OPENROUTER_REASONING_CODEGEN_MAX_TOKENS = 32000 -_OPENROUTER_FAST_CODEGEN_MAX_TOKENS = 9000 +_OPENROUTER_FAST_CODEGEN_MAX_TOKENS = 16384 _OPENROUTER_REASONING_CODEGEN_EFFORT = 'minimal' # Special effort values that map to disabling reasoning entirely. These are # only safe for non-mandatory reasoning models; mandatory reasoning models @@ -532,7 +532,7 @@ class OpenAICompatibleProvider(OpenAIProvider): 'key_env': 'OPENROUTER_API_KEY', 'model_env': 'AOD_OPENROUTER_MODEL', 'endpoint_env': 'AOD_OPENROUTER_ENDPOINT', - 'default_model': 'google/gemini-3.5-flash', + 'default_model': 'anthropic/claude-sonnet-4.6', 'default_endpoint': ( 'https://openrouter.ai/api/v1/chat/completions' ), @@ -614,9 +614,9 @@ def _request_headers(self): def _extra_generation_params(self, max_tokens, json_response): params = {} # For codegen (non-JSON) calls on OpenRouter, set minimal reasoning. - # OpenRouter defaults to "medium" thinking for many models including - # gemini-3.5-flash, which adds a long delay before the first useful - # activity.py token. Minimal reasoning favors fast, visible codegen. + # OpenRouter defaults to extra thinking for many models, which adds + # a long delay before the first useful activity.py token. Minimal + # reasoning favors fast, visible codegen. if not json_response and self._provider_name == 'openrouter': effort = os.environ.get( 'AOD_OPENROUTER_CODEGEN_REASONING_EFFORT', @@ -1076,7 +1076,8 @@ def _chat_finish_reason_error(label, finish_reason, message): ) return ( '%s returned no activity text (finish_reason=%s). Try rerunning or ' - 'switching to a text/code model such as moonshotai/kimi-k2.6.' + 'switching to a text/code model such as ' + 'anthropic/claude-sonnet-4.6.' % (label, reason) ) @@ -1309,7 +1310,10 @@ def _provider_model(name): model_env = { 'gemini': ('AOD_GEMINI_MODEL', 'gemini-2.5-flash'), 'openai': ('AOD_OPENAI_MODEL', 'gpt-4.1-mini'), - 'openrouter': ('AOD_OPENROUTER_MODEL', 'google/gemini-3.5-flash'), + 'openrouter': ( + 'AOD_OPENROUTER_MODEL', + 'anthropic/claude-sonnet-4.6', + ), 'deepseek': ('AOD_DEEPSEEK_MODEL', 'deepseek-chat'), 'qwen': ('AOD_QWEN_MODEL', 'qwen-turbo'), 'moonshot': ('AOD_MOONSHOT_MODEL', 'moonshot-v1-8k'), diff --git a/src/jarabe/model/aodpreview.py b/src/jarabe/model/aodpreview.py index d1803fa80f..8236762818 100644 --- a/src/jarabe/model/aodpreview.py +++ b/src/jarabe/model/aodpreview.py @@ -68,6 +68,35 @@ def disconnect(self, handler_id): pass +class _NoOpProxy: + """A proxy that absorbs all attribute access, calls, and iteration. + + Used by PreviewActivity.__getattr__ so that generated code like + ``self.log_text_view.get_buffer()`` doesn't crash when + ``log_text_view`` hasn't been created yet. + """ + + def __getattr__(self, name): + return _NoOpProxy() + + def __call__(self, *args, **kwargs): + return _NoOpProxy() + + def __iter__(self): + return iter([]) + + def __len__(self): + return 0 + + def __bool__(self): + return False + + def __str__(self): + return '' + + def __repr__(self): + return '' + class PreviewActivity(Gtk.Window): """Minimal stub of sugar3.activity.Activity for preview rendering. @@ -105,6 +134,15 @@ def __init__(self, handle=None, bundle_path=''): self._title = self.metadata['title'] self._activity_root = '' + def __getattr__(self, name): + # Generated activities sometimes call self.some_method() or + # access self.some_widget before it is created in __init__. + # Return a safe no-op proxy that absorbs all attribute access + # and calls instead of crashing with AttributeError. + if name.startswith('_'): + raise AttributeError(name) + return _NoOpProxy() + def set_canvas(self, canvas): self._canvas = canvas diff --git a/start_sugar_xephyr.sh b/start_sugar_xephyr.sh index adbc00ea3b..9317cde398 100755 --- a/start_sugar_xephyr.sh +++ b/start_sugar_xephyr.sh @@ -1,5 +1,5 @@ #!/bin/bash -# Launch Sugar inside a nested Xephyr window for safe testing. +# Launch Sugar inside a fullscreen nested Xephyr display for safe testing. set -e @@ -13,10 +13,10 @@ X_DISPLAY=":${DISPLAY_NUM}" SUGAR_TEST_HOME="/tmp/sugar_test_home_$$" LOG_FILE="/tmp/sugar_xephyr_$$.log" -echo "Starting Xephyr on display ${X_DISPLAY}..." -Xephyr ${X_DISPLAY} -screen 1200x900 -ac -br -noreset > "${LOG_FILE}.xephyr" 2>&1 & +echo "Starting fullscreen Xephyr on display ${X_DISPLAY}..." +setsid Xephyr "${X_DISPLAY}" -fullscreen -ac -br -noreset \ + > "${LOG_FILE}.xephyr" 2>&1 & XEPHYR_PID=$! -disown ${XEPHYR_PID} sleep 2 SUGAR_DATA_DIR="/usr/share/sugar/data" @@ -60,14 +60,14 @@ export PYTHONPATH="/home/ashutoshx7/sugar/src:${PYTHONPATH}" echo "Starting Sugar on ${X_DISPLAY}..." echo "Logs: ${LOG_FILE}" -dbus-launch --exit-with-session python3 -m jarabe.main > "${LOG_FILE}" 2>&1 & +setsid dbus-launch --exit-with-session python3 -m jarabe.main \ + > "${LOG_FILE}" 2>&1 & SUGAR_PID=$! -disown ${SUGAR_PID} echo "Xephyr PID: ${XEPHYR_PID}" echo "Sugar PID: ${SUGAR_PID}" echo "" -echo "Sugar is running in Xephyr on display ${X_DISPLAY}" +echo "Sugar is running fullscreen in Xephyr on display ${X_DISPLAY}" echo "To stop, run: kill ${XEPHYR_PID} ${SUGAR_PID}" echo "" diff --git a/tests/test_aodllm.py b/tests/test_aodllm.py index 4d4b43fc0f..067944efad 100644 --- a/tests/test_aodllm.py +++ b/tests/test_aodllm.py @@ -246,7 +246,7 @@ def test_create_qwen_provider_from_environment(self): self.assertEqual('qwen-max', provider.model) self.assertEqual('qwen-key', provider._api_key) - def test_create_openrouter_provider_uses_gemini_flash_defaults(self): + def test_create_openrouter_provider_uses_sonnet_defaults(self): provider = create_provider( 'openrouter', api_key='openrouter-key', @@ -256,7 +256,7 @@ def test_create_openrouter_provider_uses_gemini_flash_defaults(self): self.assertEqual('openrouter', provider.name) self.assertEqual('OpenRouter', provider.label) self.assertEqual('openrouter-key', provider._api_key) - self.assertEqual('google/gemini-3.5-flash', provider.model) + self.assertEqual('anthropic/claude-sonnet-4.6', provider.model) self.assertEqual( 'https://openrouter.ai/api/v1/chat/completions', provider._endpoint, @@ -289,7 +289,7 @@ def test_openrouter_request_uses_default_model_and_headers(self): 'https://openrouter.ai/api/v1/chat/completions', request.full_url, ) - self.assertEqual('google/gemini-3.5-flash', payload['model']) + self.assertEqual('anthropic/claude-sonnet-4.6', payload['model']) self.assertEqual('Sugar Activity on Demand', request.get_header('X-title')) @@ -439,7 +439,7 @@ def test_openrouter_codegen_accepts_fenced_activity_source(self): self.assertNotIn('response_format', payload) # The default OpenRouter model uses the fast codegen budget plus # minimal reasoning to avoid long thinking delays before activity.py. - self.assertEqual(9000, payload['max_tokens']) + self.assertEqual(16384, payload['max_tokens']) self.assertEqual({'effort': 'minimal'}, payload['reasoning']) def test_openrouter_plan_call_is_not_affected_by_reasoning_budget(self): @@ -535,7 +535,7 @@ def test_openrouter_codegen_reasoning_only_for_kimi_models(self): opener.call_args[0][0].data.decode('utf-8') ) - self.assertEqual(9000, payload['max_tokens']) + self.assertEqual(16384, payload['max_tokens']) self.assertEqual({'effort': 'minimal'}, payload['reasoning']) def test_create_moonshot_provider_from_environment(self): @@ -673,7 +673,7 @@ def test_opencode_go_codegen_uses_larger_token_budget(self): ) self.assertIn('GeneratedActivity', source) - self.assertEqual(16000, payload['max_tokens']) + self.assertEqual(16384, payload['max_tokens']) self.assertEqual(1.0, payload['temperature']) def test_claude_codegen_uses_larger_token_budget(self): @@ -706,7 +706,7 @@ def test_claude_codegen_uses_larger_token_budget(self): ) self.assertIn('GeneratedActivity', source) - self.assertEqual(16000, payload['max_tokens']) + self.assertEqual(16384, payload['max_tokens']) def _simple_activity_source(): From cbb9ed490a0de90285ff304e3ad16b09e96cd321 Mon Sep 17 00:00:00 2001 From: Ashutoshx7 Date: Wed, 24 Jun 2026 23:48:46 +0530 Subject: [PATCH 09/28] Improve AOD prompt input and defaults --- src/jarabe/desktop/homebox.py | 70 ++++++++++++++++++++++++++++++++-- src/jarabe/model/aodcodegen.py | 11 +++--- src/jarabe/model/aodllm.py | 6 +-- tests/test_aodllm.py | 6 +-- 4 files changed, 78 insertions(+), 15 deletions(-) diff --git a/src/jarabe/desktop/homebox.py b/src/jarabe/desktop/homebox.py index 04cec8e1a1..b26961b01f 100644 --- a/src/jarabe/desktop/homebox.py +++ b/src/jarabe/desktop/homebox.py @@ -626,6 +626,15 @@ def _create_create_view(self): prompt_header.pack_end(char_label, False, False, 0) char_label.show() + prompt_scroll = Gtk.ScrolledWindow() + prompt_scroll.set_policy(Gtk.PolicyType.NEVER, + Gtk.PolicyType.AUTOMATIC) + prompt_scroll.set_min_content_height(style.zoom(86)) + prompt_scroll.set_max_content_height(style.zoom(86)) + prompt_scroll.set_propagate_natural_height(False) + prompt_inner.pack_start(prompt_scroll, False, False, 0) + prompt_scroll.show() + text = Gtk.TextView() self._prompt_text = text text.set_can_focus(True) @@ -642,7 +651,7 @@ def _create_create_view(self): self.__prompt_button_press_event_cb) text.connect('key-press-event', self.__prompt_key_press_event_cb) text.set_size_request(-1, style.zoom(86)) - prompt_inner.pack_start(text, True, True, 0) + prompt_scroll.add(text) text.show() self._set_prompt_placeholder() @@ -4516,7 +4525,26 @@ def _read_external_clipboard_text(self): return text return '' - def _read_external_clipboard_with_helper(self, display_name, xauthority): + def _read_external_prompt_clipboard_text(self): + display_names = [] + requested = os.environ.get('SUGAR_AOD_CLIPBOARD_DISPLAY', '') + if requested: + display_names.append(requested) + for display_name in (':1', ':0'): + if display_name not in display_names and \ + display_name != os.environ.get('DISPLAY'): + display_names.append(display_name) + + for display_name in display_names: + for xauthority in self._get_external_xauthority_paths(): + text = self._read_external_clipboard_with_helper( + display_name, xauthority, clean=False) + if text: + return text + return '' + + def _read_external_clipboard_with_helper(self, display_name, xauthority, + clean=True): script = ''' import sys @@ -4557,8 +4585,10 @@ def _read_external_clipboard_with_helper(self, display_name, xauthority): ) return '' - return self._clean_pasted_api_key( - output.decode('utf-8', 'replace')) + text = output.decode('utf-8', 'replace') + if clean: + return self._clean_pasted_api_key(text) + return text def _get_external_xauthority_paths(self): paths = [] @@ -6485,8 +6515,40 @@ def __prompt_button_press_event_cb(self, text_view, event): def __prompt_key_press_event_cb(self, text_view, event): self._clear_prompt_placeholder() + modifiers = Gtk.accelerator_get_default_mod_mask() + state = event.state & modifiers + ctrl = state & Gdk.ModifierType.CONTROL_MASK + shift = state & Gdk.ModifierType.SHIFT_MASK + if ctrl and event.keyval in (Gdk.KEY_v, Gdk.KEY_V): + return self._paste_prompt_from_clipboard() + if shift and event.keyval == Gdk.KEY_Insert: + return self._paste_prompt_from_clipboard() return False + def _paste_prompt_from_clipboard(self): + if self._prompt_text is None: + return False + + self._clear_prompt_placeholder() + self._prompt_text.grab_focus() + Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD).request_text( + self.__prompt_clipboard_text_received_cb) + return True + + def __prompt_clipboard_text_received_cb(self, clipboard, text): + if not text: + text = self._read_external_prompt_clipboard_text() + if not text: + if self._prompt_status_label is not None: + self._prompt_status_label.set_text(_('Clipboard empty')) + return + + text_buffer = self._prompt_text.get_buffer() + insert_iter = text_buffer.get_iter_at_mark(text_buffer.get_insert()) + text_buffer.insert(insert_iter, text) + if self._prompt_status_label is not None: + self._prompt_status_label.set_text(_('Ready')) + def __prompt_focus_in_event_cb(self, text_view, event): self._clear_prompt_placeholder() return False diff --git a/src/jarabe/model/aodcodegen.py b/src/jarabe/model/aodcodegen.py index df3dff3a27..d806abf68d 100644 --- a/src/jarabe/model/aodcodegen.py +++ b/src/jarabe/model/aodcodegen.py @@ -123,11 +123,12 @@ def build_codegen_system_prompt(spec, plan, references=()): 'undo, etc.) via Gtk.AccelGroup or key-press-event when relevant.\n' '- No TODO, no "placeholder", no "Add your code here", no demo ' 'strings. Every label and action is final classroom-ready text.\n' - '- Length: rich activities are typically 400–800 lines of ' - 'real working code. Do NOT pad with dead helpers, but do NOT ' - 'stop at a 50-line MVP either. If the request is genuinely ' - 'simple (single timer, single counter), 120 lines is fine — ' - 'but most requests warrant 1000+.\n' + '- Length: Write all necessary code to fully realize the request without ' + 'any shortcuts, placeholders, or truncations. For complex activities ' + '(e.g. board games or simulators), this typically requires 800 to ' + '1500+ lines of complete, working code. Prioritize completeness and ' + 'correctness over any arbitrary length limits; do not abbreviate or stop ' + 'generation early.\n' '\n## Examples of richness expected per request type\n' '- Drawing: tool palette (pen/eraser), color picker (≥6 colors), ' 'brush-size slider, undo/redo stack, clear-canvas action, save-' diff --git a/src/jarabe/model/aodllm.py b/src/jarabe/model/aodllm.py index dd81220a39..f1881e48f4 100644 --- a/src/jarabe/model/aodllm.py +++ b/src/jarabe/model/aodllm.py @@ -532,7 +532,7 @@ class OpenAICompatibleProvider(OpenAIProvider): 'key_env': 'OPENROUTER_API_KEY', 'model_env': 'AOD_OPENROUTER_MODEL', 'endpoint_env': 'AOD_OPENROUTER_ENDPOINT', - 'default_model': 'anthropic/claude-sonnet-4.6', + 'default_model': 'google/gemini-3.5-flash', 'default_endpoint': ( 'https://openrouter.ai/api/v1/chat/completions' ), @@ -1077,7 +1077,7 @@ def _chat_finish_reason_error(label, finish_reason, message): return ( '%s returned no activity text (finish_reason=%s). Try rerunning or ' 'switching to a text/code model such as ' - 'anthropic/claude-sonnet-4.6.' + 'google/gemini-3.5-flash.' % (label, reason) ) @@ -1312,7 +1312,7 @@ def _provider_model(name): 'openai': ('AOD_OPENAI_MODEL', 'gpt-4.1-mini'), 'openrouter': ( 'AOD_OPENROUTER_MODEL', - 'anthropic/claude-sonnet-4.6', + 'google/gemini-3.5-flash', ), 'deepseek': ('AOD_DEEPSEEK_MODEL', 'deepseek-chat'), 'qwen': ('AOD_QWEN_MODEL', 'qwen-turbo'), diff --git a/tests/test_aodllm.py b/tests/test_aodllm.py index 067944efad..c331a54047 100644 --- a/tests/test_aodllm.py +++ b/tests/test_aodllm.py @@ -246,7 +246,7 @@ def test_create_qwen_provider_from_environment(self): self.assertEqual('qwen-max', provider.model) self.assertEqual('qwen-key', provider._api_key) - def test_create_openrouter_provider_uses_sonnet_defaults(self): + def test_create_openrouter_provider_uses_gemini_flash_defaults(self): provider = create_provider( 'openrouter', api_key='openrouter-key', @@ -256,7 +256,7 @@ def test_create_openrouter_provider_uses_sonnet_defaults(self): self.assertEqual('openrouter', provider.name) self.assertEqual('OpenRouter', provider.label) self.assertEqual('openrouter-key', provider._api_key) - self.assertEqual('anthropic/claude-sonnet-4.6', provider.model) + self.assertEqual('google/gemini-3.5-flash', provider.model) self.assertEqual( 'https://openrouter.ai/api/v1/chat/completions', provider._endpoint, @@ -289,7 +289,7 @@ def test_openrouter_request_uses_default_model_and_headers(self): 'https://openrouter.ai/api/v1/chat/completions', request.full_url, ) - self.assertEqual('anthropic/claude-sonnet-4.6', payload['model']) + self.assertEqual('google/gemini-3.5-flash', payload['model']) self.assertEqual('Sugar Activity on Demand', request.get_header('X-title')) From 5ec4dbdc35aef1f0450cb92af07da69b71886301 Mon Sep 17 00:00:00 2001 From: Ashutoshx7 Date: Sun, 28 Jun 2026 17:02:20 +0530 Subject: [PATCH 10/28] Fix bugs, add code size selector, and harden AOD pipeline - Fix sidebar preview vanish: drop show_all() (was revealing hidden toolbar buttons), store live canvas ref, call queue_draw() directly on canvas + deferred 200ms second pass - Fix SEARCH/REPLACE marker collision: anchor ======= and >>>>>>> REPLACE searches to line boundaries so identical strings in source code are not mistaken for block dividers - Fix Gemini empty response: both streaming and non-streaming paths now raise ProviderError instead of returning empty string - Fix aodsessions double disk-load: add append_messages() and append_revision_and_message() for atomic multi-mutation saves - Fix aodqueue unbounded growth: cap at 64 jobs with put_nowait() - Add code_size field to ActivitySpec (compact/standard/full) - Thread max_output_tokens through all five LLM providers - Add code size dropdown to prompt UI (~500/~1000 lines/Full output) - Adjust codegen system prompt length instruction per code_size selection --- src/jarabe/desktop/homebox.py | 205 +++++++++++++++++++++++-------- src/jarabe/model/aodcodegen.py | 57 +++++++-- src/jarabe/model/aodjobs.py | 16 ++- src/jarabe/model/aodllm.py | 49 +++++--- src/jarabe/model/aodpipeline.py | 107 +++++++++++----- src/jarabe/model/aodqueue.py | 16 ++- src/jarabe/model/aodrag.py | 64 ++++++++++ src/jarabe/model/aodrefine.py | 28 +++-- src/jarabe/model/aodservice.py | 31 +++-- src/jarabe/model/aodsessions.py | 23 ++++ src/jarabe/model/aodspec.py | 8 ++ src/jarabe/model/aodvalidator.py | 4 +- 12 files changed, 479 insertions(+), 129 deletions(-) diff --git a/src/jarabe/desktop/homebox.py b/src/jarabe/desktop/homebox.py index b26961b01f..63fa121ab1 100644 --- a/src/jarabe/desktop/homebox.py +++ b/src/jarabe/desktop/homebox.py @@ -312,9 +312,14 @@ def __init__(self): 'template': 'logic_math', 'planner': 'rag', 'policy': 'standard', + 'validate': 'on', 'provider': 'default', 'license': 'mit', + 'code_size': 'standard', } + self._code_size_combo = None + self._sidebar_visible = True + self._sidebar_toggle_button = None self._template_hint = None self._planner_hint = None self._provider_combo = None @@ -333,6 +338,7 @@ def __init__(self): self._prompt_status_label = None self._studio_prompt_labels = [] self._preview_content_box = None + self._live_preview_canvas = None self._preview_empty_title = None self._preview_empty_note = None self._preview_generation_spinner = None @@ -374,6 +380,7 @@ def __init__(self): self._chat_scroll = None self._aod_session_id = '' self._aod_active_revision_id = '' + self._aod_original_prompt = '' self._sidebar_messages_box = None self._sidebar_chat_scroll = None self._sidebar_refine_entry = None @@ -429,6 +436,7 @@ def reset_view(self): self._review_draft_was_shown = False self._aod_session_id = '' self._aod_active_revision_id = '' + self._aod_original_prompt = '' self._show_empty_activity_preview() self._use_centered_layout() self._stack.set_visible_child_name('choose') @@ -573,6 +581,15 @@ def _create_create_view(self): ], 'standard'), False, False, 0) + planner_options.pack_start(self._create_option_group( + _('Validation'), + 'validate', + [ + ('on', _('On'), _('Validate and\nretry on errors')), + ('off', _('Off'), _('Skip validation\n(faster preview)')), + ], + 'on'), False, False, 0) + planner_hint = Gtk.Label() planner_hint.get_style_context().add_class('create-ai-meta-note') self._planner_hint = planner_hint @@ -600,7 +617,7 @@ def _create_create_view(self): prompt_box = Gtk.EventBox() prompt_box.get_style_context().add_class('create-ai-prompt-box') prompt_box.set_above_child(False) - prompt_box.set_size_request(style.zoom(1040), style.zoom(226)) + prompt_box.set_size_request(style.zoom(1040), -1) prompt_box.set_halign(Gtk.Align.CENTER) container.pack_start(prompt_box, False, False, style.zoom(10)) prompt_box.show() @@ -615,12 +632,12 @@ def _create_create_view(self): prompt_header.show() prompt_label = Gtk.Label(_('Learning prompt')) - prompt_label.get_style_context().add_class('create-ai-meta-label') + prompt_label.get_style_context().add_class('create-ai-meta-note') prompt_label.set_xalign(0) prompt_header.pack_start(prompt_label, False, False, 0) prompt_label.show() - char_label = Gtk.Label(_('0 chars')) + char_label = Gtk.Label('') self._prompt_char_label = char_label char_label.get_style_context().add_class('create-ai-meta-note') prompt_header.pack_end(char_label, False, False, 0) @@ -629,8 +646,8 @@ def _create_create_view(self): prompt_scroll = Gtk.ScrolledWindow() prompt_scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) - prompt_scroll.set_min_content_height(style.zoom(86)) - prompt_scroll.set_max_content_height(style.zoom(86)) + prompt_scroll.set_min_content_height(style.zoom(88)) + prompt_scroll.set_max_content_height(style.zoom(88)) prompt_scroll.set_propagate_natural_height(False) prompt_inner.pack_start(prompt_scroll, False, False, 0) prompt_scroll.show() @@ -640,21 +657,33 @@ def _create_create_view(self): text.set_can_focus(True) text.set_editable(True) text.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) - text.set_left_margin(style.zoom(10)) - text.set_right_margin(style.zoom(10)) - text.set_top_margin(style.zoom(10)) - text.set_bottom_margin(style.zoom(10)) + text.set_left_margin(style.zoom(4)) + text.set_right_margin(style.zoom(4)) + text.set_top_margin(style.zoom(6)) + text.set_bottom_margin(style.zoom(6)) text.get_style_context().add_class('create-ai-textview') text.get_buffer().connect('changed', self.__prompt_buffer_changed_cb) text.connect('button-press-event', self.__prompt_button_press_event_cb) text.connect('key-press-event', self.__prompt_key_press_event_cb) - text.set_size_request(-1, style.zoom(86)) + text.connect('focus-in-event', + lambda w, e: prompt_box.get_style_context().add_class( + 'create-ai-prompt-box-focused')) + text.connect('focus-out-event', + lambda w, e: prompt_box.get_style_context().remove_class( + 'create-ai-prompt-box-focused')) + text.set_size_request(-1, style.zoom(88)) prompt_scroll.add(text) text.show() self._set_prompt_placeholder() + divider = Gtk.EventBox() + divider.get_style_context().add_class('create-ai-prompt-divider') + divider.set_size_request(-1, 1) + prompt_inner.pack_start(divider, False, False, 0) + divider.show() + actions = Gtk.EventBox() actions.get_style_context().add_class('create-ai-prompt-actions') actions.set_above_child(False) @@ -662,7 +691,7 @@ def _create_create_view(self): actions.show() bottom_row = Gtk.HBox(spacing=style.zoom(8)) - bottom_row.set_border_width(style.zoom(6)) + bottom_row.set_border_width(style.zoom(8)) actions.add(bottom_row) bottom_row.show() @@ -689,6 +718,21 @@ def _create_create_view(self): bottom_row.pack_end(send_row, False, False, 0) send_row.show() + code_size_combo = Gtk.ComboBoxText() + self._code_size_combo = code_size_combo + for size_id, size_label in ( + ('compact', _('~500 lines')), + ('standard', _('~1000 lines')), + ('full', _('Full output')), + ): + code_size_combo.append(size_id, size_label) + code_size_combo.set_active_id( + self._selected_options.get('code_size', 'standard')) + code_size_combo.get_style_context().add_class('create-ai-provider-combo') + code_size_combo.connect('changed', self.__code_size_combo_changed_cb) + send_row.pack_start(code_size_combo, False, False, 0) + code_size_combo.show() + thinking = Gtk.Label(_('Thinking')) self._prompt_status_label = thinking thinking.get_style_context().add_class('create-ai-pill-button') @@ -1254,6 +1298,10 @@ def _create_studio_preview_panel(self): _('Review and install'), self.__review_and_install_cb), False, False, 0) + self._sidebar_toggle_button = self._create_plain_button( + _('◀ Sidebar'), self.__sidebar_toggle_cb) + top.pack_end(self._sidebar_toggle_button, False, False, 0) + tabs = Gtk.HBox(spacing=style.zoom(8)) panel.pack_start(tabs, False, False, 0) tabs.show() @@ -1366,6 +1414,7 @@ def _clear_activity_preview(self): return for child in self._preview_content_box.get_children(): self._preview_content_box.remove(child) + self._live_preview_canvas = None self._preview_empty_title = None self._preview_empty_note = None self._preview_generation_spinner = None @@ -3480,60 +3529,73 @@ def _ensure_css(self): color: %(studio_dark_text)s; } .create-ai-prompt-box { - border-radius: 8px; - border: 1px solid %(studio_edge)s; + border-radius: 14px; + border: 1px solid #e2e2e2; background-color: %(studio_surface)s; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.045); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04); + } + .create-ai-prompt-box-focused { + border-color: #b8b8b8; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.09), 0 1px 3px rgba(0, 0, 0, 0.05); } textview.create-ai-textview { color: %(black)s; - background-color: %(studio_surface)s; - border: 1px solid %(studio_edge)s; - border-radius: 7px; - font-size: 12px; + background-color: transparent; + border: 0; + border-radius: 0; + font-size: 13px; } textview.create-ai-textview text { color: %(black)s; - background-color: %(studio_surface)s; + background-color: transparent; } textview.create-ai-textview:focus { - border-color: %(studio_lavender_border)s; - background-color: %(studio_surface)s; + border: 0; box-shadow: none; + background-color: transparent; + } + .create-ai-prompt-divider { + background-color: #ebebeb; + min-height: 1px; } .create-ai-prompt-actions { - border-radius: 7px; - border: 1px solid %(studio_edge_soft)s; - background-color: %(studio_preview)s; + border-radius: 0 0 13px 13px; + border: 0; + background-color: transparent; } .create-ai-plus { border-radius: 999px; - border: 1px solid %(studio_edge)s; + border: 1px solid #d4d4d4; background-image: none; - background-color: %(studio_surface)s; + background-color: #f5f5f5; color: %(studio_lavender_text)s; padding: 0 4px; - min-width: 30px; - min-height: 26px; + min-width: 28px; + min-height: 28px; + } + .create-ai-plus:hover { + background-color: %(studio_lavender_soft)s; + border-color: %(studio_lavender_border)s; } .create-ai-plus label { color: %(studio_lavender_text)s; } .create-ai-send { border-radius: 999px; - border: 1px solid %(studio_edge)s; + border: 1px solid #555555; background-image: none; - background-color: %(studio_surface)s; - color: %(studio_lavender_text)s; + background-color: #404040; + color: #ffffff; padding: 1px 6px; - min-width: 30px; - min-height: 26px; + min-width: 28px; + min-height: 28px; } .create-ai-send label { - color: %(studio_lavender_text)s; + color: #ffffff; } .create-ai-send:hover { - background-color: %(studio_lavender_soft)s; + background-color: #2e2e2e; + border-color: #444444; } .create-ai-studio-workspace { border-radius: 14px; @@ -4107,7 +4169,7 @@ def _set_prompt_placeholder(self): self._prompt_is_placeholder = False self._prompt_text.get_buffer().set_text('') if self._prompt_char_label is not None: - self._prompt_char_label.set_text(_('0 chars')) + self._prompt_char_label.set_text('') def _clear_prompt_placeholder(self): self._prompt_is_placeholder = False @@ -4834,6 +4896,7 @@ def _render_live_generated_activity_preview(self, result): canvas.set_vexpand(True) box.pack_start(canvas, True, True, 0) + self._live_preview_canvas = canvas self._preview_content_box.pack_start(shell, True, True, 0) shell.show_all() return True @@ -6379,6 +6442,9 @@ def __option_card_clicked_cb(self, clicked_button, group_name, value): else: self._prompt_status_label.set_text(status[value]) + def __code_size_combo_changed_cb(self, combo): + self._selected_options['code_size'] = combo.get_active_id() or 'standard' + def __provider_combo_changed_cb(self, combo): provider_name = combo.get_active_id() or 'default' if provider_name == self._selected_options['provider']: @@ -6503,7 +6569,10 @@ def __prompt_buffer_changed_cb(self, text_buffer): start, end = text_buffer.get_bounds() count = len(text_buffer.get_text(start, end, True)) - self._prompt_char_label.set_text(_('%d chars') % count) + if count > 0: + self._prompt_char_label.set_text(_('%d chars') % count) + else: + self._prompt_char_label.set_text('') def __prompt_entry_activate_cb(self, entry): self.__send_button_clicked_cb(entry) @@ -6587,6 +6656,7 @@ def _submit_generation_from_prompt(self, prompt, chat_prompt=None): prompt=prompt, category=self._selected_options['template'], license_id=license_info['spdx'], + code_size=self._selected_options.get('code_size', 'standard'), ) self._submit_generation_spec( spec, @@ -6639,6 +6709,7 @@ def _build_refinement_spec(self, refinement): flow = plan.get('classroom_flow') or plan.get('learner_steps') or [] flow_text = '\n'.join('- %s' % step for step in flow[:5]) plan_context = self._compact_plan_for_refinement(plan) + original_prompt = self._aod_original_prompt or base_spec.prompt prompt = ( 'Refine the existing generated Sugar activity. Preserve working ' 'behavior unless the new request changes it.\n\n' @@ -6651,7 +6722,7 @@ def _build_refinement_spec(self, refinement): 'Current plan JSON excerpt:\n%(plan_context)s\n\n' 'Refinement request:\n%(refinement)s' ) % { - 'original': base_spec.prompt, + 'original': original_prompt, 'name': base_spec.name, 'template': plan.get('template', base_spec.template), 'summary': plan.get('summary', ''), @@ -6670,6 +6741,7 @@ def _build_refinement_spec(self, refinement): age_band=base_spec.age_band, learner_goal=base_spec.learner_goal or plan.get('learner_goal', ''), + code_size=self._selected_options.get('code_size', 'standard'), ) def _compact_plan_for_refinement(self, plan): @@ -6751,11 +6823,6 @@ def _submit_generation_spec(self, spec, chat_prompt=None, if not self._configure_selected_provider(persist=True): return - if self._prompt_status_label is not None: - if is_refinement: - self._prompt_status_label.set_text(_('Refining')) - else: - self._prompt_status_label.set_text(_('Planning')) if self._sidebar_refine_status_label is not None: if is_refinement: self._sidebar_refine_status_label.set_text(_('Refining...')) @@ -6763,15 +6830,20 @@ def _submit_generation_spec(self, spec, chat_prompt=None, self._sidebar_refine_status_label.set_text( _('Generating activity...')) use_rag = policy not in ('local', 'strict') + validate_code = self._selected_options.get('validate', 'on') == 'on' self._detach_generation_job() self._generation_result = None if not is_refinement: self._aod_session_id = '' self._aod_active_revision_id = '' + self._aod_original_prompt = spec.prompt if display_prompt is None: display_prompt = spec.prompt - self._set_prompt_text(spec.prompt) + if is_refinement: + self._set_prompt_text(display_prompt) + else: + self._set_prompt_text(spec.prompt) self._set_studio_prompt(display_prompt) self._update_preview_license_summary() self._append_chat_message(chat_prompt or display_prompt, @@ -6833,6 +6905,7 @@ def _submit_generation_spec(self, spec, chat_prompt=None, spec, provider_name=provider_name, use_rag=use_rag, + validate_code=validate_code, session_id=self._aod_session_id if is_refinement else '', parent_revision_id=( self._aod_active_revision_id if is_refinement else ''), @@ -6845,9 +6918,19 @@ def _submit_generation_spec(self, spec, chat_prompt=None, self._generation_job_id = job.job_id self._aod_session_id = job.session_id + self._set_chat_entry_sensitive(False) service.watch(job.job_id, self._generation_job_callback) self._generation_job_updated_from_worker(job) + def _set_chat_entry_sensitive(self, sensitive): + if self._chat_entry is not None: + self._chat_entry.set_sensitive(sensitive) + if sensitive: + self._chat_entry.set_placeholder_text( + _('Ask for a refinement...')) + if self._stack.get_visible_child_name() == 'studio': + self._chat_entry.grab_focus() + def _has_active_generation_job(self): if self._generation_job_id is None: return False @@ -7017,9 +7100,7 @@ def _generation_finished_cb(self, result): self._append_chat_status(provider_status) self._append_chat_status( _('Generated. Select a part or type a refinement.')) - if self._chat_entry is not None: - self._chat_entry.set_placeholder_text( - _('Ask for a refinement...')) + self._set_chat_entry_sensitive(True) self._append_sidebar_status(provider_status) self._append_sidebar_message( _('Generated. Type another prompt here to refine ' @@ -7088,6 +7169,7 @@ def _generation_failed_cb(self, error_text, job=None): _('Generation failed: %s') % display_error) self._append_sidebar_message( _('Generation failed: %s') % display_error) + self._set_chat_entry_sensitive(True) self._set_review_file(self._current_review_file) if draft_source: self._select_studio_tab('review') @@ -7110,10 +7192,37 @@ def __preview_fullscreen_toggle_cb(self, button): if self._studio_left_panel is not None: self._studio_left_panel.show() if self._studio_right_panel is not None: - self._studio_right_panel.show() + if self._sidebar_visible: + self._studio_right_panel.show() if self._preview_fullscreen_button is not None: self._preview_fullscreen_button.set_label( _('⛶ Fullscreen')) + self._refresh_preview_layout() + + def __sidebar_toggle_cb(self, button): + self._sidebar_visible = not self._sidebar_visible + if self._studio_right_panel is not None: + if self._sidebar_visible: + self._studio_right_panel.show() + else: + self._studio_right_panel.hide() + if self._sidebar_toggle_button is not None: + self._sidebar_toggle_button.set_label( + _('◀ Sidebar') if self._sidebar_visible else _('▶ Sidebar')) + self._refresh_preview_layout() + + def _refresh_preview_layout(self): + GObject.idle_add(self.__do_refresh_preview_layout) + GLib.timeout_add(200, self.__do_refresh_preview_layout) + + def __do_refresh_preview_layout(self): + if self._studio_mode_stack is not None: + self._studio_mode_stack.queue_resize() + self._studio_mode_stack.queue_draw() + if self._live_preview_canvas is not None: + self._live_preview_canvas.queue_resize() + self._live_preview_canvas.queue_draw() + return False def _ensure_generation_bundle(self): if self._generation_result is None: diff --git a/src/jarabe/model/aodcodegen.py b/src/jarabe/model/aodcodegen.py index d806abf68d..cf83f2ec99 100644 --- a/src/jarabe/model/aodcodegen.py +++ b/src/jarabe/model/aodcodegen.py @@ -11,7 +11,29 @@ from jarabe.model.aodvalidator import FORBIDDEN_IMPORT_ROOTS -def build_codegen_system_prompt(spec, plan, references=()): +_CODE_SIZE_INSTRUCTIONS = { + 'compact': ( + 'Length: Write a focused, complete activity in roughly 300–500 lines. ' + 'Implement the core interaction fully but skip decorative extras. ' + 'Every line must be functional — no placeholders or stubs.' + ), + 'standard': ( + 'Length: Write all necessary code to fully realize the request. ' + 'For complex activities (board games, simulators) this typically ' + 'requires 800–1200 lines. Prioritize completeness over brevity; ' + 'do not abbreviate or stop early.' + ), + 'full': ( + 'Length: Write the most complete, polished version possible with no ' + 'token limit. Include rich toolbar actions, keyboard shortcuts, ' + 'multiple screens/modes, full Journal persistence, edge-case ' + 'handling, and detailed visual polish. 1500+ lines is expected for ' + 'anything non-trivial. Do not stop until the activity is production-ready.' + ), +} + + +def build_codegen_system_prompt(spec, plan, references=(), code_size='standard'): """Build the provider prompt for a complete Sugar activity.py file.""" return ( 'You are Sugar Activity on Demand, a code generator for Sugar ' @@ -39,11 +61,16 @@ def build_codegen_system_prompt(spec, plan, references=()): '4. Set up logging:\n' ' import logging\n' ' _logger = logging.getLogger("GeneratedActivity")\n' - '5. Import sugar3 modules:\n' + '5. Import sugar3 modules FIRST before any Gtk imports:\n' ' from sugar3.activity import activity\n' ' from sugar3.graphics.toolbarbox import ToolbarBox\n' ' from sugar3.activity.widgets import ActivityToolbarButton\n' ' from sugar3.activity.widgets import StopButton\n' + ' Always prefer sugar3 / sugar-toolkit-gtk3 APIs over raw Gtk ' + 'equivalents. Use sugar3.graphics.style for colors, fonts, and icon ' + 'sizes. Use sugar3.graphics.toolbutton.ToolButton for toolbar items. ' + 'Use sugar3.graphics.alert for in-activity notifications. Fall back ' + 'to plain GTK3 only when no Sugar wrapper exists.\n' '6. Class must be named GeneratedActivity(activity.Activity)\n' '7. In __init__:\n' ' - Call activity.Activity.__init__(self, handle)\n' @@ -55,6 +82,23 @@ def build_codegen_system_prompt(spec, plan, references=()): '8. Implement read_file(self, file_path) and ' 'write_file(self, file_path)\n' ' for Journal persistence using json.\n\n' + '## Rendering approach — cairo vs pygame/sugargame\n' + 'Choose the right rendering backend for the request:\n' + '- **GTK3 + cairo (default)**: Use for most activities — ' + 'board games, drawing apps, quizzes, writing tools, simulations. ' + 'Gtk.DrawingArea with a cairo draw callback is idiomatic Sugar and ' + 'integrates cleanly with GTK events. Prefer this unless the request ' + 'is clearly game-loop driven.\n' + '- **pygame via sugargame**: Use ONLY when the request explicitly ' + 'asks for a pygame game or when the activity needs a continuous ' + 'game loop (e.g. real-time arcade, physics simulation, animation-' + 'heavy game). When using pygame, wrap the activity with ' + 'sugargame.canvas.Canvas and run pygame.display.set_mode inside ' + 'the Sugar handle. Import sugargame.canvas and pygame. Still ' + 'inherit from sugar3.activity.activity.Activity. Still use the ' + 'Sugar toolbar and Journal persistence.\n' + 'When in doubt, use cairo + GTK3. Only choose sugargame when the ' + 'learner prompt clearly describes gameplay that needs a frame loop.\n\n' 'Hard requirements:\n' '- Build the specific activity described by activity_kind, ' 'interaction_model, ui_regions, learner_steps, and the learner ' @@ -123,12 +167,7 @@ def build_codegen_system_prompt(spec, plan, references=()): 'undo, etc.) via Gtk.AccelGroup or key-press-event when relevant.\n' '- No TODO, no "placeholder", no "Add your code here", no demo ' 'strings. Every label and action is final classroom-ready text.\n' - '- Length: Write all necessary code to fully realize the request without ' - 'any shortcuts, placeholders, or truncations. For complex activities ' - '(e.g. board games or simulators), this typically requires 800 to ' - '1500+ lines of complete, working code. Prioritize completeness and ' - 'correctness over any arbitrary length limits; do not abbreviate or stop ' - 'generation early.\n' + '- %(length_instruction)s\n' '\n## Examples of richness expected per request type\n' '- Drawing: tool palette (pen/eraser), color picker (≥6 colors), ' 'brush-size slider, undo/redo stack, clear-canvas action, save-' @@ -160,6 +199,8 @@ def build_codegen_system_prompt(spec, plan, references=()): 'api_reference': get_api_reference(), 'references': _format_references(references) or 'No extra references were retrieved.', + 'length_instruction': _CODE_SIZE_INSTRUCTIONS.get( + code_size, _CODE_SIZE_INSTRUCTIONS['standard']), } diff --git a/src/jarabe/model/aodjobs.py b/src/jarabe/model/aodjobs.py index f943024f6e..2a68af3fcc 100644 --- a/src/jarabe/model/aodjobs.py +++ b/src/jarabe/model/aodjobs.py @@ -41,6 +41,7 @@ class AODJob: spec: ActivitySpec provider_name: str = 'default' use_rag: bool = True + validate_code: bool = True output_root: str = '' session_id: str = '' parent_revision_id: str = '' @@ -61,13 +62,14 @@ class AODJob: @classmethod def create(cls, spec, provider_name='default', use_rag=True, - output_root=None, session_id='', parent_revision_id='', - user_prompt=''): + validate_code=True, output_root=None, session_id='', + parent_revision_id='', user_prompt=''): return cls( job_id=uuid.uuid4().hex, spec=spec.normalized(), provider_name=provider_name, use_rag=use_rag, + validate_code=validate_code, output_root=output_root or '', session_id=session_id or '', parent_revision_id=parent_revision_id or '', @@ -81,6 +83,7 @@ def from_dict(cls, data): spec=ActivitySpec.from_dict(data['spec']), provider_name=data.get('provider_name', 'default'), use_rag=data.get('use_rag', True), + validate_code=data.get('validate_code', True), output_root=data.get('output_root', ''), session_id=data.get('session_id', ''), parent_revision_id=data.get('parent_revision_id', ''), @@ -106,6 +109,7 @@ def to_dict(self): 'spec': self.spec.to_dict(), 'provider_name': self.provider_name, 'use_rag': self.use_rag, + 'validate_code': self.validate_code, 'output_root': self.output_root, 'session_id': self.session_id, 'parent_revision_id': self.parent_revision_id, @@ -137,6 +141,8 @@ def request_cancel(self): self.updated_at = time.time() def update_progress(self, status, stage, progress, message): + if self.is_terminal(): + return self.status = status self.stage = stage self.progress = max(0.0, min(1.0, float(progress))) @@ -144,6 +150,8 @@ def update_progress(self, status, stage, progress, message): self.updated_at = time.time() def finish(self, result): + if self.is_terminal(): + return self.status = STATUS_FINISHED self.stage = STATUS_FINISHED self.progress = 1.0 @@ -154,6 +162,8 @@ def finish(self, result): self.updated_at = self.finished_at def fail(self, error): + if self.is_terminal(): + return self.status = STATUS_FAILED self.stage = STATUS_FAILED self.error = str(error) @@ -162,6 +172,8 @@ def fail(self, error): self.updated_at = self.finished_at def cancel(self): + if self.is_terminal(): + return self.status = STATUS_CANCELLED self.stage = STATUS_CANCELLED self.error = '' diff --git a/src/jarabe/model/aodllm.py b/src/jarabe/model/aodllm.py index f1881e48f4..5115297615 100644 --- a/src/jarabe/model/aodllm.py +++ b/src/jarabe/model/aodllm.py @@ -3,6 +3,7 @@ # SPDX-License-Identifier: GPL-3.0-or-later import json +import logging import os import urllib.error import urllib.parse @@ -147,17 +148,19 @@ def generate_text(self, system_prompt, user_prompt, def generate_activity_source(self, system_prompt, user_prompt, timeout=_PROVIDER_CODEGEN_TIMEOUT, - stream_callback=None): + stream_callback=None, + max_output_tokens=None): + tokens = max_output_tokens or _GEMINI_CODEGEN_MAX_OUTPUT_TOKENS if stream_callback is not None: text = self._stream_content( system_prompt, user_prompt, timeout, - max_output_tokens=_GEMINI_CODEGEN_MAX_OUTPUT_TOKENS, + max_output_tokens=tokens, stream_callback=stream_callback, ) else: text = self._generate_content( system_prompt, user_prompt, timeout, - max_output_tokens=_GEMINI_CODEGEN_MAX_OUTPUT_TOKENS, + max_output_tokens=tokens, response_json=False, ) return extract_activity_source_from_response(text) @@ -232,6 +235,8 @@ def _generate_content(self, system_prompt, user_prompt, timeout, raise ProviderError( 'Gemini response did not contain a result.' ) + if not text: + raise ProviderError('Gemini returned an empty response.') return text def _stream_content(self, system_prompt, user_prompt, timeout, @@ -308,7 +313,9 @@ def _stream_content(self, system_prompt, user_prompt, timeout, try: stream_callback(accumulated) except Exception: - pass + logging.debug( + 'stream_callback raised; ignoring', + exc_info=True) else: continue break @@ -321,6 +328,8 @@ def _stream_content(self, system_prompt, user_prompt, timeout, except (OSError, ValueError) as error: raise ProviderError('Gemini streaming request failed: %s' % error) + if not accumulated: + raise ProviderError('Gemini streaming returned an empty response.') return accumulated @@ -348,24 +357,26 @@ def generate_plan(self, system_prompt, user_prompt, def generate_text(self, system_prompt, user_prompt, timeout=_PROVIDER_CODEGEN_TIMEOUT, - stream_callback=None): + stream_callback=None, max_output_tokens=None): + tokens = max_output_tokens or _CODEGEN_MAX_TOKENS if stream_callback is not None: return self._stream_text( system_prompt, user_prompt, timeout, - max_tokens=_CODEGEN_MAX_TOKENS, + max_tokens=tokens, stream_callback=stream_callback, ) return self._generate_text( system_prompt, user_prompt, timeout, - max_tokens=_CODEGEN_MAX_TOKENS, json_response=False, + max_tokens=tokens, json_response=False, ) def generate_activity_source(self, system_prompt, user_prompt, timeout=_PROVIDER_CODEGEN_TIMEOUT, - stream_callback=None): + stream_callback=None, max_output_tokens=None): text = self.generate_text( system_prompt, user_prompt, timeout, stream_callback=stream_callback, + max_output_tokens=max_output_tokens, ) return extract_activity_source_from_response(text) @@ -695,10 +706,11 @@ def generate_plan(self, system_prompt, user_prompt, def generate_text(self, system_prompt, user_prompt, timeout=_PROVIDER_CODEGEN_TIMEOUT, - stream_callback=None): + stream_callback=None, max_output_tokens=None): text = self._generate_responses_text( system_prompt, user_prompt, timeout, - max_output_tokens=_FREEMODEL_CODEGEN_MAX_OUTPUT_TOKENS, + max_output_tokens=( + max_output_tokens or _FREEMODEL_CODEGEN_MAX_OUTPUT_TOKENS), reasoning_effort=self._codegen_reasoning_effort, ) if stream_callback is not None: @@ -710,10 +722,11 @@ def generate_text(self, system_prompt, user_prompt, def generate_activity_source(self, system_prompt, user_prompt, timeout=_PROVIDER_CODEGEN_TIMEOUT, - stream_callback=None): + stream_callback=None, max_output_tokens=None): text = self.generate_text( system_prompt, user_prompt, timeout, stream_callback=stream_callback, + max_output_tokens=max_output_tokens, ) return extract_activity_source_from_response(text) @@ -785,10 +798,10 @@ def generate_plan(self, system_prompt, user_prompt, def generate_text(self, system_prompt, user_prompt, timeout=_PROVIDER_CODEGEN_TIMEOUT, - stream_callback=None): + stream_callback=None, max_output_tokens=None): text = self._generate_text( system_prompt, user_prompt, timeout, - max_tokens=_CODEGEN_MAX_TOKENS, + max_tokens=max_output_tokens or _CODEGEN_MAX_TOKENS, ) if stream_callback is not None: try: @@ -799,10 +812,11 @@ def generate_text(self, system_prompt, user_prompt, def generate_activity_source(self, system_prompt, user_prompt, timeout=_PROVIDER_CODEGEN_TIMEOUT, - stream_callback=None): + stream_callback=None, max_output_tokens=None): text = self.generate_text( system_prompt, user_prompt, timeout, stream_callback=stream_callback, + max_output_tokens=max_output_tokens, ) return extract_activity_source_from_response(text) @@ -873,10 +887,10 @@ def generate_plan(self, system_prompt, user_prompt, def generate_text(self, system_prompt, user_prompt, timeout=_PROVIDER_CODEGEN_TIMEOUT, - stream_callback=None): + stream_callback=None, max_output_tokens=None): text = self._generate_text( system_prompt, user_prompt, timeout, - num_predict=_CODEGEN_MAX_TOKENS, + num_predict=max_output_tokens or _CODEGEN_MAX_TOKENS, ) if stream_callback is not None: try: @@ -887,10 +901,11 @@ def generate_text(self, system_prompt, user_prompt, def generate_activity_source(self, system_prompt, user_prompt, timeout=_PROVIDER_CODEGEN_TIMEOUT, - stream_callback=None): + stream_callback=None, max_output_tokens=None): text = self.generate_text( system_prompt, user_prompt, timeout, stream_callback=stream_callback, + max_output_tokens=max_output_tokens, ) return extract_activity_source_from_response(text) diff --git a/src/jarabe/model/aodpipeline.py b/src/jarabe/model/aodpipeline.py index 6c73168c81..9e7f79178e 100644 --- a/src/jarabe/model/aodpipeline.py +++ b/src/jarabe/model/aodpipeline.py @@ -26,6 +26,7 @@ from jarabe.model.aodrefine import build_refine_user_prompt from jarabe.model.aodrefine import parse_search_replace from jarabe.model.aodrefine import apply_patches +from jarabe.model.aodvalidator import validate_activity_source_for_request class PipelineError(Exception): @@ -47,6 +48,7 @@ def _env_int(name, default): def generate_activity(spec, output_root=None, provider=None, provider_name='default', use_rag=True, + validate_code=True, progress_cb=None, pace=False, package_bundle=True, template_fallback=False): """Run prompt grounding, provider planning, and generation. @@ -181,6 +183,7 @@ def generate_activity(spec, output_root=None, provider=None, plan, references, progress, + validate_code=validate_code, ) ) plan['codegen_attempts'] = code_attempts @@ -333,44 +336,86 @@ def report_partial(partial_text): return report_partial +_CODE_SIZE_TOKENS = { + 'compact': 6000, + 'standard': 14000, + 'full': None, +} + + def _generate_activity_source_with_provider(provider, spec, plan, references, - progress): + progress, validate_code=True): generate_source = getattr(provider, 'generate_activity_source', None) if not callable(generate_source): return None, '', 0 - progress.report('generating', 0.56, - 'Asking the model to write activity.py') + code_size = getattr(spec, 'code_size', 'standard') + max_output_tokens = _CODE_SIZE_TOKENS.get(code_size) - stream_callback = _make_codegen_stream_callback(progress, 1) - system_prompt = build_codegen_system_prompt(spec, plan, references) + system_prompt = build_codegen_system_prompt( + spec, plan, references, code_size=code_size) user_prompt = build_codegen_user_prompt(spec, plan) - try: - try: - source = generate_source( - system_prompt, - user_prompt, - stream_callback=stream_callback, + last_error = '' + + for attempt in range(1, _CODEGEN_ATTEMPT_LIMIT + 1): + progress.report( + 'generating', + 0.56, + 'Asking the model to write activity.py' + if attempt == 1 + else 'Retrying activity.py generation (attempt %d)' % attempt, + ) + stream_callback = _make_codegen_stream_callback(progress, attempt) + retry_prompt = user_prompt + if last_error: + retry_prompt = ( + '%s\n\nPrevious attempt was rejected. Fix these issues:\n%s' + % (user_prompt, last_error) ) - except TypeError: - # Older providers / test doubles may not accept - # stream_callback; fall back to the non-streaming call. - source = generate_source(system_prompt, user_prompt) - except ProviderError as error: - return None, _redact_provider_error(error, provider), 1 - except ValueError as error: - return None, str(error), 1 - - progress.report( - 'generating', - 0.64, - 'Model returned activity.py', - { - 'draft_activity_source': source, - 'codegen_attempt': 1, - }, - ) - return source, '', 1 + try: + try: + source = generate_source( + system_prompt, + retry_prompt, + stream_callback=stream_callback, + max_output_tokens=max_output_tokens, + ) + except TypeError as err: + msg = str(err) + if 'stream_callback' not in msg and 'keyword argument' not in msg: + raise + # Provider doesn't accept stream_callback/max_output_tokens. + source = generate_source(system_prompt, retry_prompt) + except ProviderError as error: + return None, _redact_provider_error(error, provider), attempt + except ValueError as error: + return None, str(error), attempt + + progress.report( + 'generating', + 0.64, + 'Model returned activity.py', + { + 'draft_activity_source': source, + 'codegen_attempt': attempt, + }, + ) + + if not validate_code: + return source, '', attempt + + report = validate_activity_source_for_request(source, spec, plan) + if report.valid: + return source, '', attempt + + last_error = '\n'.join(report.errors) + progress.report( + 'generating', + 0.65, + 'Validation failed on attempt %d; retrying' % attempt, + ) + + return None, last_error, _CODEGEN_ATTEMPT_LIMIT def _has_refinement(provider): @@ -381,6 +426,7 @@ def _has_refinement(provider): def refine_activity(spec, current_source, current_plan, output_root, provider=None, provider_name='default', + validate_code=True, progress_cb=None, pace=False, package_bundle=False): """Refine an existing activity.py using SEARCH/REPLACE blocks. @@ -498,6 +544,7 @@ def refine_activity(spec, current_source, current_plan, output_root, current_plan, (), progress, + validate_code=validate_code, ) ) if not activity_source: diff --git a/src/jarabe/model/aodqueue.py b/src/jarabe/model/aodqueue.py index 05cd24f22f..bbee8b430e 100644 --- a/src/jarabe/model/aodqueue.py +++ b/src/jarabe/model/aodqueue.py @@ -7,20 +7,30 @@ import threading +_MAX_QUEUED_JOBS = 64 + + class AODJobQueue: """Small local worker queue for Activity-on-Demand jobs.""" - def __init__(self, runner, worker_count=1): + def __init__(self, runner, worker_count=1, max_queued=_MAX_QUEUED_JOBS): self._runner = runner self._worker_count = max(1, worker_count) - self._queue = queue.Queue() + self._queue = queue.Queue(maxsize=max_queued) self._workers = [] self._shutdown = threading.Event() self._lock = threading.Lock() def submit(self, job): self.start() - self._queue.put(job) + try: + self._queue.put_nowait(job) + except queue.Full: + raise RuntimeError( + 'Activity-on-Demand job queue is full (%d pending). ' + 'Wait for running jobs to finish before submitting more.' + % self._queue.maxsize + ) def start(self): with self._lock: diff --git a/src/jarabe/model/aodrag.py b/src/jarabe/model/aodrag.py index a7cf8f85b0..cd725ea6bb 100644 --- a/src/jarabe/model/aodrag.py +++ b/src/jarabe/model/aodrag.py @@ -247,6 +247,11 @@ def _tokens(value): - Implement write_file(file_path) and read_file(file_path) for Journal data. - GTK widgets must be updated on the GTK main thread. - Generated activities should work without network access. +- Prefer sugar3 / sugar-toolkit-gtk3 APIs before raw GTK equivalents: + - sugar3.graphics.style: FONT_SIZE, COLOR_*, zoom() for DPI-aware sizing + - sugar3.graphics.toolbutton.ToolButton: icon toolbar items with tooltip + - sugar3.graphics.alert: NotifyAlert / ConfirmationAlert for in-activity messages + - sugar3.graphics.icon.Icon: Sugar icon widget (icon_name, pixel_size) """ _REFERENCE_DOCUMENTS = ( @@ -380,4 +385,63 @@ def _tokens(value): ), tags=('tool', 'utility', 'example'), ), + RagDocument( + title='sugargame / pygame activity pattern', + text=( + 'sugargame wraps pygame so it runs inside a Sugar GTK3 window. ' + 'Use it ONLY when the activity needs a continuous game loop — ' + 'real-time arcade games, physics simulations, or frame-by-frame ' + 'animation that cannot be driven by GTK signals.\n\n' + 'Structure for a sugargame activity:\n' + ' import sugargame.canvas\n' + ' import pygame\n' + ' from sugar3.activity import activity\n' + ' class GeneratedActivity(activity.Activity):\n' + ' def __init__(self, handle):\n' + ' super().__init__(handle)\n' + ' self._canvas = sugargame.canvas.PygameCanvas(\n' + ' self, main=self._game_loop,\n' + ' modules=[pygame.display, pygame.font])\n' + ' self.set_canvas(self._canvas)\n' + ' self.show_all()\n' + ' self._canvas.run_pygame(self._game_loop)\n' + ' def _game_loop(self):\n' + ' pygame.display.set_caption("Activity")\n' + ' clock = pygame.time.Clock()\n' + ' running = True\n' + ' while running:\n' + ' for event in pygame.event.get():\n' + ' if event.type == pygame.QUIT:\n' + ' running = False\n' + ' # update + draw\n' + ' pygame.display.flip()\n' + ' clock.tick(30)\n' + ' def read_file(self, file_path): pass\n' + ' def write_file(self, file_path): pass\n\n' + 'Still use the Sugar ToolbarBox and Journal persistence. ' + 'For everything else — board games, drawing, quizzes, writing — ' + 'use GTK3 + cairo instead; it integrates better with Sugar.' + ), + tags=('game', 'pygame', 'sugargame', 'arcade', 'animation', 'loop'), + ), + RagDocument( + title='sugar-toolkit-gtk3 preferred APIs', + text=( + 'Always prefer sugar3 / sugar-toolkit-gtk3 wrappers over raw ' + 'GTK equivalents:\n' + '- sugar3.graphics.style.zoom(n): DPI-aware pixel sizes\n' + '- sugar3.graphics.style.COLOR_*: Sugar palette colors\n' + '- sugar3.graphics.style.FONT_SIZE: standard body font size\n' + '- sugar3.graphics.toolbutton.ToolButton(icon_name): toolbar ' + 'icon button with set_tooltip_text()\n' + '- sugar3.graphics.alert.NotifyAlert / ConfirmationAlert: ' + 'in-activity banners instead of Gtk.Dialog\n' + '- sugar3.graphics.icon.Icon(icon_name, pixel_size): Sugar icon\n' + '- sugar3.graphics.toolbarbox.ToolbarBox: the standard toolbar\n' + '- sugar3.activity.widgets.ActivityToolbarButton: title + share\n' + '- sugar3.activity.widgets.StopButton: the stop/close button\n' + 'Only fall back to plain Gtk when no Sugar wrapper exists.' + ), + tags=('api', 'sugar3', 'sugar-toolkit-gtk3', 'toolkit', 'style'), + ), ) diff --git a/src/jarabe/model/aodrefine.py b/src/jarabe/model/aodrefine.py index cf2a6d801e..10751d1e1d 100644 --- a/src/jarabe/model/aodrefine.py +++ b/src/jarabe/model/aodrefine.py @@ -108,36 +108,40 @@ def parse_search_replace(response): text = response.strip() - if text == 'FULLREGEN' or text.startswith('FULLREGEN'): + if text.startswith('FULLREGEN') and SEARCH_MARKER not in text: return None blocks = [] pos = 0 + _NL_DIVIDER = '\n' + DIVIDER_MARKER + _NL_REPLACE = '\n' + REPLACE_MARKER while True: search_start = text.find(SEARCH_MARKER, pos) if search_start < 0: break - divider_start = text.find(DIVIDER_MARKER, search_start + len(SEARCH_MARKER)) - if divider_start < 0: + after_search = search_start + len(SEARCH_MARKER) + + # Anchor to line boundary so '=======' inside source code is ignored. + divider_nl = text.find(_NL_DIVIDER, after_search) + if divider_nl < 0: raise ValueError( 'Malformed SEARCH/REPLACE: missing ======= divider after ' '<<<<<<< SEARCH at position %d.' % search_start ) + after_divider = divider_nl + len(_NL_DIVIDER) - replace_end = text.find(REPLACE_MARKER, divider_start + len(DIVIDER_MARKER)) - if replace_end < 0: + # Same anchoring for >>>>>>> REPLACE. + replace_nl = text.find(_NL_REPLACE, after_divider) + if replace_nl < 0: raise ValueError( 'Malformed SEARCH/REPLACE: missing >>>>>>> REPLACE after ' - '======= divider at position %d.' % divider_start + '======= divider at position %d.' % divider_nl ) - search_text = text[search_start + len(SEARCH_MARKER):divider_start] - replace_text = text[divider_start + len(DIVIDER_MARKER):replace_end] - - search_text = _strip_block_edges(search_text) - replace_text = _strip_block_edges(replace_text) + search_text = _strip_block_edges(text[after_search:divider_nl]) + replace_text = _strip_block_edges(text[after_divider:replace_nl]) if not search_text: raise ValueError( @@ -146,7 +150,7 @@ def parse_search_replace(response): ) blocks.append((search_text, replace_text)) - pos = replace_end + len(REPLACE_MARKER) + pos = replace_nl + len(_NL_REPLACE) if not blocks: if '<<<<<<<' not in text: diff --git a/src/jarabe/model/aodservice.py b/src/jarabe/model/aodservice.py index e4cffa828d..0a83cf9ecb 100644 --- a/src/jarabe/model/aodservice.py +++ b/src/jarabe/model/aodservice.py @@ -12,6 +12,7 @@ from jarabe.model.aodgenerator import restore_generation_result from jarabe.model.aodjobs import AODJob from jarabe.model.aodjobs import AODJobStore +from jarabe.model.aodjobs import STATUS_FAILED from jarabe.model.aodjobs import STATUS_FINISHED from jarabe.model.aodjobs import STATUS_GENERATING from jarabe.model.aodjobs import STATUS_GROUNDING @@ -59,8 +60,9 @@ def __init__(self, job_store=None, worker_count=1, self._queue = AODJobQueue(self._run_job, worker_count=worker_count) def submit_activity(self, spec, provider_name='default', use_rag=True, - output_root=None, callback=None, session_id='', - parent_revision_id='', user_prompt=None): + validate_code=True, output_root=None, callback=None, + session_id='', parent_revision_id='', + user_prompt=None): errors = spec.validate() if errors: raise ValueError('\n'.join(errors)) @@ -74,6 +76,7 @@ def submit_activity(self, spec, provider_name='default', use_rag=True, spec, provider_name=provider_name, use_rag=use_rag, + validate_code=validate_code, output_root=output_root, session_id=session.session_id, parent_revision_id=parent_revision_id, @@ -262,9 +265,12 @@ def _load_jobs(self): ) job.result = None if job.result is None: - job.fail( + job.status = STATUS_FAILED + job.stage = STATUS_FAILED + job.error = ( 'Generated activity artifacts are no longer available.' ) + job.message = job.error self._store.save(job) self._jobs[job.job_id] = job @@ -282,15 +288,13 @@ def _record_user_prompt(self, session_id, job, prompt_text): message_type=TYPE_PROMPT, job_id=job.job_id, ) - self._session_store.append_message(session_id, message) - status = AODMessage.create( ROLE_ASSISTANT, 'Generating Sugar activity...', message_type=TYPE_STATUS, job_id=job.job_id, ) - self._session_store.append_message(session_id, status) + self._session_store.append_messages(session_id, [message, status]) def _run_job(self, job): try: @@ -352,6 +356,7 @@ def _run_job_inner(self, job): provider=provider, provider_name=job.provider_name, use_rag=job.use_rag, + validate_code=job.validate_code, progress_cb=lambda stage, fraction, message, metadata=None: self._pipeline_progress( job, @@ -370,6 +375,10 @@ def _run_job_inner(self, job): logging.exception('Activity-on-Demand job failed') self._mark_failed(job, error) return + except BaseException as error: + logging.exception('Activity-on-Demand worker received fatal signal') + self._mark_failed(job, error) + raise if job.cancel_requested: self._mark_cancelled(job) @@ -395,6 +404,9 @@ def _pipeline_progress(self, job, stage, fraction, message, if job.cancel_requested: raise JobCancelled() + if job.is_terminal(): + return + status = _status_for_pipeline_stage(stage) self._set_progress(job, status, stage, fraction, message, metadata) @@ -413,6 +425,7 @@ def _run_refinement_job(self, job, provider): provider=None, provider_name='local-template', use_rag=job.use_rag, + validate_code=job.validate_code, progress_cb=lambda stage, fraction, message, metadata=None: self._pipeline_progress( job, stage, fraction, message, metadata), @@ -474,6 +487,7 @@ def _run_refinement_job(self, job, provider): job, stage, fraction, message, metadata), pace=True, package_bundle=False, + validate_code=job.validate_code, ) def _set_progress(self, job, status, stage, progress, message, @@ -514,7 +528,6 @@ def _record_finished_revision(self, job, revision): if not job.session_id: return - self._session_store.append_revision(job.session_id, revision) summary = job.result_summary provider = summary.get('provider', job.provider_name) model = summary.get('model', '') @@ -533,7 +546,8 @@ def _record_finished_revision(self, job, revision): job_id=job.job_id, revision_id=revision.revision_id, ) - self._session_store.append_message(job.session_id, message) + self._session_store.append_revision_and_message( + job.session_id, revision, message) def _record_failed_message(self, job): if not job.session_id: @@ -555,6 +569,7 @@ def _status_for_pipeline_stage(stage): 'provider': STATUS_PROVIDER, 'generating': STATUS_GENERATING, 'validating': STATUS_VALIDATING, + 'assembling': STATUS_PACKAGING, 'packaging': STATUS_PACKAGING, 'ready': STATUS_PACKAGING, } diff --git a/src/jarabe/model/aodsessions.py b/src/jarabe/model/aodsessions.py index d6b4d59232..7aaf327c8c 100644 --- a/src/jarabe/model/aodsessions.py +++ b/src/jarabe/model/aodsessions.py @@ -231,6 +231,17 @@ def append_message(self, session_id, message): self.save(session) return session + def append_messages(self, session_id, messages): + """Append multiple messages in one load-mutate-save cycle.""" + with self._lock: + session = self.load(session_id) + if session is None: + return None + for message in messages: + session.messages.append(message) + self.save(session) + return session + def append_revision(self, session_id, revision): with self._lock: session = self.load(session_id) @@ -241,6 +252,18 @@ def append_revision(self, session_id, revision): self.save(session) return session + def append_revision_and_message(self, session_id, revision, message): + """Append a revision and its result message atomically.""" + with self._lock: + session = self.load(session_id) + if session is None: + return None + session.revisions.append(revision) + session.active_revision_id = revision.revision_id + session.messages.append(message) + self.save(session) + return session + def _session_path(self, session_id): safe_id = ''.join( char for char in session_id diff --git a/src/jarabe/model/aodspec.py b/src/jarabe/model/aodspec.py index 78387fa188..6c81116c66 100644 --- a/src/jarabe/model/aodspec.py +++ b/src/jarabe/model/aodspec.py @@ -19,6 +19,8 @@ 'creation', ) +CODE_SIZES = ('compact', 'standard', 'full') + MAX_PROMPT_LENGTH = 12000 TEMPLATES = ( @@ -54,6 +56,7 @@ class ActivitySpec: template: str = 'auto' age_band: str = 'all' learner_goal: str = '' + code_size: str = 'standard' def validate(self): errors = [] @@ -83,6 +86,9 @@ def validate(self): if not isinstance(self.age_band, str) or not self.age_band.strip(): errors.append('Activity age band is required.') + if self.code_size not in CODE_SIZES: + errors.append('Unknown code size: %s' % self.code_size) + return errors def normalized(self): @@ -95,6 +101,7 @@ def normalized(self): template=self.template, age_band=_normalize_spaces(self.age_band), learner_goal=_normalize_spaces(self.learner_goal), + code_size=self.code_size, ) def to_dict(self): @@ -133,6 +140,7 @@ def from_dict(cls, data): template=data.get('template', 'auto'), age_band=data.get('age_band', 'all'), learner_goal=data.get('learner_goal', ''), + code_size=data.get('code_size', 'standard'), ) diff --git a/src/jarabe/model/aodvalidator.py b/src/jarabe/model/aodvalidator.py index f59d90eb95..974a3ade17 100644 --- a/src/jarabe/model/aodvalidator.py +++ b/src/jarabe/model/aodvalidator.py @@ -24,8 +24,10 @@ 'json', 'logging', 'math', + 'pygame', 'random', 'sugar3', + 'sugargame', } FORBIDDEN_IMPORT_ROOTS = { @@ -256,7 +258,7 @@ def validate_activity_source_for_request(source, spec, plan=None): ) request_words = _tokens(request) - overlap = request_words.intersection(_tokens(source_lower)) + overlap = request_words.intersection(_tokens(source)) if request_words and len(overlap) < min(2, len(request_words)): report.warnings.append( 'Generated source contains little vocabulary from the request.' From 9a0421597bd6a8ef6d76a9f0df3a31ad4b3a5d64 Mon Sep 17 00:00:00 2001 From: Ashutoshx7 Date: Sun, 28 Jun 2026 23:33:15 +0530 Subject: [PATCH 11/28] Harden AOD backend: cache RAG corpus, backoff retries, fix tempdir leak - aodrag: cache build_corpus() result at process level, invalidated by bundle directory mtime so new installs are picked up automatically - aodpipeline: add 1s/2s/4s exponential backoff between codegen retry attempts instead of retrying immediately after validation failure - aodpreview: add cleanup() method to PreviewActivity that removes the tempfile.mkdtemp() activity root; previously accumulated on every generation with no cleanup - homebox: store live preview instance in _live_preview_activity and call cleanup() when the preview is replaced or cleared --- src/jarabe/desktop/homebox.py | 8 ++++++ src/jarabe/model/aodpipeline.py | 4 +++ src/jarabe/model/aodpreview.py | 8 ++++++ src/jarabe/model/aodrag.py | 51 ++++++++++++++++++++++++++++++++- 4 files changed, 70 insertions(+), 1 deletion(-) diff --git a/src/jarabe/desktop/homebox.py b/src/jarabe/desktop/homebox.py index 63fa121ab1..f76530fe2d 100644 --- a/src/jarabe/desktop/homebox.py +++ b/src/jarabe/desktop/homebox.py @@ -339,6 +339,7 @@ def __init__(self): self._studio_prompt_labels = [] self._preview_content_box = None self._live_preview_canvas = None + self._live_preview_activity = None self._preview_empty_title = None self._preview_empty_note = None self._preview_generation_spinner = None @@ -1415,6 +1416,12 @@ def _clear_activity_preview(self): for child in self._preview_content_box.get_children(): self._preview_content_box.remove(child) self._live_preview_canvas = None + if self._live_preview_activity is not None: + try: + self._live_preview_activity.cleanup() + except Exception: + pass + self._live_preview_activity = None self._preview_empty_title = None self._preview_empty_note = None self._preview_generation_spinner = None @@ -4897,6 +4904,7 @@ def _render_live_generated_activity_preview(self, result): box.pack_start(canvas, True, True, 0) self._live_preview_canvas = canvas + self._live_preview_activity = preview self._preview_content_box.pack_start(shell, True, True, 0) shell.show_all() return True diff --git a/src/jarabe/model/aodpipeline.py b/src/jarabe/model/aodpipeline.py index 9e7f79178e..d8942c9303 100644 --- a/src/jarabe/model/aodpipeline.py +++ b/src/jarabe/model/aodpipeline.py @@ -414,6 +414,10 @@ def _generate_activity_source_with_provider(provider, spec, plan, references, 0.65, 'Validation failed on attempt %d; retrying' % attempt, ) + # Brief backoff before the next attempt so we don't hammer the + # provider immediately after a validation failure (1s, 2s, 4s…). + if attempt < _CODEGEN_ATTEMPT_LIMIT: + time.sleep(min(2.0 ** (attempt - 1), 4.0)) return None, last_error, _CODEGEN_ATTEMPT_LIMIT diff --git a/src/jarabe/model/aodpreview.py b/src/jarabe/model/aodpreview.py index 8236762818..9710aff7ce 100644 --- a/src/jarabe/model/aodpreview.py +++ b/src/jarabe/model/aodpreview.py @@ -16,6 +16,7 @@ import logging import os import re +import shutil import tempfile import gi @@ -211,6 +212,13 @@ def get_activity_root(self): def get_documents_path(self): return tempfile.gettempdir() + def cleanup(self): + """Remove the temporary activity root directory if one was created.""" + root = self._activity_root + if root and os.path.isdir(root): + shutil.rmtree(root, ignore_errors=True) + self._activity_root = '' + def write_file(self, file_path): pass diff --git a/src/jarabe/model/aodrag.py b/src/jarabe/model/aodrag.py index cd725ea6bb..9a3f7d03b8 100644 --- a/src/jarabe/model/aodrag.py +++ b/src/jarabe/model/aodrag.py @@ -5,6 +5,7 @@ from dataclasses import dataclass import os import re +import threading DEFAULT_ACTIVITY_ROOTS = ( @@ -12,6 +13,10 @@ os.path.expanduser('~/Activities'), ) +_corpus_cache = None +_corpus_lock = threading.Lock() +_corpus_mtime = 0.0 + @dataclass(frozen=True) class RagDocument: @@ -22,9 +27,36 @@ class RagDocument: def build_corpus(activity_roots=None): - documents = list(_REFERENCE_DOCUMENTS) + """Build corpus of RAG documents from installed Sugar activities. + + Result is cached at process level and invalidated when activity bundles + are added or removed. Pass activity_roots to force a rebuild with + different roots. + """ + global _corpus_cache, _corpus_mtime + roots = activity_roots or DEFAULT_ACTIVITY_ROOTS + # If custom roots provided, bypass cache + if activity_roots is not None: + return _build_corpus_uncached(roots) + + # Check if we can use cached corpus + with _corpus_lock: + current_mtime = _get_roots_mtime(roots) + if _corpus_cache is not None and current_mtime == _corpus_mtime: + return _corpus_cache + + # Cache miss or invalidated — rebuild + documents = _build_corpus_uncached(roots) + _corpus_cache = documents + _corpus_mtime = current_mtime + return documents + + +def _build_corpus_uncached(roots): + documents = list(_REFERENCE_DOCUMENTS) + for root in roots: if not os.path.isdir(root): continue @@ -78,6 +110,23 @@ def build_corpus(activity_roots=None): return documents +def _get_roots_mtime(roots): + """Return max mtime of all activity bundle directories under roots.""" + latest = 0.0 + for root in roots: + if not os.path.isdir(root): + continue + try: + for bundle_name in os.listdir(root): + bundle_path = os.path.join(root, bundle_name) + if os.path.isdir(bundle_path): + stat = os.stat(bundle_path) + latest = max(latest, stat.st_mtime) + except OSError: + continue + return latest + + def search(query, limit=5, template='', corpus=None): documents = corpus if corpus is not None else build_corpus() query_words = _tokens(query) From b38cb89756f3b7544a25f20b1b38e44c1116d855 Mon Sep 17 00:00:00 2001 From: Ashutoshx7 Date: Mon, 29 Jun 2026 00:24:44 +0530 Subject: [PATCH 12/28] Implement widget-specific click targeting for Live Edit Mode When Live Edit Mode is ON, clicking any widget in the live-generated activity preview now identifies exactly what was clicked (e.g. 'button: Reset', 'drawing canvas', 'text area', 'slider') instead of always reporting 'activity canvas'. Implementation: - Walk the live-generated GTK widget tree recursively on preview render (_walk_and_attach_live_edit), attaching button-press-event handlers to every identifiable leaf widget (Button, ToolButton, Entry, DrawingArea, Label, Scale, TextView, Grid, Toolbar) - _describe_widget_for_live_edit derives a human-readable target string from widget type + label/tooltip/placeholder - __live_edit_widget_press_cb updates the target label, applies a yellow CSS highlight ring (.live-edit-selected), and returns True to stop propagation (prevents outer shell from overriding with 'activity canvas') - Clicking empty space still falls through to the shell handler and reports 'activity canvas' as before - _detach_live_edit_handlers disconnects all handlers and removes the CSS highlight when the preview is cleared or replaced --- src/jarabe/desktop/homebox.py | 132 ++++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) diff --git a/src/jarabe/desktop/homebox.py b/src/jarabe/desktop/homebox.py index f76530fe2d..4f3b7ddea4 100644 --- a/src/jarabe/desktop/homebox.py +++ b/src/jarabe/desktop/homebox.py @@ -376,6 +376,8 @@ def __init__(self): self._live_edit_on_button = None self._live_edit_off_button = None self._live_edit_enabled = True + self._live_edit_handler_ids = [] + self._live_edit_highlighted = None self._chat_messages_box = None self._chat_entry = None self._chat_scroll = None @@ -1411,6 +1413,7 @@ def _create_activity_preview(self): return preview def _clear_activity_preview(self): + self._detach_live_edit_handlers() if self._preview_content_box is None: return for child in self._preview_content_box.get_children(): @@ -4039,6 +4042,10 @@ def _ensure_css(self): button.create-ai-preview-change label { color: %(studio_lavender_text)s; } + .live-edit-selected { + border: 2px solid rgba(255, 200, 0, 0.85); + border-radius: 3px; + } .create-ai-learning-sidebar { border-radius: 12px; border: 1px solid %(studio_edge)s; @@ -4236,11 +4243,132 @@ def _set_live_edit_target(self, target): self._live_edit_status_label.set_text( _('Target selected: %s') % target) + def _attach_live_edit_handlers_to_preview(self, canvas, toolbar): + """Walk the live preview widget tree and attach click-to-select handlers.""" + self._detach_live_edit_handlers() + if isinstance(toolbar, Gtk.Widget): + self._walk_and_attach_live_edit(toolbar, in_toolbar=True) + if isinstance(canvas, Gtk.Widget): + self._walk_and_attach_live_edit(canvas, in_toolbar=False) + + def _detach_live_edit_handlers(self): + if self._live_edit_highlighted is not None: + try: + self._live_edit_highlighted.get_style_context().remove_class( + 'live-edit-selected') + except Exception: + pass + self._live_edit_highlighted = None + for widget, handler_id in self._live_edit_handler_ids: + try: + widget.disconnect(handler_id) + except Exception: + pass + self._live_edit_handler_ids = [] + + def _walk_and_attach_live_edit(self, widget, in_toolbar=False, depth=0): + if depth > 12: + return + desc = self._describe_widget_for_live_edit(widget, in_toolbar) + if desc: + try: + widget.add_events(Gdk.EventMask.BUTTON_PRESS_MASK) + hid = widget.connect( + 'button-press-event', + self.__live_edit_widget_press_cb, + desc, + ) + self._live_edit_handler_ids.append((widget, hid)) + except Exception: + pass + # Recurse into children + if isinstance(widget, Gtk.Toolbar): + for i in range(widget.get_n_items()): + item = widget.get_nth_item(i) + if item is not None: + self._walk_and_attach_live_edit(item, True, depth + 1) + elif isinstance(widget, Gtk.ToolItem): + child = widget.get_child() + if child is not None: + self._walk_and_attach_live_edit(child, True, depth + 1) + elif isinstance(widget, Gtk.Notebook): + for i in range(widget.get_n_pages()): + page = widget.get_nth_page(i) + if page is not None: + self._walk_and_attach_live_edit(page, False, depth + 1) + elif isinstance(widget, Gtk.Stack): + visible = widget.get_visible_child() + if visible is not None: + self._walk_and_attach_live_edit(visible, False, depth + 1) + elif hasattr(widget, 'get_children'): + for child in widget.get_children(): + self._walk_and_attach_live_edit(child, in_toolbar, depth + 1) + + def _describe_widget_for_live_edit(self, widget, in_toolbar): + """Return a human-readable target name for a widget, or None to skip.""" + if isinstance(widget, Gtk.DrawingArea): + return _('drawing canvas') + if isinstance(widget, Gtk.ToolButton): + tip = (widget.get_tooltip_text() or '').strip() + lbl = (widget.get_label() or '').strip() + name = tip or lbl + return (_('toolbar: %s') % name) if name else _('toolbar button') + if isinstance(widget, Gtk.SpinButton): + return _('number input') + if isinstance(widget, Gtk.Button): + lbl = (widget.get_label() or '').strip() + tip = (widget.get_tooltip_text() or '').strip() + name = lbl or tip + if name: + return ((_('toolbar button: %s') % name) + if in_toolbar + else (_('button: %s') % name[:40])) + return _('toolbar button') if in_toolbar else _('button') + if isinstance(widget, Gtk.Entry): + ph = (widget.get_placeholder_text() or '').strip() + return (_('input: %s') % ph[:40]) if ph else _('text input') + if isinstance(widget, Gtk.TextView): + return _('text area') + if isinstance(widget, Gtk.Scale): + return _('slider') + if isinstance(widget, Gtk.Label): + text = (widget.get_text() or '').strip() + if text and not text.startswith('<') and len(text) > 2: + return _('label: %s') % text[:30] + return None + if isinstance(widget, (Gtk.Toolbar, Gtk.ToolbarBox)): + return _('activity toolbar') + if isinstance(widget, Gtk.Grid): + return _('grid') + return None + + def __live_edit_widget_press_cb(self, widget, event, description): + if not self._live_edit_enabled: + return False + self._set_live_edit_target(description) + if self._live_edit_highlighted is not None: + try: + self._live_edit_highlighted.get_style_context().remove_class( + 'live-edit-selected') + except Exception: + pass + self._live_edit_highlighted = widget + try: + widget.get_style_context().add_class('live-edit-selected') + except Exception: + pass + return True # stop propagation so outer shell doesn't overwrite target + def __preview_target_button_press_event_cb(self, widget, event, target): if self._live_edit_enabled: self._set_live_edit_target(target) return False + + if self._live_edit_enabled: + self._set_live_edit_target(target) + return False + def _update_planner_hint(self): if self._planner_hint is None: return @@ -4905,6 +5033,10 @@ def _render_live_generated_activity_preview(self, result): self._live_preview_canvas = canvas self._live_preview_activity = preview + self._attach_live_edit_handlers_to_preview( + canvas, + toolbar if isinstance(toolbar, Gtk.Widget) else None, + ) self._preview_content_box.pack_start(shell, True, True, 0) shell.show_all() return True From c2c9826385b7aa525688d6f713b869b927f37a59 Mon Sep 17 00:00:00 2001 From: Ashutoshx7 Date: Mon, 29 Jun 2026 00:56:28 +0530 Subject: [PATCH 13/28] Fix preview, improve chat responses, and add activity-specific sidebar challenges - Fix blank preview: move live-edit handler attachment after show_all() and wrap in try/except so GLib cannot swallow exceptions before pack_start - Fix walker: return after attaching to leaf widget to avoid double-handler on button's internal label child (which broke button interaction) - After generation, emit a conversational AI chat bubble instead of a plain status log line, describing what was built (kind, summary, interaction model) - Learning sidebar challenges are now derived from the AOD plan (template, activity_kind, features, learner_steps) and replaced after each generation instead of showing 8 hardcoded generic prompts --- src/jarabe/desktop/homebox.py | 220 +++++++++++++++++++++++++++++++--- 1 file changed, 201 insertions(+), 19 deletions(-) diff --git a/src/jarabe/desktop/homebox.py b/src/jarabe/desktop/homebox.py index 4f3b7ddea4..6780c75b71 100644 --- a/src/jarabe/desktop/homebox.py +++ b/src/jarabe/desktop/homebox.py @@ -388,6 +388,8 @@ def __init__(self): self._sidebar_chat_scroll = None self._sidebar_refine_entry = None self._sidebar_refine_status_label = None + self._sidebar_challenge_box = None + self._sidebar_level_label = None self._prompt_is_placeholder = False self._preview_is_fullscreen = False self._preview_fullscreen_button = None @@ -2933,21 +2935,23 @@ def _create_learning_sidebar(self): tabs.pack_start(self._create_sidebar_tab(_('Annotations'), False), True, True, 0) - level = Gtk.Label(_('Level 1 unlocked - 8 starter challenges')) - level.get_style_context().add_class('create-ai-meta-label') - level.set_xalign(0) - box.pack_start(level, False, False, 0) - level.show() + self._sidebar_level_label = Gtk.Label( + _('Level 1 unlocked - 8 starter challenges')) + self._sidebar_level_label.get_style_context().add_class( + 'create-ai-meta-label') + self._sidebar_level_label.set_xalign(0) + box.pack_start(self._sidebar_level_label, False, False, 0) + self._sidebar_level_label.show() scroll = Gtk.ScrolledWindow() scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) box.pack_start(scroll, True, True, 0) scroll.show() - challenge_box = Gtk.VBox(spacing=style.zoom(8)) - challenge_box.set_border_width(style.zoom(2)) - scroll.add_with_viewport(challenge_box) - challenge_box.show() + self._sidebar_challenge_box = Gtk.VBox(spacing=style.zoom(8)) + self._sidebar_challenge_box.set_border_width(style.zoom(2)) + scroll.add_with_viewport(self._sidebar_challenge_box) + self._sidebar_challenge_box.show() challenges = [ _('Rename the activity title in your own words.'), @@ -2960,8 +2964,8 @@ def _create_learning_sidebar(self): _('Export when the preview feels ready.'), ] for text in challenges: - challenge_box.pack_start(self._create_challenge_card(text), - False, False, 0) + self._sidebar_challenge_box.pack_start( + self._create_challenge_card(text), False, False, 0) panel.show() return panel @@ -4271,6 +4275,7 @@ def _walk_and_attach_live_edit(self, widget, in_toolbar=False, depth=0): return desc = self._describe_widget_for_live_edit(widget, in_toolbar) if desc: + # Leaf/interactive widget — attach handler and stop recursing try: widget.add_events(Gdk.EventMask.BUTTON_PRESS_MASK) hid = widget.connect( @@ -4281,7 +4286,8 @@ def _walk_and_attach_live_edit(self, widget, in_toolbar=False, depth=0): self._live_edit_handler_ids.append((widget, hid)) except Exception: pass - # Recurse into children + return # Don't recurse into interactive widget's internal children + # Container widget — recurse into children if isinstance(widget, Gtk.Toolbar): for i in range(widget.get_n_items()): item = widget.get_nth_item(i) @@ -4301,7 +4307,11 @@ def _walk_and_attach_live_edit(self, widget, in_toolbar=False, depth=0): if visible is not None: self._walk_and_attach_live_edit(visible, False, depth + 1) elif hasattr(widget, 'get_children'): - for child in widget.get_children(): + try: + children = widget.get_children() + except Exception: + return + for child in children: self._walk_and_attach_live_edit(child, in_toolbar, depth + 1) def _describe_widget_for_live_edit(self, widget, in_toolbar): @@ -5033,12 +5043,18 @@ def _render_live_generated_activity_preview(self, result): self._live_preview_canvas = canvas self._live_preview_activity = preview - self._attach_live_edit_handlers_to_preview( - canvas, - toolbar if isinstance(toolbar, Gtk.Widget) else None, - ) self._preview_content_box.pack_start(shell, True, True, 0) shell.show_all() + GObject.idle_add(self._refresh_preview_layout) + # Attach live-edit handlers after the shell is shown so any failure + # here never blanks the preview. + try: + self._attach_live_edit_handlers_to_preview( + canvas, + toolbar if isinstance(toolbar, Gtk.Widget) else None, + ) + except Exception: + logging.exception('Could not attach live edit handlers to preview') return True def _detach_preview_widget(self, widget): @@ -7238,15 +7254,181 @@ def _generation_finished_cb(self, result): self._selected_version = self._aod_active_revision_id self._refresh_version_history() self._append_chat_status(provider_status) - self._append_chat_status( - _('Generated. Select a part or type a refinement.')) + self._append_chat_message( + self._build_generation_chat_response(result, plan), + from_user=False, + ) self._set_chat_entry_sensitive(True) self._append_sidebar_status(provider_status) self._append_sidebar_message( _('Generated. Type another prompt here to refine ' 'this activity.')) + self._update_sidebar_challenges(result, plan) return False + def _build_generation_chat_response(self, result, plan): + """Build a conversational AI response describing what was generated.""" + spec = result.spec + name = spec.name or _('the activity') + summary = plan.get('summary', '') + activity_kind = plan.get('activity_kind', '') + interaction = plan.get('interaction_model', '') + template = plan.get('template', '') + learner_steps = plan.get('learner_steps') or [] + features = plan.get('features') or [] + + # Opener + opener = _("I've built %(name)s for you!") % {'name': name} + + # What kind of activity + kind_parts = [p for p in (activity_kind, template) if p] + kind_line = '' + if kind_parts: + kind_line = ' ' + _('It\'s a %s.') % ' / '.join(kind_parts) + + # Summary sentence + summary_line = (' ' + summary) if summary else '' + + # Interaction model + interaction_line = '' + if interaction: + interaction_line = ' ' + _('Interaction: %s.') % interaction + + # First 2 learner steps or features + bullet_items = (learner_steps or features)[:2] + bullets = '' + if bullet_items: + bullets = '\n' + '\n'.join( + '• %s' % step for step in bullet_items) + + # Closer + closer = '\n\n' + _( + 'Click any part of the preview to select it, then ' + 'describe your change. Or type a refinement below.') + + return (opener + kind_line + summary_line + + interaction_line + bullets + closer) + + def _update_sidebar_challenges(self, result, plan): + """Replace the learning sidebar challenge cards with activity-specific ones.""" + if self._sidebar_challenge_box is None: + return + + for child in self._sidebar_challenge_box.get_children(): + self._sidebar_challenge_box.remove(child) + + challenges = self._build_activity_challenges(result, plan) + + for text in challenges: + card = self._create_challenge_card(text) + self._sidebar_challenge_box.pack_start(card, False, False, 0) + card.show_all() + + if self._sidebar_level_label is not None: + count = len(challenges) + self._sidebar_level_label.set_text( + _('Level 1 unlocked - %(count)d challenges') % { + 'count': count}) + + def _build_activity_challenges(self, result, plan): + """Return a list of activity-specific challenge strings from the plan.""" + template = plan.get('template', '').lower() + activity_kind = plan.get('activity_kind', '').lower() + interaction = plan.get('interaction_model', '').lower() + features = [str(f).lower() for f in (plan.get('features') or [])] + learner_steps = [str(s) for s in (plan.get('learner_steps') or [])] + spec = result.spec + name = spec.name or _('the activity') + + challenges = [] + + # 1 — always: rename the title to something personal + challenges.append( + _('Rename the activity title to reflect your own topic.')) + + # 2 — learner steps → suggest modifying one of them + if learner_steps: + step_hint = learner_steps[0] + challenges.append( + _('Find the code that handles: "%(step)s" and add a hint ' + 'message for the learner.') % {'step': step_hint}) + + # 3 — template/kind specific + if 'chess' in template or 'chess' in activity_kind: + challenges.append( + _('Locate the move-validation logic and add a console print ' + 'for each illegal move attempt.')) + challenges.append( + _('Change the board colors and explain why you chose them.')) + elif 'carrom' in template or 'carrom' in activity_kind: + challenges.append( + _('Find where the striker is drawn and change its color.')) + challenges.append( + _('Locate the score counter and add a "foul" penalty.')) + elif 'draw' in template or 'paint' in template or 'draw' in activity_kind: + challenges.append( + _('Change the default brush size and add a label showing ' + 'the current size.')) + challenges.append( + _('Add a "Clear canvas" button that resets the drawing.')) + elif 'quiz' in template or 'quiz' in activity_kind: + challenges.append( + _('Add one new question and answer to the quiz data.')) + challenges.append( + _('Change the feedback message shown when a learner answers ' + 'incorrectly.')) + elif 'puzzle' in template or 'puzzle' in activity_kind: + challenges.append( + _('Find where the puzzle pieces are created and change one ' + "piece's image or color.")) + elif 'story' in template or 'story' in activity_kind: + challenges.append( + _('Replace one paragraph of the story with your own version.')) + challenges.append( + _('Add a learner name field that appears at the start of ' + 'the story.')) + else: + # Generic fallbacks for unknown templates + challenges.append( + _('Find the main label or title widget and personalise the ' + 'text.')) + challenges.append( + _('Change one button label to make it more descriptive for ' + 'learners.')) + + # 4 — interaction-model specific + if 'turn' in interaction or 'multiplayer' in interaction: + challenges.append( + _('Find where turns are tracked and add a visual indicator ' + 'showing whose turn it is.')) + elif 'timed' in interaction or 'timer' in interaction: + challenges.append( + _('Locate the timer logic and change the countdown duration.')) + + # 5 — feature-based + if any('journal' in f for f in features): + challenges.append( + _('Trace the write_file / read_file methods and describe ' + 'what data is saved to the Journal.')) + if any('score' in f for f in features): + challenges.append( + _('Find the score variable and add a "high score" display ' + 'that persists across sessions.')) + if any('color' in f or 'colour' in f for f in features): + challenges.append( + _('Change the color scheme in the activity and note which ' + 'variable controls each color.')) + + # 6 — always: Journal connection and export + challenges.append( + _('Describe what %(name)s teaches and why it matters for ' + 'learners your age.') % {'name': name}) + challenges.append( + _('Export the activity and test it by opening the XO file ' + 'in another window.')) + + return challenges[:8] + def _generation_provider_status_text(self, result, plan): fallback_reason = plan.get('provider_fallback_reason', '') if fallback_reason: From fa4110b5bc9221b62c51ebd47211ee535c9d00b3 Mon Sep 17 00:00:00 2001 From: Ashutoshx7 Date: Mon, 29 Jun 2026 01:15:03 +0530 Subject: [PATCH 14/28] Fix sidebar visibility and shorten chat bubbles - Remove 1900px min-width from studio workspace so the three-panel layout fits within a standard 1366px screen without clipping the sidebar - Shrink learning sidebar from 380px to 260px to leave more room for preview - Replace single long AI chat blob with 2-4 short separate bubbles: name+kind, one-line summary, first learner step, action prompt --- src/jarabe/desktop/homebox.py | 70 +++++++++++++++++------------------ 1 file changed, 34 insertions(+), 36 deletions(-) diff --git a/src/jarabe/desktop/homebox.py b/src/jarabe/desktop/homebox.py index 6780c75b71..9bc596c27a 100644 --- a/src/jarabe/desktop/homebox.py +++ b/src/jarabe/desktop/homebox.py @@ -1081,7 +1081,6 @@ def _create_option_card(self, group_name, value, title, detail, def _create_studio_view(self): workspace = Gtk.EventBox() workspace.get_style_context().add_class('create-ai-studio-workspace') - workspace.set_size_request(style.zoom(1900), -1) workspace.set_margin_top(style.zoom(6)) studio = Gtk.VBox(spacing=style.zoom(12)) @@ -2865,7 +2864,7 @@ def _create_live_edit_panel(self): def _create_learning_sidebar(self): panel = Gtk.EventBox() panel.get_style_context().add_class('create-ai-learning-sidebar') - panel.set_size_request(style.zoom(380), -1) + panel.set_size_request(style.zoom(260), -1) box = Gtk.VBox(spacing=style.zoom(9)) box.set_border_width(style.zoom(11)) @@ -7254,10 +7253,11 @@ def _generation_finished_cb(self, result): self._selected_version = self._aod_active_revision_id self._refresh_version_history() self._append_chat_status(provider_status) - self._append_chat_message( - self._build_generation_chat_response(result, plan), - from_user=False, - ) + chat_msgs = self._build_generation_chat_messages(result, plan) + for i, msg in enumerate(chat_msgs): + self._append_chat_message( + msg, from_user=False, + scroll=(i == len(chat_msgs) - 1)) self._set_chat_entry_sensitive(True) self._append_sidebar_status(provider_status) self._append_sidebar_message( @@ -7266,8 +7266,8 @@ def _generation_finished_cb(self, result): self._update_sidebar_challenges(result, plan) return False - def _build_generation_chat_response(self, result, plan): - """Build a conversational AI response describing what was generated.""" + def _build_generation_chat_messages(self, result, plan): + """Return a list of short chat bubbles describing the generated activity.""" spec = result.spec name = spec.name or _('the activity') summary = plan.get('summary', '') @@ -7277,37 +7277,35 @@ def _build_generation_chat_response(self, result, plan): learner_steps = plan.get('learner_steps') or [] features = plan.get('features') or [] - # Opener - opener = _("I've built %(name)s for you!") % {'name': name} + msgs = [] - # What kind of activity + # Bubble 1 — short opener with name + kind kind_parts = [p for p in (activity_kind, template) if p] - kind_line = '' if kind_parts: - kind_line = ' ' + _('It\'s a %s.') % ' / '.join(kind_parts) - - # Summary sentence - summary_line = (' ' + summary) if summary else '' - - # Interaction model - interaction_line = '' - if interaction: - interaction_line = ' ' + _('Interaction: %s.') % interaction - - # First 2 learner steps or features - bullet_items = (learner_steps or features)[:2] - bullets = '' - if bullet_items: - bullets = '\n' + '\n'.join( - '• %s' % step for step in bullet_items) - - # Closer - closer = '\n\n' + _( - 'Click any part of the preview to select it, then ' - 'describe your change. Or type a refinement below.') - - return (opener + kind_line + summary_line - + interaction_line + bullets + closer) + msgs.append( + _("Done! I built %(name)s — %(kind)s.") % { + 'name': name, + 'kind': kind_parts[0], + }) + else: + msgs.append(_("Done! %(name)s is ready.") % {'name': name}) + + # Bubble 2 — one-line summary or interaction model + detail = summary or ( + _('Interaction: %s') % interaction if interaction else '') + if detail: + msgs.append(detail[:120]) + + # Bubble 3 — first learner step or feature as a teaser + teaser = next(iter(learner_steps or features), '') + if teaser: + msgs.append('• %s' % str(teaser)[:100]) + + # Final bubble — action prompt + msgs.append(_('Click anywhere in the preview to pick a target, ' + 'then tell me what to change.')) + + return msgs def _update_sidebar_challenges(self, result, plan): """Replace the learning sidebar challenge cards with activity-specific ones.""" From 227ca1672bbd4c6f89d1abde446e3551bb8a4d5b Mon Sep 17 00:00:00 2001 From: Ashutoshx7 Date: Mon, 29 Jun 2026 01:20:35 +0530 Subject: [PATCH 15/28] Add slide animation to sidebar open/close via Gtk.Revealer Wrap the learning sidebar in a Gtk.Revealer with SLIDE_LEFT transition (200ms). Toggle reveal_child() instead of show()/hide() so the panel slides in/out smoothly. Fullscreen mode also animates through the revealer. --- src/jarabe/desktop/homebox.py | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/src/jarabe/desktop/homebox.py b/src/jarabe/desktop/homebox.py index 9bc596c27a..685e836741 100644 --- a/src/jarabe/desktop/homebox.py +++ b/src/jarabe/desktop/homebox.py @@ -320,6 +320,7 @@ def __init__(self): self._code_size_combo = None self._sidebar_visible = True self._sidebar_toggle_button = None + self._sidebar_revealer = None self._template_hint = None self._planner_hint = None self._provider_combo = None @@ -1098,7 +1099,14 @@ def _create_studio_view(self): body.pack_start(self._create_studio_preview_panel(), True, True, 0) self._studio_right_panel = self._create_learning_sidebar() - body.pack_start(self._studio_right_panel, False, False, 0) + self._sidebar_revealer = Gtk.Revealer() + self._sidebar_revealer.set_transition_type( + Gtk.RevealerTransitionType.SLIDE_LEFT) + self._sidebar_revealer.set_transition_duration(200) + self._sidebar_revealer.add(self._studio_right_panel) + self._sidebar_revealer.set_reveal_child(True) + self._sidebar_revealer.show() + body.pack_start(self._sidebar_revealer, False, False, 0) footer = Gtk.HBox(spacing=style.zoom(8)) footer.get_style_context().add_class('create-ai-studio-footer') @@ -7503,17 +7511,17 @@ def __preview_fullscreen_toggle_cb(self, button): if self._preview_is_fullscreen: if self._studio_left_panel is not None: self._studio_left_panel.hide() - if self._studio_right_panel is not None: - self._studio_right_panel.hide() + if self._sidebar_revealer is not None: + self._sidebar_revealer.set_reveal_child(False) if self._preview_fullscreen_button is not None: self._preview_fullscreen_button.set_label( _('⛶ Exit Fullscreen')) else: if self._studio_left_panel is not None: self._studio_left_panel.show() - if self._studio_right_panel is not None: - if self._sidebar_visible: - self._studio_right_panel.show() + if self._sidebar_revealer is not None: + self._sidebar_revealer.set_reveal_child( + self._sidebar_visible) if self._preview_fullscreen_button is not None: self._preview_fullscreen_button.set_label( _('⛶ Fullscreen')) @@ -7521,11 +7529,8 @@ def __preview_fullscreen_toggle_cb(self, button): def __sidebar_toggle_cb(self, button): self._sidebar_visible = not self._sidebar_visible - if self._studio_right_panel is not None: - if self._sidebar_visible: - self._studio_right_panel.show() - else: - self._studio_right_panel.hide() + if self._sidebar_revealer is not None: + self._sidebar_revealer.set_reveal_child(self._sidebar_visible) if self._sidebar_toggle_button is not None: self._sidebar_toggle_button.set_label( _('◀ Sidebar') if self._sidebar_visible else _('▶ Sidebar')) From 7a8edeacbac4bd94dec926eee59164b42df54d6e Mon Sep 17 00:00:00 2001 From: Ashutoshx7 Date: Mon, 29 Jun 2026 01:26:35 +0530 Subject: [PATCH 16/28] Replace Revealer with manual smoothstep sidebar animation Gtk.Revealer keeps its full width allocated during the slide, so the preview panel would jump to fill the freed space only when the animation ended. Replace with a 16ms GLib timer that directly changes the sidebar set_size_request() each frame using a smoothstep ease (250ms). The HBox reallocates on every tick, so the preview expands in perfect sync with the sidebar closing. - _animate_sidebar(opening): starts the animation, shows panel before fade-in - _sidebar_anim_tick(): smoothstep interpolation, hides panel at end of close - _sidebar_snap(): instant snap for fullscreen toggle (no animation) --- src/jarabe/desktop/homebox.py | 90 ++++++++++++++++++++++++++++------- 1 file changed, 74 insertions(+), 16 deletions(-) diff --git a/src/jarabe/desktop/homebox.py b/src/jarabe/desktop/homebox.py index 685e836741..9367b98a2b 100644 --- a/src/jarabe/desktop/homebox.py +++ b/src/jarabe/desktop/homebox.py @@ -321,6 +321,10 @@ def __init__(self): self._sidebar_visible = True self._sidebar_toggle_button = None self._sidebar_revealer = None + self._sidebar_anim_id = 0 + self._sidebar_anim_from_w = 0 + self._sidebar_anim_to_w = 0 + self._sidebar_anim_start_us = 0 self._template_hint = None self._planner_hint = None self._provider_combo = None @@ -1099,14 +1103,7 @@ def _create_studio_view(self): body.pack_start(self._create_studio_preview_panel(), True, True, 0) self._studio_right_panel = self._create_learning_sidebar() - self._sidebar_revealer = Gtk.Revealer() - self._sidebar_revealer.set_transition_type( - Gtk.RevealerTransitionType.SLIDE_LEFT) - self._sidebar_revealer.set_transition_duration(200) - self._sidebar_revealer.add(self._studio_right_panel) - self._sidebar_revealer.set_reveal_child(True) - self._sidebar_revealer.show() - body.pack_start(self._sidebar_revealer, False, False, 0) + body.pack_start(self._studio_right_panel, False, False, 0) footer = Gtk.HBox(spacing=style.zoom(8)) footer.get_style_context().add_class('create-ai-studio-footer') @@ -7511,17 +7508,14 @@ def __preview_fullscreen_toggle_cb(self, button): if self._preview_is_fullscreen: if self._studio_left_panel is not None: self._studio_left_panel.hide() - if self._sidebar_revealer is not None: - self._sidebar_revealer.set_reveal_child(False) + self._sidebar_snap(False) if self._preview_fullscreen_button is not None: self._preview_fullscreen_button.set_label( _('⛶ Exit Fullscreen')) else: if self._studio_left_panel is not None: self._studio_left_panel.show() - if self._sidebar_revealer is not None: - self._sidebar_revealer.set_reveal_child( - self._sidebar_visible) + self._sidebar_snap(self._sidebar_visible) if self._preview_fullscreen_button is not None: self._preview_fullscreen_button.set_label( _('⛶ Fullscreen')) @@ -7529,12 +7523,76 @@ def __preview_fullscreen_toggle_cb(self, button): def __sidebar_toggle_cb(self, button): self._sidebar_visible = not self._sidebar_visible - if self._sidebar_revealer is not None: - self._sidebar_revealer.set_reveal_child(self._sidebar_visible) if self._sidebar_toggle_button is not None: self._sidebar_toggle_button.set_label( _('◀ Sidebar') if self._sidebar_visible else _('▶ Sidebar')) - self._refresh_preview_layout() + self._animate_sidebar(self._sidebar_visible) + + def _sidebar_full_width(self): + return style.zoom(260) + + def _sidebar_snap(self, visible): + """Instantly set sidebar to its final state (no animation).""" + if self._sidebar_anim_id: + GLib.source_remove(self._sidebar_anim_id) + self._sidebar_anim_id = 0 + if self._studio_right_panel is None: + return + if visible: + self._studio_right_panel.set_size_request( + self._sidebar_full_width(), -1) + self._studio_right_panel.show() + else: + self._studio_right_panel.set_size_request(0, -1) + self._studio_right_panel.hide() + + def _animate_sidebar(self, opening): + """Slide the sidebar open or closed with a smoothstep ease (250ms).""" + if self._sidebar_anim_id: + GLib.source_remove(self._sidebar_anim_id) + self._sidebar_anim_id = 0 + + full_w = self._sidebar_full_width() + panel = self._studio_right_panel + if panel is None: + return + + req = panel.get_size_request()[0] + from_w = req if req >= 0 else (0 if opening else full_w) + to_w = full_w if opening else 0 + + if opening: + panel.set_size_request(from_w, -1) + panel.show() + + self._sidebar_anim_from_w = from_w + self._sidebar_anim_to_w = to_w + self._sidebar_anim_start_us = GLib.get_monotonic_time() + self._sidebar_anim_id = GLib.timeout_add( + 16, self._sidebar_anim_tick) + + def _sidebar_anim_tick(self): + elapsed_ms = (GLib.get_monotonic_time() + - self._sidebar_anim_start_us) / 1000.0 + t = min(1.0, elapsed_ms / 250.0) + # smoothstep: gentle ease-in then ease-out + t = t * t * (3.0 - 2.0 * t) + + w = int(round(self._sidebar_anim_from_w + + (self._sidebar_anim_to_w + - self._sidebar_anim_from_w) * t)) + + panel = self._studio_right_panel + if panel is not None: + panel.set_size_request(w, -1) + + if t >= 1.0: + self._sidebar_anim_id = 0 + if self._sidebar_anim_to_w == 0 and panel is not None: + panel.hide() + self._refresh_preview_layout() + return False + return True def _refresh_preview_layout(self): GObject.idle_add(self.__do_refresh_preview_layout) From cc465ccb7f76abd2e2aaea1f7bbd7efd72f98bdf Mon Sep 17 00:00:00 2001 From: Ashutoshx7 Date: Mon, 29 Jun 2026 01:38:21 +0530 Subject: [PATCH 17/28] Improve create-view UX with constructionist options and visual polish Learning area: - Expanded from 4 to 6 areas: Science (explore & measure) and Language (stories & words) added alongside Logic, Tools, Games, Creation Constructionist additions: - New 'Who learns' option group (age_band: 6-9 / 10-13 / 14+ / any) feeds ActivitySpec.age_band so the planner adjusts vocabulary/complexity - New 'How they work' option group (collab: solo / pair / class) prepends collaboration context to the generation prompt Generation options: - Planner now has two real choices: RAG (Sugar examples) and Direct (no examples, faster); Direct sets use_rag=False in the pipeline - Policy adds Creative option (fewer structural constraints) Layout: - Replaced inline 'Start with' / 'Learning area' headings with subtle uppercase section labels via _create_section_label() - LLM provider selector collapsed into a Gtk.Expander (hidden by default) to reduce visual clutter for users who don't need it - Cards use border-radius 8px with 120ms CSS ease transition on hover - Hint texts updated for all 6 learning areas with richer descriptions --- src/jarabe/desktop/homebox.py | 149 +++++++++++++++++++++++++++------- 1 file changed, 121 insertions(+), 28 deletions(-) diff --git a/src/jarabe/desktop/homebox.py b/src/jarabe/desktop/homebox.py index 9367b98a2b..def3357383 100644 --- a/src/jarabe/desktop/homebox.py +++ b/src/jarabe/desktop/homebox.py @@ -310,6 +310,8 @@ def __init__(self): self._selected_options = { 'mode': 'make', 'template': 'logic_math', + 'age_band': 'all', + 'collab': 'solo', 'planner': 'rag', 'policy': 'standard', 'validate': 'on', @@ -537,8 +539,12 @@ def _create_create_view(self): container.pack_start(options_area, False, False, style.zoom(8)) options_area.show() + options_area.pack_start( + self._create_section_label(_('Start with')), + False, False, style.zoom(2)) + options_area.pack_start(self._create_option_group( - _('Start with'), + '', 'mode', [ ('make', _('Make'), _('Create something\nnew')), @@ -547,17 +553,22 @@ def _create_create_view(self): ], 'make'), False, False, 0) + options_area.pack_start( + self._create_section_label(_('Learning area')), + False, False, style.zoom(6)) + options_area.pack_start(self._create_option_group( - _('Learning area'), + '', 'template', [ - ('logic_math', _('Logic & math'), _('Puzzles,\npatterns')), - ('tools_utils', _('Tools/utilities'), _('Build helpful\n' - 'tools')), + ('logic_math', _('Logic & math'), _('Puzzles &\npatterns')), + ('science', _('Science'), _('Explore &\nmeasure')), + ('language', _('Language'), _('Stories &\nwords')), + ('tools_utils', _('Tools'), _('Build helpful\ntools')), ('games', _('Games'), _('Play loops\nand score')), - ('creation', _('Creation'), _('Make and\nexpress')), + ('creation', _('Creation'), _('Make &\nexpress')), ], - 'logic_math'), False, False, 0) + 'logic_math', card_width=84), False, False, 0) template_hint = Gtk.Label( _('Learning area: logic and math activities for puzzles, patterns, ' @@ -569,6 +580,32 @@ def _create_create_view(self): container.pack_start(template_hint, False, False, style.zoom(1)) template_hint.show() + learner_row = Gtk.HBox(spacing=style.zoom(24)) + learner_row.set_halign(Gtk.Align.CENTER) + container.pack_start(learner_row, False, False, style.zoom(4)) + learner_row.show() + + learner_row.pack_start(self._create_option_group( + _('Who learns'), + 'age_band', + [ + ('primary', _('Ages 6–9'), _('Early\nprimary')), + ('middle', _('Ages 10–13'), _('Upper primary /\nmiddle')), + ('senior', _('Ages 14+'), _('Secondary\n& higher')), + ('all', _('Any age'), _('Adapt to\nall learners')), + ], + 'all', card_width=96), False, False, 0) + + learner_row.pack_start(self._create_option_group( + _('How they work'), + 'collab', + [ + ('solo', _('Solo maker'), _('One learner\nbuilds & reflects')), + ('pair', _('Peer pair'), _('Two learners\ncollaborate')), + ('class', _('Whole class'), _('Teacher-led\nwith the group')), + ], + 'solo', card_width=108), False, False, 0) + planner_options = Gtk.HBox(spacing=style.zoom(14)) planner_options.set_halign(Gtk.Align.CENTER) container.pack_start(planner_options, False, False, style.zoom(4)) @@ -579,6 +616,7 @@ def _create_create_view(self): 'planner', [ ('rag', _('RAG'), _('Use Sugar\nexamples')), + ('direct', _('Direct'), _('No examples,\nfaster')), ], 'rag'), False, False, 0) @@ -586,8 +624,8 @@ def _create_create_view(self): _('Policy'), 'policy', [ - ('standard', _('Standard'), - _('RAG + model\nvalidated')), + ('standard', _('Standard'), _('Model\nvalidated')), + ('creative', _('Creative'), _('Fewer rules,\nmore flair')), ], 'standard'), False, False, 0) @@ -611,8 +649,15 @@ def _create_create_view(self): planner_hint.show() self._update_planner_hint() - container.pack_start(self._create_provider_selector(), - False, False, style.zoom(3)) + provider_expander = Gtk.Expander() + provider_expander.set_label(_('LLM provider')) + provider_expander.get_style_context().add_class( + 'create-ai-expander') + provider_expander.set_halign(Gtk.Align.CENTER) + provider_expander.add(self._create_provider_selector()) + provider_expander.set_expanded(False) + container.pack_start(provider_expander, False, False, style.zoom(3)) + provider_expander.show_all() container.pack_start(self._create_license_selector(), False, False, style.zoom(3)) @@ -763,6 +808,13 @@ def _create_create_view(self): return container + def _create_section_label(self, text): + label = Gtk.Label(text) + label.get_style_context().add_class('create-ai-section-label') + label.set_halign(Gtk.Align.CENTER) + label.show() + return label + def _create_provider_selector(self): selector = Gtk.VBox(spacing=style.zoom(4)) selector.set_halign(Gtk.Align.CENTER) @@ -1026,7 +1078,8 @@ def _create_option_group(self, title, group_name, options, active_value, label.get_style_context().add_class('create-ai-option-heading') label.set_halign(Gtk.Align.CENTER) group.pack_start(label, False, False, 0) - label.show() + if title: + label.show() row = Gtk.HBox(spacing=style.zoom(7)) row.set_halign(Gtk.Align.CENTER) @@ -3503,29 +3556,43 @@ def _ensure_css(self): background-color: %(studio_lavender_soft)s; border-color: %(studio_lavender_border)s; } + .create-ai-section-label { + color: %(inactive_stroke)s; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.06em; + } + .create-ai-expander { + color: %(toolbar)s; + font-size: 12px; + } .create-ai-option-heading { color: %(toolbar)s; font-weight: 700; font-size: 12px; } button.create-ai-option-card { - border-radius: 6px; + border-radius: 8px; border: 1px solid %(studio_edge)s; background-image: none; background-color: %(studio_surface)s; color: %(toolbar)s; padding: 0; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); + transition: background-color 120ms ease, + border-color 120ms ease, + box-shadow 120ms ease; } button.create-ai-option-card:hover { background-color: %(studio_lavender_soft)s; border-color: %(studio_lavender_faint)s; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.09); } button.create-ai-option-card-active { background-color: %(studio_dark)s; border-color: %(studio_dark)s; color: %(studio_dark_text)s; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.13); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.18); } button.create-ai-option-card-active:hover { background-color: %(studio_dark_hover)s; @@ -4389,15 +4456,19 @@ def _update_planner_hint(self): planner_notes = { 'rag': _('RAG over Sugar activity examples'), + 'direct': _('direct LLM call, no Sugar examples'), 'validate': _('local template planning plus AST validation'), } policy_notes = { 'standard': _('configured provider, no template fallback'), + 'creative': _('fewer structural constraints, more expression'), 'local': _('Ollama when configured, otherwise local templates'), 'strict': _('fixed local templates without model calls'), } - planner = planner_notes[self._selected_options['planner']] - policy = policy_notes[self._selected_options['policy']] + planner = planner_notes.get(self._selected_options['planner'], + self._selected_options['planner']) + policy = policy_notes.get(self._selected_options['policy'], + self._selected_options['policy']) provider = self._get_provider_label( self._selected_options['provider']) self._planner_hint.set_text( @@ -4855,17 +4926,22 @@ def _update_template_hint(self): return notes = { - 'logic_math': _('Learning area: logic and math activities for ' - 'puzzles, patterns, and reasoning.'), - 'tools_utils': _('Learning area: tools and utilities that help ' - 'learners make, measure, organize, or explore.'), - 'games': _('Learning area: games with play loops, goals, ' - 'scoring, and feedback.'), - 'creation': _('Learning area: creation activities for drawing, ' - 'writing, building, and expression.'), + 'logic_math': _('Puzzles, patterns, and reasoning — number games, ' + 'sequence finders, logic grids.'), + 'science': _('Experiments and measurement — simulations, data ' + 'collectors, interactive models.'), + 'language': _('Stories and words — writing prompts, word games, ' + 'vocabulary builders, storytelling.'), + 'tools_utils': _('Tools and utilities — calculators, converters, ' + 'organizers, exploration helpers.'), + 'games': _('Play loops and scoring — turn-based games, reflex ' + 'challenges, board games, simulations.'), + 'creation': _('Making and expression — drawing canvases, music ' + 'makers, collage builders, animations.'), } self._template_hint.set_text( - notes[self._selected_options['template']]) + notes.get(self._selected_options['template'], + self._selected_options['template'])) def _update_license_hint(self): if self._license_hint is None: @@ -6811,12 +6887,28 @@ def _submit_generation_from_prompt(self, prompt, chat_prompt=None): from jarabe.model.aodspec import name_from_prompt license_info = self._get_selected_license() + + age_band_map = { + 'primary': 'ages 6-9', + 'middle': 'ages 10-13', + 'senior': 'ages 14+', + 'all': 'all', + } + age_band = age_band_map.get( + self._selected_options.get('age_band', 'all'), 'all') + + collab_prefix = { + 'pair': _('Two learners collaborate together. '), + 'class': _('Whole class activity with teacher facilitation. '), + }.get(self._selected_options.get('collab', 'solo'), '') + spec = ActivitySpec( name=name_from_prompt(prompt), - prompt=prompt, + prompt=collab_prefix + prompt, category=self._selected_options['template'], license_id=license_info['spdx'], code_size=self._selected_options.get('code_size', 'standard'), + age_band=age_band, ) self._submit_generation_spec( spec, @@ -6989,7 +7081,8 @@ def _submit_generation_spec(self, spec, chat_prompt=None, else: self._sidebar_refine_status_label.set_text( _('Generating activity...')) - use_rag = policy not in ('local', 'strict') + use_rag = (planner != 'direct' + and policy not in ('local', 'strict')) validate_code = self._selected_options.get('validate', 'on') == 'on' self._detach_generation_job() From 3b353af57f01e81040298d31689b94481ab93cc6 Mon Sep 17 00:00:00 2001 From: Ashutoshx7 Date: Mon, 29 Jun 2026 01:43:29 +0530 Subject: [PATCH 18/28] Simplify provider selector following Sugar design philosophy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before: one dense row with 6+ widgets — combo, key, Paste, Model, Endpoint, Save & test, Remove key all visible simultaneously. After: - Primary row: provider picker + API key + Paste + Save + Remove (clean, one clear action; labels shortened to 'Save' / 'Remove') - Advanced row (Model override + Endpoint URL): shown only when the selected provider actually supports those fields (cloud or Ollama); hidden for 'Automatic' and local providers - 'Save & test' → 'Save' (the test happens implicitly on next generate) --- src/jarabe/desktop/homebox.py | 103 +++++++++++++++++----------------- 1 file changed, 53 insertions(+), 50 deletions(-) diff --git a/src/jarabe/desktop/homebox.py b/src/jarabe/desktop/homebox.py index def3357383..d34f098544 100644 --- a/src/jarabe/desktop/homebox.py +++ b/src/jarabe/desktop/homebox.py @@ -337,6 +337,7 @@ def __init__(self): self._provider_endpoint_entry = None self._provider_apply_button = None self._provider_remove_button = None + self._provider_adv_row = None self._provider_status_label = None self._provider_test_running = False self._license_hint = None @@ -816,16 +817,12 @@ def _create_section_label(self, text): return label def _create_provider_selector(self): - selector = Gtk.VBox(spacing=style.zoom(4)) + selector = Gtk.VBox(spacing=style.zoom(8)) selector.set_halign(Gtk.Align.CENTER) + selector.set_border_width(style.zoom(8)) - heading = Gtk.Label(_('LLM provider')) - heading.get_style_context().add_class('create-ai-option-heading') - heading.set_halign(Gtk.Align.CENTER) - selector.pack_start(heading, False, False, 0) - heading.show() - - row = Gtk.HBox(spacing=style.zoom(7)) + # Primary row: provider picker + key + save + row = Gtk.HBox(spacing=style.zoom(8)) row.set_halign(Gtk.Align.CENTER) selector.pack_start(row, False, False, 0) row.show() @@ -837,7 +834,7 @@ def _create_provider_selector(self): initial_provider = self._initial_provider_option() self._selected_options['provider'] = initial_provider combo.set_active_id(initial_provider) - combo.set_size_request(style.zoom(170), style.zoom(42)) + combo.set_size_request(style.zoom(180), style.zoom(40)) combo.get_style_context().add_class('create-ai-provider-combo') combo.connect('changed', self.__provider_combo_changed_cb) row.pack_start(combo, False, False, 0) @@ -848,11 +845,11 @@ def _create_provider_selector(self): key_entry.set_visibility(False) key_entry.set_invisible_char('*') key_entry.set_input_purpose(Gtk.InputPurpose.PASSWORD) - key_entry.set_placeholder_text(_('API key (masked)')) + key_entry.set_placeholder_text(_('API key')) key_entry.set_tooltip_text( - _('Saved keys stay in the private Sugar profile and are never ' - 'added to generated activities.')) - key_entry.set_size_request(style.zoom(245), style.zoom(42)) + _('Keys stay in your Sugar profile and are never added to ' + 'generated activities.')) + key_entry.set_size_request(style.zoom(260), style.zoom(40)) key_entry.get_style_context().add_class('create-ai-provider-entry') key_entry.connect('key-press-event', self.__provider_key_entry_key_press_event_cb) @@ -863,52 +860,52 @@ def _create_provider_selector(self): paste_button = Gtk.Button.new_with_label(_('Paste')) self._provider_paste_button = paste_button - paste_button.set_size_request(style.zoom(75), style.zoom(42)) - paste_button.set_tooltip_text(_('Paste an API key from the clipboard.')) - paste_button.get_style_context().add_class( - 'create-ai-provider-button') + paste_button.set_size_request(style.zoom(72), style.zoom(40)) + paste_button.get_style_context().add_class('create-ai-provider-button') paste_button.connect('clicked', self.__provider_paste_clicked_cb) row.pack_start(paste_button, False, False, 0) paste_button.show() - model_entry = Gtk.Entry() - self._provider_model_entry = model_entry - model_entry.set_placeholder_text(_('Model (optional)')) - model_entry.set_size_request(style.zoom(190), style.zoom(42)) - model_entry.get_style_context().add_class( - 'create-ai-provider-entry') - row.pack_start(model_entry, False, False, 0) - model_entry.show() - - endpoint_entry = Gtk.Entry() - self._provider_endpoint_entry = endpoint_entry - endpoint_entry.set_placeholder_text(_('Endpoint (optional)')) - endpoint_entry.set_tooltip_text( - _('Use the provider API endpoint, or Ollama /api/generate URL.')) - endpoint_entry.set_size_request(style.zoom(245), style.zoom(42)) - endpoint_entry.get_style_context().add_class( - 'create-ai-provider-entry') - row.pack_start(endpoint_entry, False, False, 0) - endpoint_entry.show() - - apply_button = Gtk.Button.new_with_label(_('Save & test')) + apply_button = Gtk.Button.new_with_label(_('Save')) self._provider_apply_button = apply_button - apply_button.set_size_request(style.zoom(125), style.zoom(42)) - apply_button.get_style_context().add_class( - 'create-ai-provider-button') + apply_button.set_size_request(style.zoom(72), style.zoom(40)) + apply_button.get_style_context().add_class('create-ai-provider-button') apply_button.connect('clicked', self.__provider_apply_clicked_cb) row.pack_start(apply_button, False, False, 0) apply_button.show() - remove_button = Gtk.Button.new_with_label(_('Remove key')) + remove_button = Gtk.Button.new_with_label(_('Remove')) self._provider_remove_button = remove_button - remove_button.set_size_request(style.zoom(115), style.zoom(42)) - remove_button.get_style_context().add_class( - 'create-ai-provider-button') + remove_button.set_size_request(style.zoom(80), style.zoom(40)) + remove_button.get_style_context().add_class('create-ai-provider-button') remove_button.connect('clicked', self.__provider_remove_clicked_cb) row.pack_start(remove_button, False, False, 0) remove_button.show() + # Advanced row: model + endpoint (shown only when provider needs it) + adv_row = Gtk.HBox(spacing=style.zoom(8)) + adv_row.set_halign(Gtk.Align.CENTER) + selector.pack_start(adv_row, False, False, 0) + + model_entry = Gtk.Entry() + self._provider_model_entry = model_entry + model_entry.set_placeholder_text(_('Model override (optional)')) + model_entry.set_size_request(style.zoom(240), style.zoom(40)) + model_entry.get_style_context().add_class('create-ai-provider-entry') + adv_row.pack_start(model_entry, False, False, 0) + model_entry.show() + + endpoint_entry = Gtk.Entry() + self._provider_endpoint_entry = endpoint_entry + endpoint_entry.set_placeholder_text(_('Endpoint URL (optional)')) + endpoint_entry.set_tooltip_text( + _('Provider API base URL, or Ollama /api/generate address.')) + endpoint_entry.set_size_request(style.zoom(300), style.zoom(40)) + endpoint_entry.get_style_context().add_class('create-ai-provider-entry') + adv_row.pack_start(endpoint_entry, False, False, 0) + endpoint_entry.show() + + # model_switch_row (opencode-go specific) model_switch_row = Gtk.HBox(spacing=style.zoom(6)) self._provider_model_switch_row = model_switch_row model_switch_row.set_halign(Gtk.Align.CENTER) @@ -921,10 +918,7 @@ def _create_provider_selector(self): model_button.get_style_context().add_class( 'create-ai-provider-button') model_button.connect( - 'clicked', - self.__provider_model_switch_clicked_cb, - model, - ) + 'clicked', self.__provider_model_switch_clicked_cb, model) model_switch_row.pack_start(model_button, False, False, 0) model_button.show() model_switch_row.hide() @@ -935,10 +929,11 @@ def _create_provider_selector(self): status.set_halign(Gtk.Align.CENTER) status.set_justify(Gtk.Justification.CENTER) status.set_line_wrap(True) - status.set_max_width_chars(132) + status.set_max_width_chars(100) selector.pack_start(status, False, False, 0) status.show() + self._provider_adv_row = adv_row self._update_provider_controls() selector.show() return selector @@ -4485,12 +4480,20 @@ def _update_provider_controls(self): 'freemodel', 'openrouter', 'gemini', 'openai', 'deepseek', 'qwen', 'moonshot', 'opencode', 'opencode-go', 'claude') model_provider = cloud_provider or provider_name == 'ollama' + needs_adv = model_provider # show advanced row for any provider with model/endpoint self._provider_key_entry.set_sensitive(cloud_provider) self._provider_paste_button.set_sensitive(cloud_provider) self._provider_model_entry.set_sensitive(model_provider) self._provider_endpoint_entry.set_sensitive(model_provider) self._provider_remove_button.set_sensitive(False) + + if self._provider_adv_row is not None: + if needs_adv: + self._provider_adv_row.show() + else: + self._provider_adv_row.hide() + if self._provider_model_switch_row is not None: if provider_name == 'opencode-go': self._provider_model_switch_row.show() From e4c00e5114fb9824739cdc25aa43957c5d21f4cd Mon Sep 17 00:00:00 2001 From: Ashutoshx7 Date: Mon, 29 Jun 2026 01:51:49 +0530 Subject: [PATCH 19/28] Remove age/collab groups and model/endpoint boxes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop 'Who learns' and 'How they work' option groups — Sugar's existing activity templates already encode these implicitly - Remove Model override and Endpoint URL input boxes from the provider row entirely; keep the Gtk.Entry stubs as non-rendered widgets so the save callback continues to work without layout noise --- src/jarabe/desktop/homebox.py | 64 +++-------------------------------- 1 file changed, 5 insertions(+), 59 deletions(-) diff --git a/src/jarabe/desktop/homebox.py b/src/jarabe/desktop/homebox.py index d34f098544..fd208a19cc 100644 --- a/src/jarabe/desktop/homebox.py +++ b/src/jarabe/desktop/homebox.py @@ -581,32 +581,6 @@ def _create_create_view(self): container.pack_start(template_hint, False, False, style.zoom(1)) template_hint.show() - learner_row = Gtk.HBox(spacing=style.zoom(24)) - learner_row.set_halign(Gtk.Align.CENTER) - container.pack_start(learner_row, False, False, style.zoom(4)) - learner_row.show() - - learner_row.pack_start(self._create_option_group( - _('Who learns'), - 'age_band', - [ - ('primary', _('Ages 6–9'), _('Early\nprimary')), - ('middle', _('Ages 10–13'), _('Upper primary /\nmiddle')), - ('senior', _('Ages 14+'), _('Secondary\n& higher')), - ('all', _('Any age'), _('Adapt to\nall learners')), - ], - 'all', card_width=96), False, False, 0) - - learner_row.pack_start(self._create_option_group( - _('How they work'), - 'collab', - [ - ('solo', _('Solo maker'), _('One learner\nbuilds & reflects')), - ('pair', _('Peer pair'), _('Two learners\ncollaborate')), - ('class', _('Whole class'), _('Teacher-led\nwith the group')), - ], - 'solo', card_width=108), False, False, 0) - planner_options = Gtk.HBox(spacing=style.zoom(14)) planner_options.set_halign(Gtk.Align.CENTER) container.pack_start(planner_options, False, False, style.zoom(4)) @@ -882,28 +856,11 @@ def _create_provider_selector(self): row.pack_start(remove_button, False, False, 0) remove_button.show() - # Advanced row: model + endpoint (shown only when provider needs it) - adv_row = Gtk.HBox(spacing=style.zoom(8)) - adv_row.set_halign(Gtk.Align.CENTER) - selector.pack_start(adv_row, False, False, 0) - - model_entry = Gtk.Entry() - self._provider_model_entry = model_entry - model_entry.set_placeholder_text(_('Model override (optional)')) - model_entry.set_size_request(style.zoom(240), style.zoom(40)) - model_entry.get_style_context().add_class('create-ai-provider-entry') - adv_row.pack_start(model_entry, False, False, 0) - model_entry.show() - - endpoint_entry = Gtk.Entry() - self._provider_endpoint_entry = endpoint_entry - endpoint_entry.set_placeholder_text(_('Endpoint URL (optional)')) - endpoint_entry.set_tooltip_text( - _('Provider API base URL, or Ollama /api/generate address.')) - endpoint_entry.set_size_request(style.zoom(300), style.zoom(40)) - endpoint_entry.get_style_context().add_class('create-ai-provider-entry') - adv_row.pack_start(endpoint_entry, False, False, 0) - endpoint_entry.show() + # Keep model/endpoint widgets as non-visible stubs so the save + # callback can still read them without crashing. + self._provider_model_entry = Gtk.Entry() + self._provider_endpoint_entry = Gtk.Entry() + adv_row = None # model_switch_row (opencode-go specific) model_switch_row = Gtk.HBox(spacing=style.zoom(6)) @@ -933,7 +890,6 @@ def _create_provider_selector(self): selector.pack_start(status, False, False, 0) status.show() - self._provider_adv_row = adv_row self._update_provider_controls() selector.show() return selector @@ -4479,21 +4435,11 @@ def _update_provider_controls(self): cloud_provider = provider_name in ( 'freemodel', 'openrouter', 'gemini', 'openai', 'deepseek', 'qwen', 'moonshot', 'opencode', 'opencode-go', 'claude') - model_provider = cloud_provider or provider_name == 'ollama' - needs_adv = model_provider # show advanced row for any provider with model/endpoint self._provider_key_entry.set_sensitive(cloud_provider) self._provider_paste_button.set_sensitive(cloud_provider) - self._provider_model_entry.set_sensitive(model_provider) - self._provider_endpoint_entry.set_sensitive(model_provider) self._provider_remove_button.set_sensitive(False) - if self._provider_adv_row is not None: - if needs_adv: - self._provider_adv_row.show() - else: - self._provider_adv_row.hide() - if self._provider_model_switch_row is not None: if provider_name == 'opencode-go': self._provider_model_switch_row.show() From 9bacf20bfc10276d81362a92301c032b963d3e83 Mon Sep 17 00:00:00 2001 From: Ashutoshx7 Date: Mon, 29 Jun 2026 01:57:22 +0530 Subject: [PATCH 20/28] Fix sidebar animation: use Gtk.Revealer instead of manual set_size_request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The manual smoothstep loop called set_size_request(w, -1) each tick, but GTK allocates non-expand widgets at their natural content size regardless of the size request minimum — so the widget never actually shrank and the sidebar just snapped on hide(). Gtk.Revealer overrides get_preferred_width during animation so the HBox correctly shrinks/grows the revealer on every frame, the preview expands in sync, and the content is clipped to the animated width. - SLIDE_LEFT transition (260ms): sidebar slides from/to the right edge - notify::child-revealed signal fires _refresh_preview_layout() once done - Fullscreen toggle uses duration=0 snap (no animation, just instant hide) --- src/jarabe/desktop/homebox.py | 96 +++++++++-------------------------- 1 file changed, 23 insertions(+), 73 deletions(-) diff --git a/src/jarabe/desktop/homebox.py b/src/jarabe/desktop/homebox.py index fd208a19cc..3c4b5eccfb 100644 --- a/src/jarabe/desktop/homebox.py +++ b/src/jarabe/desktop/homebox.py @@ -323,10 +323,6 @@ def __init__(self): self._sidebar_visible = True self._sidebar_toggle_button = None self._sidebar_revealer = None - self._sidebar_anim_id = 0 - self._sidebar_anim_from_w = 0 - self._sidebar_anim_to_w = 0 - self._sidebar_anim_start_us = 0 self._template_hint = None self._planner_hint = None self._provider_combo = None @@ -1107,7 +1103,16 @@ def _create_studio_view(self): body.pack_start(self._create_studio_preview_panel(), True, True, 0) self._studio_right_panel = self._create_learning_sidebar() - body.pack_start(self._studio_right_panel, False, False, 0) + self._sidebar_revealer = Gtk.Revealer() + self._sidebar_revealer.set_transition_type( + Gtk.RevealerTransitionType.SLIDE_LEFT) + self._sidebar_revealer.set_transition_duration(260) + self._sidebar_revealer.add(self._studio_right_panel) + self._sidebar_revealer.set_reveal_child(True) + self._sidebar_revealer.show() + self._sidebar_revealer.connect( + 'notify::child-revealed', self.__sidebar_reveal_done_cb) + body.pack_start(self._sidebar_revealer, False, False, 0) footer = Gtk.HBox(spacing=style.zoom(8)) footer.get_style_context().add_class('create-ai-studio-footer') @@ -7550,14 +7555,21 @@ def __preview_fullscreen_toggle_cb(self, button): if self._preview_is_fullscreen: if self._studio_left_panel is not None: self._studio_left_panel.hide() - self._sidebar_snap(False) + if self._sidebar_revealer is not None: + self._sidebar_revealer.set_transition_duration(0) + self._sidebar_revealer.set_reveal_child(False) + self._sidebar_revealer.set_transition_duration(260) if self._preview_fullscreen_button is not None: self._preview_fullscreen_button.set_label( _('⛶ Exit Fullscreen')) else: if self._studio_left_panel is not None: self._studio_left_panel.show() - self._sidebar_snap(self._sidebar_visible) + if self._sidebar_revealer is not None: + self._sidebar_revealer.set_transition_duration(0) + self._sidebar_revealer.set_reveal_child( + self._sidebar_visible) + self._sidebar_revealer.set_transition_duration(260) if self._preview_fullscreen_button is not None: self._preview_fullscreen_button.set_label( _('⛶ Fullscreen')) @@ -7568,73 +7580,11 @@ def __sidebar_toggle_cb(self, button): if self._sidebar_toggle_button is not None: self._sidebar_toggle_button.set_label( _('◀ Sidebar') if self._sidebar_visible else _('▶ Sidebar')) - self._animate_sidebar(self._sidebar_visible) + if self._sidebar_revealer is not None: + self._sidebar_revealer.set_reveal_child(self._sidebar_visible) - def _sidebar_full_width(self): - return style.zoom(260) - - def _sidebar_snap(self, visible): - """Instantly set sidebar to its final state (no animation).""" - if self._sidebar_anim_id: - GLib.source_remove(self._sidebar_anim_id) - self._sidebar_anim_id = 0 - if self._studio_right_panel is None: - return - if visible: - self._studio_right_panel.set_size_request( - self._sidebar_full_width(), -1) - self._studio_right_panel.show() - else: - self._studio_right_panel.set_size_request(0, -1) - self._studio_right_panel.hide() - - def _animate_sidebar(self, opening): - """Slide the sidebar open or closed with a smoothstep ease (250ms).""" - if self._sidebar_anim_id: - GLib.source_remove(self._sidebar_anim_id) - self._sidebar_anim_id = 0 - - full_w = self._sidebar_full_width() - panel = self._studio_right_panel - if panel is None: - return - - req = panel.get_size_request()[0] - from_w = req if req >= 0 else (0 if opening else full_w) - to_w = full_w if opening else 0 - - if opening: - panel.set_size_request(from_w, -1) - panel.show() - - self._sidebar_anim_from_w = from_w - self._sidebar_anim_to_w = to_w - self._sidebar_anim_start_us = GLib.get_monotonic_time() - self._sidebar_anim_id = GLib.timeout_add( - 16, self._sidebar_anim_tick) - - def _sidebar_anim_tick(self): - elapsed_ms = (GLib.get_monotonic_time() - - self._sidebar_anim_start_us) / 1000.0 - t = min(1.0, elapsed_ms / 250.0) - # smoothstep: gentle ease-in then ease-out - t = t * t * (3.0 - 2.0 * t) - - w = int(round(self._sidebar_anim_from_w - + (self._sidebar_anim_to_w - - self._sidebar_anim_from_w) * t)) - - panel = self._studio_right_panel - if panel is not None: - panel.set_size_request(w, -1) - - if t >= 1.0: - self._sidebar_anim_id = 0 - if self._sidebar_anim_to_w == 0 and panel is not None: - panel.hide() - self._refresh_preview_layout() - return False - return True + def __sidebar_reveal_done_cb(self, revealer, _param): + self._refresh_preview_layout() def _refresh_preview_layout(self): GObject.idle_add(self.__do_refresh_preview_layout) From 963f9127838e590b6362e641dc4ffd6880dfb2a0 Mon Sep 17 00:00:00 2001 From: Ashutoshx7 Date: Mon, 29 Jun 2026 17:14:44 +0530 Subject: [PATCH 21/28] Fix generated previews and update OpenRouter model --- src/jarabe/desktop/homebox.py | 1 + src/jarabe/model/aodcodegen.py | 7 +++- src/jarabe/model/aodllm.py | 6 ++-- src/jarabe/model/aodpreview.py | 34 ++++++++++++++++++ src/jarabe/model/aodvalidator.py | 14 ++++++++ tests/test_aodllm.py | 6 ++-- tests/test_aodpreview.py | 60 ++++++++++++++++++++++++++++++++ tests/test_aodvalidator.py | 28 +++++++++++++++ 8 files changed, 149 insertions(+), 7 deletions(-) create mode 100644 tests/test_aodpreview.py diff --git a/src/jarabe/desktop/homebox.py b/src/jarabe/desktop/homebox.py index 3c4b5eccfb..9dfb4d8a5b 100644 --- a/src/jarabe/desktop/homebox.py +++ b/src/jarabe/desktop/homebox.py @@ -6624,6 +6624,7 @@ def __option_card_clicked_cb(self, clicked_button, group_name, value): 'standard': _('Standard'), 'local': _('Local'), 'strict': _('Strict'), + 'off': _('Off'), } if self._prompt_status_label is not None: if group_name == 'license': diff --git a/src/jarabe/model/aodcodegen.py b/src/jarabe/model/aodcodegen.py index cf83f2ec99..02fdf10d83 100644 --- a/src/jarabe/model/aodcodegen.py +++ b/src/jarabe/model/aodcodegen.py @@ -74,11 +74,16 @@ def build_codegen_system_prompt(spec, plan, references=(), code_size='standard') '6. Class must be named GeneratedActivity(activity.Activity)\n' '7. In __init__:\n' ' - Call activity.Activity.__init__(self, handle)\n' - ' - Create ToolbarBox with ActivityToolbarButton and StopButton\n' + ' - Create ToolbarBox with ActivityToolbarButton and StopButton. ' + 'Insert every item using ' + 'toolbar_box.toolbar.insert(item, position). ToolbarBox does NOT have ' + 'an add_toolbar_button() method.\n' ' - Call self.set_toolbar_box(toolbar_box)\n' ' - Build the canvas with Gtk widgets\n' ' - Call self.set_canvas(canvas)\n' ' - Call self.show_all()\n' + ' - For Gtk.Adjustment ranges, call set_lower() and set_upper(); ' + 'Gtk.Adjustment does NOT have set_bounds().\n' '8. Implement read_file(self, file_path) and ' 'write_file(self, file_path)\n' ' for Journal persistence using json.\n\n' diff --git a/src/jarabe/model/aodllm.py b/src/jarabe/model/aodllm.py index 5115297615..b58bc24ee6 100644 --- a/src/jarabe/model/aodllm.py +++ b/src/jarabe/model/aodllm.py @@ -543,7 +543,7 @@ class OpenAICompatibleProvider(OpenAIProvider): 'key_env': 'OPENROUTER_API_KEY', 'model_env': 'AOD_OPENROUTER_MODEL', 'endpoint_env': 'AOD_OPENROUTER_ENDPOINT', - 'default_model': 'google/gemini-3.5-flash', + 'default_model': 'anthropic/claude-opus-4.8', 'default_endpoint': ( 'https://openrouter.ai/api/v1/chat/completions' ), @@ -1092,7 +1092,7 @@ def _chat_finish_reason_error(label, finish_reason, message): return ( '%s returned no activity text (finish_reason=%s). Try rerunning or ' 'switching to a text/code model such as ' - 'google/gemini-3.5-flash.' + 'anthropic/claude-opus-4.8.' % (label, reason) ) @@ -1327,7 +1327,7 @@ def _provider_model(name): 'openai': ('AOD_OPENAI_MODEL', 'gpt-4.1-mini'), 'openrouter': ( 'AOD_OPENROUTER_MODEL', - 'google/gemini-3.5-flash', + 'anthropic/claude-opus-4.8', ), 'deepseek': ('AOD_DEEPSEEK_MODEL', 'deepseek-chat'), 'qwen': ('AOD_QWEN_MODEL', 'qwen-turbo'), diff --git a/src/jarabe/model/aodpreview.py b/src/jarabe/model/aodpreview.py index 9710aff7ce..80417ac9aa 100644 --- a/src/jarabe/model/aodpreview.py +++ b/src/jarabe/model/aodpreview.py @@ -18,6 +18,7 @@ import re import shutil import tempfile +from gettext import gettext as _ import gi gi.require_version('Gtk', '3.0') @@ -270,6 +271,7 @@ def render_activity_preview(project_path, activity_name=''): bundle_path = project_path _install_bundle_path_helper(bundle_path) + _install_preview_compatibility() result = _try_exec_preview(patched_source, source_path, bundle_path, activity_name) @@ -302,6 +304,10 @@ def _try_exec_preview(patched_source, source_path, bundle_path, '__file__': source_path, 'PreviewActivity': PreviewActivity, '_preview_instance': preview, + # Keep preview useful when otherwise valid generated code forgot the + # standard gettext import. The exported source is still validated + # separately; this fallback only prevents the preview from blanking. + '_': _, } try: @@ -422,6 +428,34 @@ def __init__(self, handle): return patched +def _install_preview_compatibility(): + """Install small shims for common generated GTK/Sugar API mistakes. + + Provider validation normally rejects these calls. Validation can be + disabled for faster iteration, though, and a minor API hallucination + should not make the entire preview disappear. Keep the shims narrowly + scoped to unambiguous aliases with the same behavior as the real API. + """ + try: + from sugar3.graphics.toolbarbox import ToolbarBox + if not hasattr(ToolbarBox, 'add_toolbar_button'): + ToolbarBox.add_toolbar_button = _add_toolbar_button + except ImportError: + pass + + if not hasattr(Gtk.Adjustment, 'set_bounds'): + Gtk.Adjustment.set_bounds = _set_adjustment_bounds + + +def _add_toolbar_button(toolbar_box, item): + toolbar_box.toolbar.insert(item, -1) + + +def _set_adjustment_bounds(adjustment, lower, upper): + adjustment.set_lower(lower) + adjustment.set_upper(upper) + + _bundle_path_override = [''] diff --git a/src/jarabe/model/aodvalidator.py b/src/jarabe/model/aodvalidator.py index 974a3ade17..96f43d306f 100644 --- a/src/jarabe/model/aodvalidator.py +++ b/src/jarabe/model/aodvalidator.py @@ -135,6 +135,20 @@ def validate_source(source): if 'ToolbarBox' not in source: report.errors.append('Generated activity must include a ToolbarBox.') + invalid_api_calls = { + 'add_toolbar_button': ( + 'ToolbarBox has no add_toolbar_button() method; insert items with ' + 'toolbar_box.toolbar.insert(item, position).' + ), + 'set_bounds': ( + 'Gtk.Adjustment has no set_bounds() method; use set_lower() and ' + 'set_upper().' + ), + } + for call_name, message in invalid_api_calls.items(): + if any(name.endswith(call_name) for name in calls): + report.errors.append(message) + return report diff --git a/tests/test_aodllm.py b/tests/test_aodllm.py index c331a54047..6198145aeb 100644 --- a/tests/test_aodllm.py +++ b/tests/test_aodllm.py @@ -246,7 +246,7 @@ def test_create_qwen_provider_from_environment(self): self.assertEqual('qwen-max', provider.model) self.assertEqual('qwen-key', provider._api_key) - def test_create_openrouter_provider_uses_gemini_flash_defaults(self): + def test_create_openrouter_provider_uses_claude_opus_defaults(self): provider = create_provider( 'openrouter', api_key='openrouter-key', @@ -256,7 +256,7 @@ def test_create_openrouter_provider_uses_gemini_flash_defaults(self): self.assertEqual('openrouter', provider.name) self.assertEqual('OpenRouter', provider.label) self.assertEqual('openrouter-key', provider._api_key) - self.assertEqual('google/gemini-3.5-flash', provider.model) + self.assertEqual('anthropic/claude-opus-4.8', provider.model) self.assertEqual( 'https://openrouter.ai/api/v1/chat/completions', provider._endpoint, @@ -289,7 +289,7 @@ def test_openrouter_request_uses_default_model_and_headers(self): 'https://openrouter.ai/api/v1/chat/completions', request.full_url, ) - self.assertEqual('google/gemini-3.5-flash', payload['model']) + self.assertEqual('anthropic/claude-opus-4.8', payload['model']) self.assertEqual('Sugar Activity on Demand', request.get_header('X-title')) diff --git a/tests/test_aodpreview.py b/tests/test_aodpreview.py new file mode 100644 index 0000000000..e651d309f9 --- /dev/null +++ b/tests/test_aodpreview.py @@ -0,0 +1,60 @@ +# Copyright (C) 2026 Sugar Labs +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import unittest +from unittest import mock + +from jarabe.model.aodpreview import _add_toolbar_button +from jarabe.model.aodpreview import _set_adjustment_bounds +from jarabe.model.aodpreview import _try_exec_preview + + +class TestAodPreviewCompatibility(unittest.TestCase): + + def test_toolbar_alias_inserts_at_end(self): + toolbar_box = mock.Mock() + item = object() + + _add_toolbar_button(toolbar_box, item) + + toolbar_box.toolbar.insert.assert_called_once_with(item, -1) + + def test_adjustment_alias_sets_both_limits(self): + adjustment = mock.Mock() + + _set_adjustment_bounds(adjustment, 2, 12) + + adjustment.set_lower.assert_called_once_with(2) + adjustment.set_upper.assert_called_once_with(12) + + def test_preview_supplies_gettext_fallback(self): + class FakePreviewActivity: + def __init__(self, handle=None, bundle_path=''): + self.canvas = object() + + def get_canvas(self): + return self.canvas + + def get_toolbar_box(self): + return None + + source = ( + 'class GeneratedActivity(PreviewActivity):\n' + ' def __init__(self, handle=None):\n' + ' PreviewActivity.__init__(self, handle)\n' + ' self.label = _("Hello")\n' + ) + + with mock.patch( + 'jarabe.model.aodpreview.PreviewActivity', + FakePreviewActivity): + result = _try_exec_preview( + source, 'activity.py', '.', 'Preview') + + self.assertIsNotNone(result[0]) + self.assertEqual('Hello', result[0].label) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_aodvalidator.py b/tests/test_aodvalidator.py index 0a26af988c..507daea6f1 100644 --- a/tests/test_aodvalidator.py +++ b/tests/test_aodvalidator.py @@ -35,6 +35,34 @@ def test_requires_activity_structure(self): report.errors, ) + def test_rejects_invented_toolbar_and_adjustment_apis(self): + spec = ActivitySpec( + 'Counter', + 'Make a counter utility.', + 'tools_utils', + 'MIT', + ) + plan = enrich_plan(spec, {'template': 'utility'}) + source = render_activity_source(spec, plan) + source = source.replace( + 'toolbar.insert(ActivityToolbarButton(self), 0)', + 'toolbar_box.add_toolbar_button(ActivityToolbarButton(self))', + ) + source = source.replace( + 'self.set_canvas(canvas)', + 'adjustment.set_bounds(0, 10)\n self.set_canvas(canvas)', + ) + + report = validate_source(source) + + self.assertFalse(report.valid) + self.assertTrue(any( + 'add_toolbar_button' in error for error in report.errors + )) + self.assertTrue(any( + 'set_bounds' in error for error in report.errors + )) + def test_request_validation_rejects_generic_source_for_drawing(self): spec = ActivitySpec( 'Draw Together', From 199a2228f0839688b2c16a399e6c4448dd252048 Mon Sep 17 00:00:00 2001 From: Ashutoshx7 Date: Fri, 3 Jul 2026 16:35:43 +0530 Subject: [PATCH 22/28] Polish AOD generation UX and harden the pipeline Preview: replace the static generating screen with a bigger XO logo in an animated orbit canvas (breathing halo, comet that closes into the progress ring, staggered entrance, completion ripple). Stopping the animation early (instant failure or cancel) now restores every faded widget and settles the ring, so fast failures no longer leave a blank preview panel. Pipeline: add science and language as first-class learning categories end to end (spec, planner steering, template ranking); coerce unknown soft spec fields instead of failing the job; retry transient HTTP failures (429/5xx/network) with backoff across all LLM providers; retry planning once on a malformed plan response; lower the validator size floor for compact activities. Also add the Flatpak export module with tests. --- src/jarabe/desktop/homebox.py | 1833 ++++++++++++++++++++++++------ src/jarabe/model/Makefile.am | 1 + src/jarabe/model/aodcodegen.py | 77 +- src/jarabe/model/aodflatpak.py | 319 ++++++ src/jarabe/model/aodgenerator.py | 58 +- src/jarabe/model/aodllm.py | 58 +- src/jarabe/model/aodpipeline.py | 76 +- src/jarabe/model/aodpreview.py | 115 +- src/jarabe/model/aodprompts.py | 12 +- src/jarabe/model/aodspec.py | 33 +- src/jarabe/model/aodvalidator.py | 7 +- tests/test_aodflatpak.py | 135 +++ tests/test_aodgenerator.py | 40 + tests/test_aodllm.py | 53 + tests/test_aodprompts.py | 13 + tests/test_aodspec.py | 27 + 16 files changed, 2477 insertions(+), 380 deletions(-) create mode 100644 src/jarabe/model/aodflatpak.py create mode 100644 tests/test_aodflatpak.py diff --git a/src/jarabe/desktop/homebox.py b/src/jarabe/desktop/homebox.py index 9dfb4d8a5b..321bbfa235 100644 --- a/src/jarabe/desktop/homebox.py +++ b/src/jarabe/desktop/homebox.py @@ -26,6 +26,8 @@ import time from gettext import gettext as _ +import cairo + from gi.repository import Gtk from gi.repository import Gdk from gi.repository import Gio @@ -308,12 +310,11 @@ def __init__(self): self.get_style_context().add_class('create-ai-panel') self._option_buttons = {} self._selected_options = { - 'mode': 'make', 'template': 'logic_math', 'age_band': 'all', 'collab': 'solo', 'planner': 'rag', - 'policy': 'standard', + 'policy': 'creative', 'validate': 'on', 'provider': 'default', 'license': 'mit', @@ -325,6 +326,10 @@ def __init__(self): self._sidebar_revealer = None self._template_hint = None self._planner_hint = None + self._validate_chip_value_label = None + self._provider_chip_value_label = None + self._template_card_icons = {} + self._template_card_buttons = {} self._provider_combo = None self._provider_key_entry = None self._provider_paste_button = None @@ -336,12 +341,14 @@ def __init__(self): self._provider_adv_row = None self._provider_status_label = None self._provider_test_running = False + self._flatpak_export_running = False self._license_hint = None self._prompt_text = None self._prompt_char_label = None self._prompt_status_label = None self._studio_prompt_labels = [] self._preview_content_box = None + self._last_preview_error = '' self._live_preview_canvas = None self._live_preview_activity = None self._preview_empty_title = None @@ -352,6 +359,23 @@ def __init__(self): self._preview_generation_steps = [] self._generation_animation_id = 0 self._generation_animation_hide_id = 0 + self._preview_generation_xo = None + self._preview_generation_fun = None + self._generation_tick_count = 0 + self._generation_has_fraction = False + self._preview_generation_canvas = None + self._generation_wheel_cache = None + self._generation_anim_start_us = None + self._generation_anim_t = 0.0 + self._generation_anim_done = False + self._generation_final_rgb = None + self._generation_fun_next = None + self._generation_fun_alpha = 1.0 + self._generation_target_fraction = None + self._generation_shown_fraction = 0.0 + self._generation_fraction_mix = 0.0 + self._generation_done_at = None + self._generation_fade_widgets = [] self._studio_preview_tab = None self._studio_review_tab = None self._studio_versions_tab = None @@ -377,11 +401,25 @@ def __init__(self): self._live_edit_status_label = None self._live_edit_target_label = None self._live_edit_target = _('activity canvas') + self._live_edit_target_is_region = False self._live_edit_on_button = None self._live_edit_off_button = None self._live_edit_enabled = True self._live_edit_handler_ids = [] self._live_edit_highlighted = None + self._live_edit_panel = None + self._live_edit_press_handled = False + self._live_edit_targets = [] + self._preview_shell = None + self._select_start = None + self._select_rect = None + self._ask_bar = None + self._ask_bar_entry = None + self._ask_bar_target_label = None + self._ask_bar_status_label = None + self._ask_bar_plus = None + self._ask_bar_edit_on = None + self._ask_bar_edit_off = None self._chat_messages_box = None self._chat_entry = None self._chat_scroll = None @@ -512,134 +550,25 @@ def _create_choose_view(self): return content def _create_create_view(self): - container = Gtk.VBox(spacing=style.zoom(11)) + container = Gtk.VBox(spacing=style.zoom(10)) container.set_size_request(style.zoom(1280), -1) title = Gtk.Label() - title.set_text(_('Let\'s make a playful learning activity')) - title.get_style_context().add_class('create-ai-builder-title') + title.set_text(_('What will you make today?')) + title.get_style_context().add_class('create-ai-hero-title') title.set_justify(Gtk.Justification.CENTER) title.set_halign(Gtk.Align.CENTER) - container.pack_start(title, False, False, 0) + container.pack_start(title, False, False, style.zoom(6)) title.show() - subtitle = Gtk.Label(_('Tell me what learners make, play with, and ' - 'share.')) + subtitle = Gtk.Label(_('Describe a learning activity and Sugar will ' + 'build it with you.')) subtitle.get_style_context().add_class('create-ai-builder-subtitle') subtitle.set_justify(Gtk.Justification.CENTER) subtitle.set_halign(Gtk.Align.CENTER) container.pack_start(subtitle, False, False, style.zoom(2)) subtitle.show() - options_area = Gtk.VBox(spacing=style.zoom(9)) - options_area.set_halign(Gtk.Align.CENTER) - container.pack_start(options_area, False, False, style.zoom(8)) - options_area.show() - - options_area.pack_start( - self._create_section_label(_('Start with')), - False, False, style.zoom(2)) - - options_area.pack_start(self._create_option_group( - '', - 'mode', - [ - ('make', _('Make'), _('Create something\nnew')), - ('play', _('Play'), _('Try the activity\nquickly')), - ('share', _('Share'), _('Prepare an XO\nbundle')), - ], - 'make'), False, False, 0) - - options_area.pack_start( - self._create_section_label(_('Learning area')), - False, False, style.zoom(6)) - - options_area.pack_start(self._create_option_group( - '', - 'template', - [ - ('logic_math', _('Logic & math'), _('Puzzles &\npatterns')), - ('science', _('Science'), _('Explore &\nmeasure')), - ('language', _('Language'), _('Stories &\nwords')), - ('tools_utils', _('Tools'), _('Build helpful\ntools')), - ('games', _('Games'), _('Play loops\nand score')), - ('creation', _('Creation'), _('Make &\nexpress')), - ], - 'logic_math', card_width=84), False, False, 0) - - template_hint = Gtk.Label( - _('Learning area: logic and math activities for puzzles, patterns, ' - 'and reasoning.')) - self._template_hint = template_hint - template_hint.get_style_context().add_class('create-ai-meta-note') - template_hint.set_halign(Gtk.Align.CENTER) - template_hint.set_justify(Gtk.Justification.CENTER) - container.pack_start(template_hint, False, False, style.zoom(1)) - template_hint.show() - - planner_options = Gtk.HBox(spacing=style.zoom(14)) - planner_options.set_halign(Gtk.Align.CENTER) - container.pack_start(planner_options, False, False, style.zoom(4)) - planner_options.show() - - planner_options.pack_start(self._create_option_group( - _('Planner'), - 'planner', - [ - ('rag', _('RAG'), _('Use Sugar\nexamples')), - ('direct', _('Direct'), _('No examples,\nfaster')), - ], - 'rag'), False, False, 0) - - planner_options.pack_start(self._create_option_group( - _('Policy'), - 'policy', - [ - ('standard', _('Standard'), _('Model\nvalidated')), - ('creative', _('Creative'), _('Fewer rules,\nmore flair')), - ], - 'standard'), False, False, 0) - - planner_options.pack_start(self._create_option_group( - _('Validation'), - 'validate', - [ - ('on', _('On'), _('Validate and\nretry on errors')), - ('off', _('Off'), _('Skip validation\n(faster preview)')), - ], - 'on'), False, False, 0) - - planner_hint = Gtk.Label() - planner_hint.get_style_context().add_class('create-ai-meta-note') - self._planner_hint = planner_hint - planner_hint.set_halign(Gtk.Align.CENTER) - planner_hint.set_justify(Gtk.Justification.CENTER) - planner_hint.set_line_wrap(True) - planner_hint.set_max_width_chars(132) - container.pack_start(planner_hint, False, False, 0) - planner_hint.show() - self._update_planner_hint() - - provider_expander = Gtk.Expander() - provider_expander.set_label(_('LLM provider')) - provider_expander.get_style_context().add_class( - 'create-ai-expander') - provider_expander.set_halign(Gtk.Align.CENTER) - provider_expander.add(self._create_provider_selector()) - provider_expander.set_expanded(False) - container.pack_start(provider_expander, False, False, style.zoom(3)) - provider_expander.show_all() - - container.pack_start(self._create_license_selector(), - False, False, style.zoom(3)) - - guided_button = Gtk.ToggleButton.new_with_label(_('Guided spec')) - guided_button.get_style_context().add_class('create-ai-meta-button') - guided_button.set_halign(Gtk.Align.CENTER) - guided_button.connect('toggled', self.__guided_button_toggled_cb) - container.pack_start(guided_button, False, False, style.zoom(3)) - guided_button.show() - prompt_box = Gtk.EventBox() prompt_box.get_style_context().add_class('create-ai-prompt-box') prompt_box.set_above_child(False) @@ -653,27 +582,11 @@ def _create_create_view(self): prompt_box.add(prompt_inner) prompt_inner.show() - prompt_header = Gtk.HBox() - prompt_inner.pack_start(prompt_header, False, False, 0) - prompt_header.show() - - prompt_label = Gtk.Label(_('Learning prompt')) - prompt_label.get_style_context().add_class('create-ai-meta-note') - prompt_label.set_xalign(0) - prompt_header.pack_start(prompt_label, False, False, 0) - prompt_label.show() - - char_label = Gtk.Label('') - self._prompt_char_label = char_label - char_label.get_style_context().add_class('create-ai-meta-note') - prompt_header.pack_end(char_label, False, False, 0) - char_label.show() - prompt_scroll = Gtk.ScrolledWindow() prompt_scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) - prompt_scroll.set_min_content_height(style.zoom(88)) - prompt_scroll.set_max_content_height(style.zoom(88)) + prompt_scroll.set_min_content_height(style.zoom(96)) + prompt_scroll.set_max_content_height(style.zoom(96)) prompt_scroll.set_propagate_natural_height(False) prompt_inner.pack_start(prompt_scroll, False, False, 0) prompt_scroll.show() @@ -699,7 +612,7 @@ def _create_create_view(self): text.connect('focus-out-event', lambda w, e: prompt_box.get_style_context().remove_class( 'create-ai-prompt-box-focused')) - text.set_size_request(-1, style.zoom(88)) + text.set_size_request(-1, style.zoom(96)) prompt_scroll.add(text) text.show() self._set_prompt_placeholder() @@ -729,20 +642,49 @@ def _create_create_view(self): add_icon.show() hint_icon.set_image(add_icon) hint_icon.get_style_context().add_class('create-ai-plus') + hint_icon.set_valign(Gtk.Align.CENTER) + hint_icon.set_tooltip_text( + _('Try an example: treasure-map quest where teams solve clues ' + 'and explain each step.')) hint_icon.connect('clicked', self.__prompt_example_clicked_cb) bottom_row.pack_start(hint_icon, False, False, 0) hint_icon.show() - hint_text = Gtk.Label(_('Try: treasure-map quest where teams solve ' - 'clues and explain each step.')) - hint_text.get_style_context().add_class('create-ai-meta-note') - hint_text.set_xalign(0) - bottom_row.pack_start(hint_text, True, True, 0) - hint_text.show() + validate_chip = Gtk.ToggleButton() + validate_chip.set_relief(Gtk.ReliefStyle.NONE) + validate_chip.get_style_context().add_class('create-ai-prompt-chip') + validate_chip.get_style_context().add_class( + 'create-ai-prompt-chip-active') + validate_content, validate_value = self._build_chip_content( + _('Validation'), _('On')) + validate_chip.add(validate_content) + self._validate_chip_value_label = validate_value + validate_chip.set_active(True) + validate_chip.connect('toggled', self.__validate_chip_toggled_cb) + bottom_row.pack_start(validate_chip, False, False, 0) + validate_chip.show() + + send_btn = Gtk.Button() + send_icon = Icon(icon_name='go-up', + pixel_size=style.SMALL_ICON_SIZE, + stroke_color=style.COLOR_WHITE.get_svg(), + fill_color=style.COLOR_WHITE.get_svg()) + send_icon.show() + send_btn.set_image(send_icon) + send_btn.get_style_context().add_class('create-ai-send') + send_btn.set_size_request(style.zoom(36), style.zoom(36)) + send_btn.set_valign(Gtk.Align.CENTER) + send_btn.set_tooltip_text(_('Generate the activity')) + send_btn.connect('clicked', self.__send_button_clicked_cb) + bottom_row.pack_end(send_btn, False, False, 0) + send_btn.show() - send_row = Gtk.HBox(spacing=style.zoom(6)) - bottom_row.pack_end(send_row, False, False, 0) - send_row.show() + thinking = Gtk.Label('') + self._prompt_status_label = thinking + thinking.get_style_context().add_class('create-ai-prompt-status') + thinking.set_valign(Gtk.Align.CENTER) + bottom_row.pack_end(thinking, False, False, 0) + thinking.show() code_size_combo = Gtk.ComboBoxText() self._code_size_combo = code_size_combo @@ -754,31 +696,196 @@ def _create_create_view(self): code_size_combo.append(size_id, size_label) code_size_combo.set_active_id( self._selected_options.get('code_size', 'standard')) - code_size_combo.get_style_context().add_class('create-ai-provider-combo') + code_size_combo.get_style_context().add_class( + 'create-ai-provider-combo') + code_size_combo.set_valign(Gtk.Align.CENTER) code_size_combo.connect('changed', self.__code_size_combo_changed_cb) - send_row.pack_start(code_size_combo, False, False, 0) + bottom_row.pack_end(code_size_combo, False, False, 0) code_size_combo.show() - thinking = Gtk.Label(_('Thinking')) - self._prompt_status_label = thinking - thinking.get_style_context().add_class('create-ai-pill-button') - send_row.pack_start(thinking, False, False, 0) - thinking.show() + provider_selector = self._create_provider_selector() + + model_chip = Gtk.MenuButton() + model_chip.set_relief(Gtk.ReliefStyle.NONE) + model_chip.get_style_context().add_class('create-ai-prompt-chip') + model_content, model_value = self._build_chip_content( + _('Model'), + self._get_provider_label(self._selected_options['provider']), + caret=True) + model_chip.add(model_content) + self._provider_chip_value_label = model_value + model_popover = Gtk.Popover.new(model_chip) + model_popover.get_style_context().add_class('create-ai-popover') + model_popover.set_position(Gtk.PositionType.BOTTOM) + model_popover.add(provider_selector) + model_chip.set_popover(model_popover) + bottom_row.pack_end(model_chip, False, False, 0) + model_chip.show() + + cards_caption = Gtk.Label(_('Start with a learning area...')) + cards_caption.get_style_context().add_class( + 'create-ai-template-caption') + cards_caption.set_halign(Gtk.Align.CENTER) + container.pack_start(cards_caption, False, False, style.zoom(6)) + cards_caption.show() + + cards_row = Gtk.HBox(spacing=style.zoom(10)) + cards_row.set_halign(Gtk.Align.CENTER) + container.pack_start(cards_row, False, False, 0) + cards_row.show() + + self._option_buttons['template'] = [] + self._template_card_icons = {} + self._template_card_buttons = {} + fan_offsets = [0, 6, 12, 12, 6, 0] + for index, (value, card_title, card_detail, icon_name) in \ + enumerate(self._get_learning_area_options()): + card = self._create_template_card(value, card_title, + card_detail, icon_name) + card.set_margin_top( + style.zoom(fan_offsets[index % len(fan_offsets)])) + if value == self._selected_options.get('template'): + card.get_style_context().add_class( + 'create-ai-option-card-active') + cards_row.pack_start(card, False, False, 0) + card.show() + self._update_template_card_icons() - send_btn = Gtk.Button() - send_icon = Icon(icon_name='go-up', - pixel_size=style.SMALL_ICON_SIZE, - stroke_color=style.COLOR_WHITE.get_svg(), - fill_color=style.COLOR_BLACK.get_svg()) - send_icon.show() - send_btn.set_image(send_icon) - send_btn.get_style_context().add_class('create-ai-send') - send_btn.connect('clicked', self.__send_button_clicked_cb) - send_row.pack_start(send_btn, False, False, 0) - send_btn.show() + template_hint = Gtk.Label( + _('Learning area: logic and math activities for puzzles, ' + 'patterns, and reasoning.')) + self._template_hint = template_hint + template_hint.get_style_context().add_class('create-ai-meta-note') + template_hint.set_halign(Gtk.Align.CENTER) + template_hint.set_justify(Gtk.Justification.CENTER) + container.pack_start(template_hint, False, False, style.zoom(2)) + template_hint.show() + + planner_hint = Gtk.Label() + planner_hint.get_style_context().add_class('create-ai-meta-note') + self._planner_hint = planner_hint + planner_hint.set_halign(Gtk.Align.CENTER) + planner_hint.set_justify(Gtk.Justification.CENTER) + planner_hint.set_line_wrap(True) + planner_hint.set_max_width_chars(132) + container.pack_start(planner_hint, False, False, 0) + planner_hint.show() + self._update_planner_hint() return container + def _get_learning_area_options(self): + return [ + ('logic_math', _('Logic & math'), _('Puzzles & patterns'), + 'insert-table'), + ('science', _('Science'), _('Explore & measure'), + 'system-search'), + ('language', _('Language'), _('Stories & words'), + 'edit-description'), + ('tools_utils', _('Tools'), _('Build helpful tools'), + 'preferences-system'), + ('games', _('Games'), _('Play loops & score'), + 'media-playback-start'), + ('creation', _('Creation'), _('Make & express'), + 'toolbar-colors'), + ] + + def _build_chip_content(self, caption, value, caret=False): + box = Gtk.VBox(spacing=0) + + caption_label = Gtk.Label(caption) + caption_label.get_style_context().add_class('create-ai-chip-caption') + caption_label.set_xalign(0) + box.pack_start(caption_label, False, False, 0) + caption_label.show() + + value_row = Gtk.HBox(spacing=style.zoom(4)) + box.pack_start(value_row, False, False, 0) + value_row.show() + + value_label = Gtk.Label(value) + value_label.get_style_context().add_class('create-ai-chip-value') + value_label.set_xalign(0) + value_label.set_ellipsize(Pango.EllipsizeMode.END) + value_label.set_max_width_chars(14) + value_row.pack_start(value_label, False, False, 0) + value_label.show() + + if caret: + caret_label = Gtk.Label('▾') + caret_label.get_style_context().add_class('create-ai-chip-caret') + value_row.pack_start(caret_label, False, False, 0) + caret_label.show() + + box.show() + return box, value_label + + def _create_template_card(self, value, title, detail, icon_name): + button = Gtk.Button() + button.set_relief(Gtk.ReliefStyle.NONE) + button.get_style_context().add_class('create-ai-template-card') + button.set_size_request(style.zoom(150), style.zoom(150)) + button.connect('clicked', self.__option_card_clicked_cb, + 'template', value) + + content = Gtk.VBox(spacing=style.zoom(4)) + content.set_border_width(style.zoom(10)) + button.add(content) + content.show() + + icon = Icon(icon_name=icon_name, + pixel_size=style.STANDARD_ICON_SIZE, + stroke_color=style.COLOR_TOOLBAR_GREY.get_svg(), + fill_color=style.COLOR_INACTIVE_FILL.get_svg()) + icon.set_halign(Gtk.Align.CENTER) + content.pack_start(icon, True, True, 0) + icon.show() + + title_label = Gtk.Label(title) + title_label.get_style_context().add_class('create-ai-option-title') + title_label.set_justify(Gtk.Justification.CENTER) + content.pack_start(title_label, False, False, 0) + title_label.show() + + detail_label = Gtk.Label(detail) + detail_label.get_style_context().add_class('create-ai-option-detail') + detail_label.set_justify(Gtk.Justification.CENTER) + detail_label.set_line_wrap(True) + detail_label.set_max_width_chars(16) + content.pack_start(detail_label, False, False, 0) + detail_label.show() + + self._template_card_icons[value] = icon + self._template_card_buttons[value] = button + self._option_buttons['template'].append(button) + return button + + def _update_template_card_icons(self): + selected = self._selected_options.get('template') + for value, icon in self._template_card_icons.items(): + if value == selected: + icon.props.stroke_color = style.COLOR_WHITE.get_svg() + icon.props.fill_color = style.COLOR_BUTTON_GREY.get_svg() + else: + icon.props.stroke_color = style.COLOR_TOOLBAR_GREY.get_svg() + icon.props.fill_color = style.COLOR_INACTIVE_FILL.get_svg() + + def __validate_chip_toggled_cb(self, button): + active = button.get_active() + self._selected_options['validate'] = 'on' if active else 'off' + if self._validate_chip_value_label is not None: + self._validate_chip_value_label.set_text( + _('On') if active else _('Off')) + if active: + button.get_style_context().add_class( + 'create-ai-prompt-chip-active') + else: + button.get_style_context().remove_class( + 'create-ai-prompt-chip-active') + if self._prompt_status_label is not None: + self._prompt_status_label.set_text( + _('Validation on') if active else _('Validation off')) + def _create_section_label(self, text): label = Gtk.Label(text) label.get_style_context().add_class('create-ai-section-label') @@ -787,15 +894,25 @@ def _create_section_label(self, text): return label def _create_provider_selector(self): - selector = Gtk.VBox(spacing=style.zoom(8)) - selector.set_halign(Gtk.Align.CENTER) - selector.set_border_width(style.zoom(8)) + selector = Gtk.VBox(spacing=style.zoom(9)) + selector.set_border_width(style.zoom(14)) + selector.set_size_request(style.zoom(340), -1) - # Primary row: provider picker + key + save - row = Gtk.HBox(spacing=style.zoom(8)) - row.set_halign(Gtk.Align.CENTER) - selector.pack_start(row, False, False, 0) - row.show() + heading = Gtk.Label(_('AI model')) + heading.get_style_context().add_class('create-ai-provider-heading') + heading.set_xalign(0) + selector.pack_start(heading, False, False, 0) + heading.show() + + subtitle = Gtk.Label( + _('Choose who generates your activity. API keys stay private ' + 'in your Sugar profile.')) + subtitle.get_style_context().add_class('create-ai-meta-note') + subtitle.set_xalign(0) + subtitle.set_line_wrap(True) + subtitle.set_max_width_chars(46) + selector.pack_start(subtitle, False, False, 0) + subtitle.show() combo = Gtk.ComboBoxText() self._provider_combo = combo @@ -804,59 +921,69 @@ def _create_provider_selector(self): initial_provider = self._initial_provider_option() self._selected_options['provider'] = initial_provider combo.set_active_id(initial_provider) - combo.set_size_request(style.zoom(180), style.zoom(40)) + combo.set_size_request(-1, style.zoom(40)) combo.get_style_context().add_class('create-ai-provider-combo') combo.connect('changed', self.__provider_combo_changed_cb) - row.pack_start(combo, False, False, 0) + selector.pack_start(combo, False, False, 0) combo.show() + key_row = Gtk.HBox(spacing=style.zoom(6)) + selector.pack_start(key_row, False, False, 0) + key_row.show() + key_entry = Gtk.Entry() self._provider_key_entry = key_entry key_entry.set_visibility(False) key_entry.set_invisible_char('*') key_entry.set_input_purpose(Gtk.InputPurpose.PASSWORD) - key_entry.set_placeholder_text(_('API key')) + key_entry.set_placeholder_text(_('Paste your API key here')) key_entry.set_tooltip_text( _('Keys stay in your Sugar profile and are never added to ' 'generated activities.')) - key_entry.set_size_request(style.zoom(260), style.zoom(40)) + key_entry.set_size_request(-1, style.zoom(40)) key_entry.get_style_context().add_class('create-ai-provider-entry') key_entry.connect('key-press-event', self.__provider_key_entry_key_press_event_cb) key_entry.connect('paste-clipboard', self.__provider_key_entry_paste_clipboard_cb) - row.pack_start(key_entry, False, False, 0) + key_row.pack_start(key_entry, True, True, 0) key_entry.show() paste_button = Gtk.Button.new_with_label(_('Paste')) self._provider_paste_button = paste_button paste_button.set_size_request(style.zoom(72), style.zoom(40)) - paste_button.get_style_context().add_class('create-ai-provider-button') + paste_button.get_style_context().add_class( + 'create-ai-provider-button') paste_button.connect('clicked', self.__provider_paste_clicked_cb) - row.pack_start(paste_button, False, False, 0) + key_row.pack_start(paste_button, False, False, 0) paste_button.show() - apply_button = Gtk.Button.new_with_label(_('Save')) - self._provider_apply_button = apply_button - apply_button.set_size_request(style.zoom(72), style.zoom(40)) - apply_button.get_style_context().add_class('create-ai-provider-button') - apply_button.connect('clicked', self.__provider_apply_clicked_cb) - row.pack_start(apply_button, False, False, 0) - apply_button.show() + action_row = Gtk.HBox(spacing=style.zoom(6)) + selector.pack_start(action_row, False, False, 0) + action_row.show() - remove_button = Gtk.Button.new_with_label(_('Remove')) + remove_button = Gtk.Button.new_with_label(_('Remove key')) self._provider_remove_button = remove_button - remove_button.set_size_request(style.zoom(80), style.zoom(40)) - remove_button.get_style_context().add_class('create-ai-provider-button') + remove_button.set_size_request(style.zoom(104), style.zoom(40)) + remove_button.get_style_context().add_class( + 'create-ai-provider-button') remove_button.connect('clicked', self.__provider_remove_clicked_cb) - row.pack_start(remove_button, False, False, 0) + action_row.pack_start(remove_button, False, False, 0) remove_button.show() + apply_button = Gtk.Button.new_with_label(_('Save key')) + self._provider_apply_button = apply_button + apply_button.set_size_request(style.zoom(104), style.zoom(40)) + apply_button.get_style_context().add_class( + 'create-ai-provider-primary') + apply_button.connect('clicked', self.__provider_apply_clicked_cb) + action_row.pack_end(apply_button, False, False, 0) + apply_button.show() + # Keep model/endpoint widgets as non-visible stubs so the save # callback can still read them without crashing. self._provider_model_entry = Gtk.Entry() self._provider_endpoint_entry = Gtk.Entry() - adv_row = None # model_switch_row (opencode-go specific) model_switch_row = Gtk.HBox(spacing=style.zoom(6)) @@ -878,11 +1005,10 @@ def _create_provider_selector(self): status = Gtk.Label() self._provider_status_label = status - status.get_style_context().add_class('create-ai-meta-note') - status.set_halign(Gtk.Align.CENTER) - status.set_justify(Gtk.Justification.CENTER) + status.get_style_context().add_class('create-ai-provider-status') + status.set_xalign(0) status.set_line_wrap(True) - status.set_max_width_chars(100) + status.set_max_width_chars(46) selector.pack_start(status, False, False, 0) status.show() @@ -924,37 +1050,6 @@ def _initial_provider_option(self): return provider_name return 'default' - def _create_license_selector(self): - selector = Gtk.VBox(spacing=style.zoom(4)) - selector.set_halign(Gtk.Align.CENTER) - - selector.pack_start(self._create_option_group( - _('License'), - 'license', - self._get_license_option_cards(), - 'mit', - card_width=142, - card_height=76, - detail_width=15), False, False, 0) - - hint = Gtk.Label() - self._license_hint = hint - hint.get_style_context().add_class('create-ai-meta-note') - hint.set_halign(Gtk.Align.CENTER) - hint.set_justify(Gtk.Justification.CENTER) - hint.set_line_wrap(True) - hint.set_max_width_chars(120) - selector.pack_start(hint, False, False, 0) - hint.show() - - self._update_license_hint() - selector.show() - return selector - - def _get_license_option_cards(self): - return [(option['value'], option['label'], option['card_detail']) - for option in self._get_license_options()] - def _get_license_options(self): return [ { @@ -1129,6 +1224,9 @@ def _create_studio_view(self): footer.pack_start(self._create_plain_button( _('Export XO'), self.__export_xo_cb), False, False, 0) + footer.pack_start(self._create_plain_button( + _('Export Flatpak'), self.__export_flatpak_cb), + False, False, 0) footer.pack_start(self._create_primary_button( _('Install & Open'), self.__install_and_open_cb), False, False, 0) @@ -1364,8 +1462,10 @@ def _create_studio_preview_panel(self): preview_scroll.add(self._create_preview_frame()) preview_page.pack_start(preview_scroll, True, True, 0) preview_scroll.show() - preview_page.pack_start(self._create_live_edit_panel(), False, False, - 0) + self._live_edit_panel = self._create_live_edit_panel() + preview_page.pack_start(self._live_edit_panel, False, False, 0) + self._ask_bar = self._create_ask_bar() + preview_page.pack_start(self._ask_bar, False, False, 0) self._studio_mode_stack.add_named(preview_page, 'preview') preview_page.show() @@ -1399,7 +1499,8 @@ def _create_preview_frame(self): preview_align.add(self._create_activity_preview()) help_text = Gtk.Label( - _('Click a preview area to select it, then describe the change.')) + _('Live edit: click a part or drag across an area, then ' + 'describe the change.')) help_text.get_style_context().add_class('create-ai-meta-note') help_text.set_xalign(0) frame_box.pack_start(help_text, False, False, 0) @@ -1434,6 +1535,9 @@ def _clear_activity_preview(self): for child in self._preview_content_box.get_children(): self._preview_content_box.remove(child) self._live_preview_canvas = None + self._preview_shell = None + self._select_start = None + self._select_rect = None if self._live_preview_activity is not None: try: self._live_preview_activity.cleanup() @@ -1445,6 +1549,18 @@ def _clear_activity_preview(self): self._preview_generation_spinner = None self._preview_generation_progress = None self._preview_generation_stage = None + self._preview_generation_xo = None + self._preview_generation_fun = None + self._preview_generation_canvas = None + self._generation_anim_start_us = None + self._generation_anim_done = False + self._generation_final_rgb = None + self._generation_fun_next = None + self._generation_target_fraction = None + self._generation_shown_fraction = 0.0 + self._generation_fraction_mix = 0.0 + self._generation_done_at = None + self._generation_fade_widgets = [] self._preview_generation_steps = [] self._preview_generation_step_boxes = [] @@ -1468,9 +1584,54 @@ def _show_empty_activity_preview(self): self._preview_content_box.pack_start(note, False, False, 0) note.show() - def _show_generation_activity_preview(self, message=None): + def _show_generation_activity_preview(self): self._clear_activity_preview() + self._generation_anim_start_us = None + self._generation_anim_t = 0.0 + self._generation_anim_done = False + self._generation_final_rgb = None + self._generation_fun_next = None + self._generation_fun_alpha = 1.0 + self._generation_target_fraction = None + self._generation_shown_fraction = 0.0 + self._generation_fraction_mix = 0.0 + self._generation_done_at = None + self._generation_fade_widgets = [] + + xo_icon = None + canvas = None + try: + stroke, fill = self._xo_pulse_color(0) + xo_icon = Icon(icon_name='computer-xo', + pixel_size=style.zoom(120), + stroke_color=stroke, + fill_color=fill) + except Exception: + logging.exception('Could not create pulsing XO icon') + self._preview_generation_xo = xo_icon + if xo_icon is not None: + size = style.zoom(200) + canvas = Gtk.DrawingArea() + canvas.set_size_request(size, size) + canvas.connect('draw', self._draw_generation_canvas) + self._preview_generation_canvas = canvas + + overlay = Gtk.Overlay() + overlay.set_halign(Gtk.Align.CENTER) + overlay.set_margin_top(style.zoom(4)) + overlay.add(canvas) + canvas.show() + + xo_icon.set_halign(Gtk.Align.CENTER) + xo_icon.set_valign(Gtk.Align.CENTER) + overlay.add_overlay(xo_icon) + xo_icon.show() + + self._preview_content_box.pack_start(overlay, False, False, 0) + overlay.show() + canvas.add_tick_callback(self._generation_canvas_tick) + title = Gtk.Label(_('Building your activity')) self._preview_empty_title = title title.get_style_context().add_class('create-ai-preview-title') @@ -1479,7 +1640,7 @@ def _show_generation_activity_preview(self, message=None): title.show() note = Gtk.Label( - message or _('Turning your idea into a real Sugar activity')) + _('Turning your idea into a real Sugar activity')) self._preview_empty_note = note note.get_style_context().add_class('create-ai-meta-note') note.set_justify(Gtk.Justification.CENTER) @@ -1489,13 +1650,17 @@ def _show_generation_activity_preview(self, message=None): self._preview_content_box.pack_start(note, False, False, 0) note.show() - progress = Gtk.ProgressBar() - self._preview_generation_progress = progress - progress.set_size_request(style.zoom(280), style.zoom(8)) - progress.get_style_context().add_class('create-ai-generation-progress') - self._preview_content_box.pack_start( - progress, False, False, style.zoom(6)) - progress.show() + if canvas is None: + # No orbit canvas to close into a progress ring, so fall + # back to the plain bar. + progress = Gtk.ProgressBar() + self._preview_generation_progress = progress + progress.set_size_request(style.zoom(280), style.zoom(8)) + progress.get_style_context().add_class( + 'create-ai-generation-progress') + self._preview_content_box.pack_start( + progress, False, False, style.zoom(6)) + progress.show() stage = Gtk.Label() self._preview_generation_stage = stage @@ -1507,6 +1672,16 @@ def _show_generation_activity_preview(self, message=None): stage, False, False, style.zoom(4)) stage.show() + fun = Gtk.Label(self._generation_fun_messages()[0]) + self._preview_generation_fun = fun + fun.get_style_context().add_class('create-ai-generation-fun') + fun.set_justify(Gtk.Justification.CENTER) + fun.set_line_wrap(True) + fun.set_max_width_chars(60) + self._preview_content_box.pack_start( + fun, False, False, style.zoom(2)) + fun.show() + steps_box = Gtk.VBox(spacing=style.zoom(8)) steps_box.set_margin_top(style.zoom(6)) self._preview_content_box.pack_start( @@ -1515,15 +1690,15 @@ def _show_generation_activity_preview(self, message=None): step_defs = [ (_('Think'), _('Reading your prompt and planning the activity'), - 'emblem-default'), + 'emblem-question'), (_('Gather'), _('Finding real Sugar activity examples for context'), 'system-search'), (_('Write'), _('Writing the Python code for your activity'), - 'accessories-text-editor'), + 'edit-description'), (_('Check'), _('Preparing project files and previewing'), - 'dialog-apply'), + 'dialog-ok'), (_('Ready'), _('Packaging your installable activity bundle'), - 'package-x-generic'), + 'package_settings'), ] self._preview_generation_steps = [] self._preview_generation_step_boxes = [] @@ -1535,7 +1710,6 @@ def _show_generation_activity_preview(self, message=None): icon = None try: - from sugar3.graphics.icon import Icon icon = Icon(icon_name=icon_name, pixel_size=style.zoom(16)) except Exception: @@ -1568,6 +1742,15 @@ def _show_generation_activity_preview(self, message=None): self._preview_generation_steps.append(label) self._preview_generation_step_boxes.append(row) + if canvas is not None: + # Staggered entrance: each element fades in a beat after + # the previous one, driven by the canvas frame tick. + self._generation_fade_widgets = [ + (xo_icon, 0.0), (title, 0.15), (note, 0.25), + (stage, 0.35), (fun, 0.45), (steps_box, 0.55)] + for widget, _unused in self._generation_fade_widgets: + widget.set_opacity(0.0) + def _create_review_page(self): review = Gtk.EventBox() review.get_style_context().add_class('create-ai-review-shell') @@ -2827,7 +3010,8 @@ def _create_live_edit_panel(self): toggle.pack_start(self._live_edit_off_button, False, False, 0) description = Gtk.Label( - _('Click a preview part, then describe the change.')) + _('Turn on, then click a part or drag across an area of the ' + 'preview to target it.')) description.get_style_context().add_class('create-ai-meta-note') description.set_xalign(0) description.set_line_wrap(True) @@ -2875,6 +3059,149 @@ def _create_live_edit_panel(self): panel.show() return panel + def _create_ask_bar(self): + bar = Gtk.EventBox() + bar.get_style_context().add_class('create-ai-ask-bar') + bar.set_halign(Gtk.Align.CENTER) + bar.set_size_request(style.zoom(720), -1) + + row = Gtk.HBox(spacing=style.zoom(8)) + row.set_border_width(style.zoom(6)) + bar.add(row) + row.show() + + mode_group = Gtk.HBox(spacing=0) + mode_group.get_style_context().add_class('create-ai-ask-mode-group') + mode_group.set_valign(Gtk.Align.CENTER) + row.pack_start(mode_group, False, False, 0) + mode_group.show() + + edit_button = Gtk.Button.new_with_label(_('Edit')) + self._ask_bar_edit_on = edit_button + edit_button.set_relief(Gtk.ReliefStyle.NONE) + edit_button.get_style_context().add_class('create-ai-ask-mode') + if self._live_edit_enabled: + edit_button.get_style_context().add_class( + 'create-ai-ask-mode-active') + edit_button.set_tooltip_text( + _('Edit mode: click or drag on the preview to pick a target.')) + edit_button.connect('clicked', self.__live_toggle_clicked_cb, True) + mode_group.pack_start(edit_button, False, False, 0) + edit_button.show() + + play_button = Gtk.Button.new_with_label(_('Play')) + self._ask_bar_edit_off = play_button + play_button.set_relief(Gtk.ReliefStyle.NONE) + play_button.get_style_context().add_class('create-ai-ask-mode') + if not self._live_edit_enabled: + play_button.get_style_context().add_class( + 'create-ai-ask-mode-active') + play_button.set_tooltip_text( + _('Play mode: try the activity like a learner.')) + play_button.connect('clicked', self.__live_toggle_clicked_cb, False) + mode_group.pack_start(play_button, False, False, 0) + play_button.show() + + plus = Gtk.Button() + plus_icon = Icon(icon_name='list-add', + pixel_size=style.SMALL_ICON_SIZE, + stroke_color='#e2e2e2', + fill_color='#e2e2e2') + plus_icon.show() + plus.set_image(plus_icon) + plus.set_relief(Gtk.ReliefStyle.NONE) + plus.get_style_context().add_class('create-ai-ask-plus') + plus.set_valign(Gtk.Align.CENTER) + plus.set_tooltip_text(_('Target the whole activity again')) + plus.connect('clicked', self.__ask_bar_reset_target_cb) + row.pack_start(plus, False, False, 0) + plus.show() + self._ask_bar_plus = plus + + target = Gtk.Label(self._live_edit_target) + self._ask_bar_target_label = target + target.get_style_context().add_class('create-ai-ask-target') + target.set_valign(Gtk.Align.CENTER) + target.set_ellipsize(Pango.EllipsizeMode.END) + target.set_max_width_chars(18) + target.set_tooltip_text( + _('Click a part of the preview, or drag across an area, ' + 'to change just that part.')) + row.pack_start(target, False, False, 0) + target.show() + + entry = Gtk.Entry() + self._ask_bar_entry = entry + entry.set_placeholder_text( + _('Describe a change for the selected part') + if self._live_edit_enabled else _('Ask anything')) + entry.set_has_frame(False) + entry.get_style_context().add_class('create-ai-ask-entry') + entry.connect('activate', self.__ask_bar_send_cb) + row.pack_start(entry, True, True, 0) + entry.show() + + status = Gtk.Label('') + self._ask_bar_status_label = status + status.get_style_context().add_class('create-ai-ask-status') + status.set_valign(Gtk.Align.CENTER) + status.set_ellipsize(Pango.EllipsizeMode.END) + status.set_max_width_chars(24) + row.pack_start(status, False, False, 0) + status.show() + + send = Gtk.Button() + send_icon = Icon(icon_name='go-up', + pixel_size=style.SMALL_ICON_SIZE, + stroke_color=style.COLOR_WHITE.get_svg(), + fill_color=style.COLOR_WHITE.get_svg()) + send_icon.show() + send.set_image(send_icon) + send.set_relief(Gtk.ReliefStyle.NONE) + send.get_style_context().add_class('create-ai-ask-send') + send.set_valign(Gtk.Align.CENTER) + send.set_tooltip_text(_('Apply the change')) + send.connect('clicked', self.__ask_bar_send_cb) + row.pack_start(send, False, False, 0) + send.show() + + return bar + + def _set_live_edit_status(self, text): + if self._live_edit_status_label is not None: + self._live_edit_status_label.set_text(text) + if self._ask_bar_status_label is not None: + self._ask_bar_status_label.set_text(text) + + def __ask_bar_reset_target_cb(self, button): + self._set_live_edit_target(_('activity canvas')) + if self._ask_bar_entry is not None: + self._ask_bar_entry.grab_focus() + + def __ask_bar_send_cb(self, widget): + if self._ask_bar_entry is None or self._live_edit_entry is None: + return + + text = self._ask_bar_entry.get_text().strip() + if not text: + self._ask_bar_entry.grab_focus() + self._set_live_edit_status(_('Describe the change first.')) + return + + self._ask_bar_entry.set_text('') + if self._live_edit_enabled: + self._live_edit_entry.set_text(text) + self.__live_edit_add_clicked_cb(widget) + return + + # Play mode: send the request as a whole-activity refinement. + if self._generation_result is None: + self._set_live_edit_status( + _('Generate an activity before asking for changes.')) + return + self._set_live_edit_status(_('Refining...')) + self._submit_refinement_from_prompt(text, source='chat') + def _create_learning_sidebar(self): panel = Gtk.EventBox() panel.get_style_context().add_class('create-ai-learning-sidebar') @@ -3296,6 +3623,98 @@ def _ensure_css(self): color: #686868; font-size: 13px; } + .create-ai-hero-title { + color: #1c1c1c; + font-weight: 700; + font-size: 30px; + } + .create-ai-template-caption { + color: #8a8a8a; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + } + button.create-ai-prompt-chip { + border-radius: 10px; + border: 1px solid #e3e3e3; + background-image: none; + background-color: #fafafa; + padding: 3px 10px; + min-height: 0; + } + button.create-ai-prompt-chip:hover { + background-color: %(studio_lavender_soft)s; + border-color: %(studio_lavender_faint)s; + } + button.create-ai-prompt-chip-active { + background-color: %(studio_lavender_soft)s; + border-color: %(studio_lavender_border)s; + } + .create-ai-chip-caption { + color: #9a9a9a; + font-size: 9px; + } + .create-ai-chip-value { + color: %(toolbar)s; + font-size: 11px; + font-weight: 700; + } + .create-ai-chip-caret { + color: #9a9a9a; + font-size: 8px; + } + .create-ai-prompt-status { + color: #8a8a8a; + font-size: 11px; + } + popover.create-ai-popover { + background-color: %(studio_surface)s; + border: 1px solid %(studio_edge)s; + border-radius: 10px; + } + .create-ai-provider-heading { + color: #202020; + font-weight: 700; + font-size: 13px; + } + .create-ai-provider-status { + color: #7a7a7a; + font-size: 10px; + } + button.create-ai-provider-primary { + border-radius: 7px; + border: 1px solid %(studio_dark)s; + background-image: none; + background-color: %(studio_dark)s; + color: %(studio_dark_text)s; + padding: 7px 12px; + font-size: 11px; + font-weight: 700; + } + button.create-ai-provider-primary label { + color: %(studio_dark_text)s; + } + button.create-ai-provider-primary:hover { + background-color: %(studio_dark_hover)s; + border-color: %(studio_dark_hover)s; + } + button.create-ai-template-card { + border-radius: 12px; + border: 1px solid %(studio_edge)s; + background-image: none; + background-color: %(studio_surface)s; + color: %(toolbar)s; + padding: 0; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08); + transition: background-color 120ms ease, + border-color 120ms ease, + box-shadow 120ms ease; + } + button.create-ai-template-card:hover { + background-color: %(studio_lavender_soft)s; + border-color: %(studio_lavender_faint)s; + box-shadow: 0 4px 9px rgba(0, 0, 0, 0.12); + } .create-ai-meta-label { color: %(toolbar)s; font-weight: 700; @@ -3321,10 +3740,16 @@ def _ensure_css(self): font-weight: 400; font-size: 11px; } + .create-ai-generation-fun { + color: #a58324; + font-size: 11px; + font-style: italic; + } .create-ai-generation-step { color: #aaa; font-size: 11px; font-weight: 400; + transition: color 250ms ease-out; } .create-ai-generation-step-active { color: #333; @@ -3338,6 +3763,8 @@ def _ensure_css(self): padding: 6px 10px; border-radius: 8px; border: 1px solid transparent; + transition: background-color 250ms ease-out, + border-color 250ms ease-out; } .create-ai-generation-step-row-active { background-color: %(studio_lavender_soft)s; @@ -3623,20 +4050,20 @@ def _ensure_css(self): } .create-ai-send { border-radius: 999px; - border: 1px solid #555555; + border: 1px solid %(toolbar)s; background-image: none; - background-color: #404040; - color: #ffffff; - padding: 1px 6px; - min-width: 28px; - min-height: 28px; + background-color: %(toolbar)s; + color: %(white)s; + padding: 0; + min-width: 34px; + min-height: 34px; } .create-ai-send label { - color: #ffffff; + color: %(white)s; } .create-ai-send:hover { - background-color: #2e2e2e; - border-color: #444444; + background-color: %(black)s; + border-color: %(black)s; } .create-ai-studio-workspace { border-radius: 14px; @@ -4077,6 +4504,92 @@ def _ensure_css(self): border: 2px solid rgba(255, 200, 0, 0.85); border-radius: 3px; } + .create-ai-ask-bar { + border-radius: 999px; + border: 1px solid #3d3d3d; + background-color: #2b2b2b; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.25); + } + .create-ai-ask-mode-group { + border-radius: 999px; + border: 1px solid #4d4d4d; + background-color: #232323; + } + button.create-ai-ask-mode { + border-radius: 999px; + border: 1px solid transparent; + background-image: none; + background-color: transparent; + padding: 2px 10px; + font-size: 10px; + font-weight: 700; + min-height: 0; + } + button.create-ai-ask-mode label { + color: #b8b8b8; + } + button.create-ai-ask-mode:hover { + background-color: #3d3d3d; + } + button.create-ai-ask-mode-active { + background-color: %(studio_cream)s; + border-color: %(studio_cream_border)s; + } + button.create-ai-ask-mode-active label { + color: %(studio_cream_text)s; + } + button.create-ai-ask-mode-active:hover { + background-color: %(studio_cream)s; + } + button.create-ai-ask-plus { + border-radius: 999px; + border: 0; + background-image: none; + background-color: transparent; + padding: 0; + min-width: 30px; + min-height: 30px; + } + button.create-ai-ask-plus:hover { + background-color: #3d3d3d; + } + .create-ai-ask-target { + border-radius: 999px; + background-color: #3d3d3d; + color: #d8d8d8; + padding: 2px 10px; + font-size: 10px; + } + entry.create-ai-ask-entry { + border: 0; + border-radius: 0; + background-color: transparent; + background-image: none; + box-shadow: none; + color: #f0f0f0; + caret-color: #ffffff; + font-size: 13px; + } + entry.create-ai-ask-entry:focus { + border: 0; + box-shadow: none; + } + .create-ai-ask-status { + color: #9a9a9a; + font-size: 10px; + } + button.create-ai-ask-send { + border-radius: 999px; + border: 1px solid #4d4d4d; + background-image: none; + background-color: #454545; + padding: 0; + min-width: 32px; + min-height: 32px; + } + button.create-ai-ask-send:hover { + background-color: #5a5a5a; + } .create-ai-learning-sidebar { border-radius: 12px; border: 1px solid %(studio_edge)s; @@ -4205,7 +4718,7 @@ def _reset_prompt(self): if self._prompt_text is not None: self._set_prompt_placeholder() if self._prompt_status_label is not None: - self._prompt_status_label.set_text(_('Thinking')) + self._prompt_status_label.set_text('') def _set_prompt_placeholder(self): if self._prompt_text is None: @@ -4247,7 +4760,7 @@ def append_prompt_text(self, text): self._set_prompt_text(text) self.focus_prompt() if self._prompt_status_label is not None: - self._prompt_status_label.set_text(_('Thinking')) + self._prompt_status_label.set_text('') def focus_prompt(self): GObject.idle_add(self.__focus_prompt_text) @@ -4262,17 +4775,18 @@ def _set_studio_prompt(self, prompt): for label in self._studio_prompt_labels: label.set_text(prompt) - def _set_live_edit_target(self, target): + def _set_live_edit_target(self, target, is_region=False): target = target.strip() if isinstance(target, str) else '' if not target: target = _('activity canvas') self._live_edit_target = target + self._live_edit_target_is_region = is_region if self._live_edit_target_label is not None: self._live_edit_target_label.set_text( _('Preview target: %s') % target) - if self._live_edit_status_label is not None: - self._live_edit_status_label.set_text( - _('Target selected: %s') % target) + if self._ask_bar_target_label is not None: + self._ask_bar_target_label.set_text(target) + self._set_live_edit_status(_('Target selected: %s') % target) def _attach_live_edit_handlers_to_preview(self, canvas, toolbar): """Walk the live preview widget tree and attach click-to-select handlers.""" @@ -4296,6 +4810,7 @@ def _detach_live_edit_handlers(self): except Exception: pass self._live_edit_handler_ids = [] + self._live_edit_targets = [] def _walk_and_attach_live_edit(self, widget, in_toolbar=False, depth=0): if depth > 12: @@ -4303,6 +4818,7 @@ def _walk_and_attach_live_edit(self, widget, in_toolbar=False, depth=0): desc = self._describe_widget_for_live_edit(widget, in_toolbar) if desc: # Leaf/interactive widget — attach handler and stop recursing + self._live_edit_targets.append((widget, desc)) try: widget.add_events(Gdk.EventMask.BUTTON_PRESS_MASK) hid = widget.connect( @@ -4382,7 +4898,16 @@ def _describe_widget_for_live_edit(self, widget, in_toolbar): def __live_edit_widget_press_cb(self, widget, event, description): if not self._live_edit_enabled: return False + # Mark this press as handled so the outer shell's generic + # 'activity canvas' handler does not overwrite the target, but + # still return False so the activity itself reacts to the click. + self._live_edit_press_handled = True + GObject.idle_add(self.__clear_live_edit_press_flag) self._set_live_edit_target(description) + self._highlight_live_edit_widget(widget) + return False + + def _highlight_live_edit_widget(self, widget): if self._live_edit_highlighted is not None: try: self._live_edit_highlighted.get_style_context().remove_class( @@ -4390,18 +4915,113 @@ def __live_edit_widget_press_cb(self, widget, event, description): except Exception: pass self._live_edit_highlighted = widget + if widget is None: + return try: widget.get_style_context().add_class('live-edit-selected') except Exception: pass - return True # stop propagation so outer shell doesn't overwrite target - def __preview_target_button_press_event_cb(self, widget, event, target): - if self._live_edit_enabled: - self._set_live_edit_target(target) + def _focus_live_edit_entry(self): + if self._preview_is_fullscreen and self._ask_bar_entry is not None: + self._ask_bar_entry.grab_focus() + elif self._live_edit_entry is not None: + self._live_edit_entry.grab_focus() + + def __preview_shell_press_cb(self, shell, event): + if not self._live_edit_enabled or event.button != 1: + return False + self._select_start = (event.x, event.y) + self._select_rect = None + shell.queue_draw() + return True + + def __preview_shell_motion_cb(self, shell, event): + if self._select_start is None: + return False + x0, y0 = self._select_start + self._select_rect = (min(x0, event.x), min(y0, event.y), + abs(event.x - x0), abs(event.y - y0)) + shell.queue_draw() + return True + + def __preview_shell_release_cb(self, shell, event): + if self._select_start is None: + return False + self._select_start = None + rect = self._select_rect + if rect is None or (rect[2] < 8 and rect[3] < 8): + # A plain click: target the widget under the pointer. + self._select_rect = None + target = self._pick_live_edit_target_at(shell, event.x, event.y) + self._set_live_edit_target(target or _('activity canvas')) + else: + # A drag: target the marked region, described in percentages + # of the preview so refinements know where to look. + self._highlight_live_edit_widget(None) + alloc = shell.get_allocation() + width = max(alloc.width, 1) + height = max(alloc.height, 1) + x, y, w, h = rect + self._set_live_edit_target( + _('area %(x)d%%, %(y)d%% • %(w)d%% × %(h)d%%') % { + 'x': int(round(x * 100.0 / width)), + 'y': int(round(y * 100.0 / height)), + 'w': int(round(w * 100.0 / width)), + 'h': int(round(h * 100.0 / height)), + }, is_region=True) + shell.queue_draw() + self._focus_live_edit_entry() + return True + + def __preview_shell_draw_after_cb(self, shell, cr): + rect = self._select_rect + if rect is None or not self._live_edit_enabled: + return False + x, y, w, h = rect + cr.set_source_rgba(1.0, 0.78, 0.0, 0.14) + cr.rectangle(x, y, w, h) + cr.fill() + cr.set_source_rgba(1.0, 0.78, 0.0, 0.9) + cr.set_line_width(2) + cr.rectangle(x, y, w, h) + cr.stroke() return False + def _pick_live_edit_target_at(self, shell, x, y): + best_desc = None + best_widget = None + best_area = None + for widget, desc in self._live_edit_targets: + try: + if not widget.get_mapped(): + continue + pos = widget.translate_coordinates(shell, 0, 0) + if not pos: + continue + wx, wy = pos + alloc = widget.get_allocation() + except Exception: + continue + if not (wx <= x <= wx + alloc.width and + wy <= y <= wy + alloc.height): + continue + area = alloc.width * alloc.height + if best_area is None or area < best_area: + best_desc = desc + best_widget = widget + best_area = area + if best_widget is not None: + self._highlight_live_edit_widget(best_widget) + return best_desc + + def __clear_live_edit_press_flag(self): + self._live_edit_press_handled = False + return False + def __preview_target_button_press_event_cb(self, widget, event, target): + if self._live_edit_press_handled: + return False if self._live_edit_enabled: self._set_live_edit_target(target) return False @@ -4410,27 +5030,11 @@ def _update_planner_hint(self): if self._planner_hint is None: return - planner_notes = { - 'rag': _('RAG over Sugar activity examples'), - 'direct': _('direct LLM call, no Sugar examples'), - 'validate': _('local template planning plus AST validation'), - } - policy_notes = { - 'standard': _('configured provider, no template fallback'), - 'creative': _('fewer structural constraints, more expression'), - 'local': _('Ollama when configured, otherwise local templates'), - 'strict': _('fixed local templates without model calls'), - } - planner = planner_notes.get(self._selected_options['planner'], - self._selected_options['planner']) - policy = policy_notes.get(self._selected_options['policy'], - self._selected_options['policy']) provider = self._get_provider_label( self._selected_options['provider']) self._planner_hint.set_text( - _('Planner mode: %s. Policy: %s. Provider: %s. Generated code ' - 'will still be checked before it becomes an activity.') - % (planner, policy, provider)) + _('Provider: %s. Generated code is still checked before it ' + 'becomes an activity.') % provider) def _update_provider_controls(self): if self._provider_key_entry is None: @@ -4924,7 +5528,9 @@ def _start_generation_animation(self, message=None): if self._generation_animation_hide_id: GLib.source_remove(self._generation_animation_hide_id) self._generation_animation_hide_id = 0 - self._show_generation_activity_preview(message) + self._generation_tick_count = 0 + self._generation_has_fraction = False + self._show_generation_activity_preview() if self._preview_generation_progress is not None: self._preview_generation_progress.set_fraction(0.0) self._preview_generation_progress.show() @@ -4948,19 +5554,297 @@ def _stop_generation_animation(self): self._preview_generation_progress.hide() if self._preview_generation_stage is not None: self._preview_generation_stage.hide() + was_done = self._generation_anim_done + # Settle the orbit canvas into its calm full ring, then freeze + # it; the tick callback removes itself once the reference drops. + canvas = self._preview_generation_canvas + self._preview_generation_canvas = None + if canvas is not None and not was_done: + self._generation_anim_done = True + canvas.queue_draw() + self._generation_fun_next = None + # The staggered entrance may not have finished (or even started, + # if generation failed within milliseconds) — restore every + # faded element so nothing is left invisible. + for child, _unused in self._generation_fade_widgets: + child.set_opacity(1.0) + self._generation_fade_widgets = [] + if self._preview_generation_fun is not None: + self._generation_fun_alpha = 1.0 + self._preview_generation_fun.set_opacity(1.0) + if not was_done: + # A playful "building..." quip reads wrong next to a + # failure or cancel notice. + self._preview_generation_fun.hide() + + def _generation_fun_messages(self): + return [ + _('Great ideas take a moment to build...'), + _('Real Sugar activities are lending a hand as examples.'), + _('Mixing colors, code, and curiosity...'), + _('Teaching your activity how to play fair...'), + _('Almost like magic — but it\'s Python!'), + _('Wiring up buttons for curious fingers...'), + _('Your idea is becoming something learners can touch.'), + ] + + def _hex_to_rgb(self, hex_color): + hex_color = hex_color.lstrip('#') + try: + return tuple(int(hex_color[i:i + 2], 16) / 255.0 + for i in (0, 2, 4)) + except (ValueError, IndexError): + return (0.16, 0.16, 0.16) + + def _generation_color_wheel(self): + if self._generation_wheel_cache is not None: + return self._generation_wheel_cache + try: + from sugar3.graphics.xocolor import colors as xo_colors + except Exception: + xo_colors = [['#282828', '#B8B8B8']] + wheel = [] + for stroke, fill in xo_colors: + red, green, blue = self._hex_to_rgb(fill) + # Near-white fills make the XO body vanish on the light + # preview panel, so leave them out of the drift. + if 0.299 * red + 0.587 * green + 0.114 * blue > 0.86: + continue + wheel.append((stroke, fill)) + if not wheel: + wheel = [('#282828', '#B8B8B8')] + self._generation_wheel_cache = wheel + return wheel + + def _xo_pulse_color(self, index): + wheel = self._generation_color_wheel() + stroke, fill = wheel[index % len(wheel)] + return stroke, fill + + def _generation_wheel_rgb(self, phase): + """Smoothly interpolated (stroke, fill) rgb along the XO wheel.""" + wheel = self._generation_color_wheel() + index = int(phase) + frac = phase - index + stroke_a, fill_a = wheel[index % len(wheel)] + stroke_b, fill_b = wheel[(index + 1) % len(wheel)] + + def _lerp(hex_a, hex_b): + rgb_a = self._hex_to_rgb(hex_a) + rgb_b = self._hex_to_rgb(hex_b) + return tuple(a + (b - a) * frac for a, b in zip(rgb_a, rgb_b)) + + return _lerp(stroke_a, stroke_b), _lerp(fill_a, fill_b) + + def _generation_canvas_tick(self, widget, frame_clock): + if widget is not self._preview_generation_canvas: + return GLib.SOURCE_REMOVE + now = frame_clock.get_frame_time() + if self._generation_anim_start_us is None: + self._generation_anim_start_us = now + previous = self._generation_anim_t + self._generation_anim_t = \ + (now - self._generation_anim_start_us) / 1000000.0 + dt = max(0.0, self._generation_anim_t - previous) + + # Glide the displayed progress toward the backend's fraction so + # the ring never jumps. + if self._generation_target_fraction is not None: + self._generation_fraction_mix = min( + 1.0, self._generation_fraction_mix + dt * 2.0) + gap = self._generation_target_fraction - \ + self._generation_shown_fraction + self._generation_shown_fraction += gap * min(1.0, dt * 2.5) + + fun = self._preview_generation_fun + if fun is not None: + # Crossfade the playful messages instead of snapping them. + if self._generation_fun_next is not None: + self._generation_fun_alpha = max( + 0.0, self._generation_fun_alpha - dt * 4.0) + if self._generation_fun_alpha == 0.0: + fun.set_text(self._generation_fun_next) + self._generation_fun_next = None + elif self._generation_fun_alpha < 1.0: + self._generation_fun_alpha = min( + 1.0, self._generation_fun_alpha + dt * 4.0) + + # Staggered entrance fades; afterwards opacities hold steady + # (GTK ignores set_opacity calls with an unchanged value). + for child, delay in self._generation_fade_widgets: + progress = (self._generation_anim_t - delay) / 0.5 + progress = max(0.0, min(1.0, progress)) + eased = progress * progress * (3.0 - 2.0 * progress) + if child is fun: + eased *= self._generation_fun_alpha + child.set_opacity(eased) + + widget.queue_draw() + return GLib.SOURCE_CONTINUE + + def _draw_generation_canvas(self, widget, cr): + alloc = widget.get_allocation() + center_x = alloc.width / 2.0 + center_y = alloc.height / 2.0 + t = self._generation_anim_t + orbit_radius = style.zoom(78) + + # Everything eases in together over the first beat. + entrance = max(0.0, min(1.0, t / 0.7)) + entrance = entrance * entrance * (3.0 - 2.0 * entrance) + + if self._generation_anim_done and \ + self._generation_final_rgb is not None: + stroke_rgb, fill_rgb = self._generation_final_rgb + else: + stroke_rgb, fill_rgb = self._generation_wheel_rgb(t / 0.8) + + # A soft halo breathing behind the XO, like a slow heartbeat. + breath = 0.5 + 0.5 * math.sin(t * 2.0 * math.pi / 3.2) + if self._generation_anim_done: + breath = 0.75 + halo_radius = style.zoom(58) + style.zoom(10) * breath + halo = cairo.RadialGradient(center_x, center_y, style.zoom(20), + center_x, center_y, halo_radius) + halo_alpha = (0.10 + 0.10 * breath) * entrance + halo.add_color_stop_rgba(0.0, *fill_rgb, halo_alpha) + halo.add_color_stop_rgba(1.0, *fill_rgb, 0.0) + cr.set_source(halo) + cr.arc(center_x, center_y, halo_radius, 0, 2.0 * math.pi) + cr.fill() + + if self._generation_anim_done: + # Settled: a calm, complete ring in the learner's colors, + # marked once by a soft outward ripple. + cr.set_source_rgba(*stroke_rgb, 0.45) + cr.set_line_width(style.zoom(3)) + cr.arc(center_x, center_y, orbit_radius, 0, 2.0 * math.pi) + cr.stroke() + if self._generation_done_at is not None: + ripple = (t - self._generation_done_at) / 0.7 + if 0.0 <= ripple < 1.0: + cr.set_source_rgba(*stroke_rgb, 0.30 * (1.0 - ripple)) + cr.set_line_width(max(1, style.zoom(2))) + cr.arc(center_x, center_y, + orbit_radius + style.zoom(18) * ripple, + 0, 2.0 * math.pi) + cr.stroke() + return False + + # Faint guide ring the comet travels on. + cr.set_line_width(style.zoom(2)) + cr.set_source_rgba(*stroke_rgb, 0.10 * entrance) + cr.arc(center_x, center_y, orbit_radius, 0, 2.0 * math.pi) + cr.stroke() + + # Tiny seeds drifting the other way, barely there. + seed_angle = -t * 2.0 * math.pi / 14.0 + for i in range(3): + angle = seed_angle + i * 2.0 * math.pi / 3.0 + cr.set_source_rgba(*stroke_rgb, 0.16 * entrance) + cr.arc(center_x + orbit_radius * math.cos(angle), + center_y + orbit_radius * math.sin(angle), + max(2, style.zoom(2)), 0, 2.0 * math.pi) + cr.fill() + + mix = self._generation_fraction_mix + free_alpha = (1.0 - mix) * entrance + if free_alpha > 0.01: + # While progress is unknown the comet roams freely; a touch + # of sinusoidal drift keeps the motion organic. + head_angle = t * 2.0 * math.pi / 4.5 - math.pi / 2.0 + \ + 0.15 * math.sin(t * 0.7) + self._draw_generation_comet( + cr, center_x, center_y, orbit_radius, head_angle, + stroke_rgb, fill_rgb, free_alpha) + + if mix > 0.01 and self._generation_shown_fraction > 0.002: + # Real progress: the ring closes as the work completes, + # with the comet as the pen drawing it. + arc_alpha = mix * entrance + start = -math.pi / 2.0 + end = start + \ + 2.0 * math.pi * self._generation_shown_fraction + cr.set_source_rgba(*stroke_rgb, 0.40 * arc_alpha) + cr.set_line_width(style.zoom(3)) + cr.arc(center_x, center_y, orbit_radius, start, end) + cr.stroke() + self._draw_generation_comet_head( + cr, center_x, center_y, orbit_radius, end, + stroke_rgb, fill_rgb, arc_alpha) + return False + + def _draw_generation_comet(self, cr, center_x, center_y, radius, + head_angle, stroke_rgb, fill_rgb, alpha): + tail_span = math.radians(110) + segments = 22 + cr.set_line_width(style.zoom(3)) + for i in range(segments): + frac0 = i / float(segments) + frac1 = (i + 1) / float(segments) + cr.set_source_rgba( + *stroke_rgb, 0.5 * alpha * (1.0 - frac0) ** 1.7) + cr.arc(center_x, center_y, radius, + head_angle - tail_span * frac1, + head_angle - tail_span * frac0) + cr.stroke() + self._draw_generation_comet_head( + cr, center_x, center_y, radius, head_angle, + stroke_rgb, fill_rgb, alpha) + + def _draw_generation_comet_head(self, cr, center_x, center_y, radius, + angle, stroke_rgb, fill_rgb, alpha): + head_x = center_x + radius * math.cos(angle) + head_y = center_y + radius * math.sin(angle) + glow = cairo.RadialGradient(head_x, head_y, 0, + head_x, head_y, style.zoom(9)) + glow.add_color_stop_rgba(0.0, *stroke_rgb, 0.8 * alpha) + glow.add_color_stop_rgba(1.0, *stroke_rgb, 0.0) + cr.set_source(glow) + cr.arc(head_x, head_y, style.zoom(9), 0, 2.0 * math.pi) + cr.fill() + cr.set_source_rgba(*fill_rgb, 0.9 * alpha) + cr.arc(head_x, head_y, max(3, style.zoom(3)), 0, 2.0 * math.pi) + cr.fill() def _pulse_generation_progress(self): - if self._preview_generation_progress is not None: + self._generation_tick_count += 1 + if self._preview_generation_progress is not None and \ + not self._generation_has_fraction: self._preview_generation_progress.pulse() + + xo_icon = self._preview_generation_xo + if xo_icon is not None and not self._generation_anim_done and \ + self._generation_tick_count % 2 == 0: + # Drift through the XO color wheel one neighbor at a time, + # like Sugar's boot pulse — slow enough to feel calm. + stroke, fill = self._xo_pulse_color( + self._generation_tick_count // 2) + try: + xo_icon.props.stroke_color = stroke + xo_icon.props.fill_color = fill + except Exception: + pass + + fun = self._preview_generation_fun + if fun is not None and self._generation_tick_count % 9 == 0: + messages = self._generation_fun_messages() + text = messages[ + (self._generation_tick_count // 9) % len(messages)] + if self._preview_generation_canvas is not None: + self._generation_fun_next = text + else: + fun.set_text(text) return True def _update_generation_animation(self, stage, fraction, message): + if fraction > 0: + self._generation_has_fraction = True + self._generation_target_fraction = max( + 0.0, min(0.98, float(fraction))) if self._preview_generation_progress is not None: self._preview_generation_progress.show() if fraction > 0: - if self._generation_animation_id: - GLib.source_remove(self._generation_animation_id) - self._generation_animation_id = 0 self._preview_generation_progress.set_fraction( max(0.0, min(0.98, float(fraction)))) if self._preview_generation_stage is not None: @@ -4976,9 +5860,32 @@ def _complete_generation_animation(self, result=None): if self._preview_generation_progress is not None: self._preview_generation_progress.set_fraction(1.0) self._preview_generation_progress.show() + if self._preview_generation_xo is not None: + # Settle the pulsing XO on the learner's own colors. + try: + from sugar3 import profile + color = profile.get_color() + self._preview_generation_xo.props.stroke_color = \ + color.get_stroke_color() + self._preview_generation_xo.props.fill_color = \ + color.get_fill_color() + self._generation_final_rgb = ( + self._hex_to_rgb(color.get_stroke_color()), + self._hex_to_rgb(color.get_fill_color())) + except Exception: + pass + self._generation_anim_done = True + self._generation_target_fraction = 1.0 + self._generation_done_at = self._generation_anim_t if self._preview_generation_stage is not None: - self._preview_generation_stage.set_text(_('Activity is ready')) + self._preview_generation_stage.set_text( + _('Your activity is ready!')) self._preview_generation_stage.show() + if self._preview_generation_fun is not None: + self._generation_fun_next = None + self._generation_fun_alpha = 1.0 + self._preview_generation_fun.set_opacity(1.0) + self._preview_generation_fun.set_text(_('Have fun exploring!')) self._set_generation_step_active(4) if self._generation_animation_hide_id: GLib.source_remove(self._generation_animation_hide_id) @@ -4993,8 +5900,11 @@ def _show_generated_activity_preview(self, result): return False def _render_generated_activity_preview(self, result): - if self._render_live_generated_activity_preview(result): - return + try: + if self._render_live_generated_activity_preview(result): + return + except Exception: + logging.exception('Could not embed live activity preview') self._clear_activity_preview() @@ -5024,6 +5934,21 @@ def _render_generated_activity_preview(self, result): error_note.set_justify(Gtk.Justification.CENTER) error_box.pack_start(error_note, False, False, 0) + if self._last_preview_error: + error_detail = Gtk.Label(self._last_preview_error[:220]) + error_detail.get_style_context().add_class( + 'create-ai-generation-stage') + error_detail.set_line_wrap(True) + error_detail.set_max_width_chars(70) + error_detail.set_justify(Gtk.Justification.CENTER) + error_box.pack_start(error_detail, False, False, 0) + try: + self._append_chat_status( + _('Preview issue: %s') + % self._last_preview_error[:160]) + except Exception: + logging.exception('Could not post preview issue to chat') + self._preview_content_box.pack_start(error_box, True, True, 0) error_box.show_all() @@ -5038,15 +5963,18 @@ def _render_live_generated_activity_preview(self, result): project_path, getattr(result.spec, 'name', '') or _('Generated Activity'), ) - except Exception: + except Exception as error: logging.exception('Could not render live generated activity') + self._last_preview_error = str(error) return False if preview is None or not isinstance(canvas, Gtk.Widget): logging.error( 'Live preview failed for %s: %s', project_path, canvas) + self._last_preview_error = str(canvas) return False + self._last_preview_error = '' self._clear_activity_preview() @@ -5054,10 +5982,20 @@ def _render_live_generated_activity_preview(self, result): shell.get_style_context().add_class('create-ai-generated-preview') shell.set_hexpand(True) shell.set_vexpand(True) - shell.add_events(Gdk.EventMask.BUTTON_PRESS_MASK) - shell.connect('button-press-event', - self.__preview_target_button_press_event_cb, - _('activity canvas')) + shell.add_events(Gdk.EventMask.BUTTON_PRESS_MASK | + Gdk.EventMask.BUTTON_RELEASE_MASK | + Gdk.EventMask.POINTER_MOTION_MASK | + Gdk.EventMask.BUTTON1_MOTION_MASK) + shell.connect('button-press-event', self.__preview_shell_press_cb) + shell.connect('motion-notify-event', self.__preview_shell_motion_cb) + shell.connect('button-release-event', self.__preview_shell_release_cb) + shell.connect_after('draw', self.__preview_shell_draw_after_cb) + # With live edit on, the shell's input window sits above the + # activity so clicks and drags select targets instead of playing. + shell.set_above_child(self._live_edit_enabled) + self._preview_shell = shell + self._select_start = None + self._select_rect = None box = Gtk.VBox(spacing=style.zoom(6)) box.set_border_width(style.zoom(4)) @@ -6604,6 +7542,7 @@ def __option_card_clicked_cb(self, clicked_button, group_name, value): if group_name == 'template': self._update_template_hint() + self._update_template_card_icons() elif group_name in ('planner', 'policy'): self._update_planner_hint() elif group_name == 'license': @@ -6615,6 +7554,8 @@ def __option_card_clicked_cb(self, clicked_button, group_name, value): 'play': _('Play'), 'share': _('Share'), 'logic_math': _('Logic & math'), + 'science': _('Science'), + 'language': _('Language'), 'tools_utils': _('Tools/utilities'), 'games': _('Games'), 'creation': _('Creation'), @@ -6624,6 +7565,7 @@ def __option_card_clicked_cb(self, clicked_button, group_name, value): 'standard': _('Standard'), 'local': _('Local'), 'strict': _('Strict'), + 'on': _('On'), 'off': _('Off'), } if self._prompt_status_label is not None: @@ -6631,7 +7573,7 @@ def __option_card_clicked_cb(self, clicked_button, group_name, value): self._prompt_status_label.set_text( self._get_selected_license()['label']) else: - self._prompt_status_label.set_text(status[value]) + self._prompt_status_label.set_text(status.get(value, value)) def __code_size_combo_changed_cb(self, combo): self._selected_options['code_size'] = combo.get_active_id() or 'standard' @@ -6645,6 +7587,9 @@ def __provider_combo_changed_cb(self, combo): self._provider_key_entry.set_text('') self._provider_model_entry.set_text('') self._provider_endpoint_entry.set_text('') + if self._provider_chip_value_label is not None: + self._provider_chip_value_label.set_text( + self._get_provider_label(provider_name)) self._update_provider_controls() self._update_planner_hint() @@ -6703,16 +7648,6 @@ def __provider_remove_clicked_cb(self, button): _('%s API key removed.') % self._get_provider_label(provider_name)) - def __guided_button_toggled_cb(self, button): - if button.get_active(): - button.get_style_context().add_class('create-ai-pill-active') - if self._prompt_status_label is not None: - self._prompt_status_label.set_text(_('Guided')) - else: - button.get_style_context().remove_class('create-ai-pill-active') - if self._prompt_status_label is not None: - self._prompt_status_label.set_text(_('Thinking')) - def _select_studio_tab(self, tab_name): if self._studio_mode_stack is not None: self._studio_mode_stack.set_visible_child_name(tab_name) @@ -6884,9 +7819,27 @@ def _submit_refinement_from_prompt(self, refinement, source='chat'): backend_refinement = refinement display_refinement = refinement if source == 'preview': + if self._live_edit_target_is_region: + target_note = ( + 'The learner dragged a selection over the live ' + 'preview. The target below is that rectangle, in ' + 'percent of the activity canvas measured from its ' + 'top-left corner (x, y • width × height). ' + 'Work out which widgets or drawing fall inside that ' + 'region and apply the change to them.' + ) + else: + target_note = ( + 'The learner clicked this specific part of the live ' + 'preview. Apply the change to it.' + ) backend_refinement = ( - 'Target: %(target)s\nChange: %(change)s' + '%(note)s Keep the rest of the activity working ' + 'unchanged.\n' + 'Target: %(target)s\n' + 'Change: %(change)s' ) % { + 'note': target_note, 'target': target, 'change': refinement, } @@ -6894,8 +7847,8 @@ def _submit_refinement_from_prompt(self, refinement, source='chat'): 'target': target, 'change': refinement, } - if source == 'preview' and self._live_edit_status_label is not None: - self._live_edit_status_label.set_text(_('Refining preview...')) + if source == 'preview': + self._set_live_edit_status(_('Refining preview...')) if source == 'sidebar' and \ self._sidebar_refine_status_label is not None: self._sidebar_refine_status_label.set_text(_('Refining...')) @@ -6927,6 +7880,7 @@ def _build_refinement_spec(self, refinement): '- Summary: %(summary)s\n' '- Classroom flow:\n%(flow)s\n\n' 'Current plan JSON excerpt:\n%(plan_context)s\n\n' + 'Current activity.py excerpt:\n%(source)s\n\n' 'Refinement request:\n%(refinement)s' ) % { 'original': original_prompt, @@ -6935,6 +7889,7 @@ def _build_refinement_spec(self, refinement): 'summary': plan.get('summary', ''), 'flow': flow_text or '- Keep the activity usable for learners.', 'plan_context': plan_context, + 'source': self._source_context_for_refinement(result), 'refinement': refinement, } prompt = self._limit_refinement_prompt(prompt) @@ -6996,10 +7951,10 @@ def _compact_source_for_refinement(self, source): ) def _limit_refinement_prompt(self, prompt): - if len(prompt) <= 11500: + if len(prompt) <= 18000: return prompt - keep_head = prompt[:5600].rstrip() - keep_tail = prompt[-5600:].lstrip() + keep_head = prompt[:8800].rstrip() + keep_tail = prompt[-8800:].lstrip() return '%s\n\n[Previous context shortened]\n\n%s' % ( keep_head, keep_tail) @@ -7563,6 +8518,12 @@ def __preview_fullscreen_toggle_cb(self, button): if self._preview_fullscreen_button is not None: self._preview_fullscreen_button.set_label( _('⛶ Exit Fullscreen')) + if self._live_edit_panel is not None: + self._live_edit_panel.hide() + if self._ask_bar is not None: + self._ask_bar.show() + if self._ask_bar_entry is not None: + self._ask_bar_entry.grab_focus() else: if self._studio_left_panel is not None: self._studio_left_panel.show() @@ -7574,6 +8535,10 @@ def __preview_fullscreen_toggle_cb(self, button): if self._preview_fullscreen_button is not None: self._preview_fullscreen_button.set_label( _('⛶ Fullscreen')) + if self._ask_bar is not None: + self._ask_bar.hide() + if self._live_edit_panel is not None: + self._live_edit_panel.show() self._refresh_preview_layout() def __sidebar_toggle_cb(self, button): @@ -7621,12 +8586,91 @@ def _ensure_generation_bundle(self): _('XO packaged for export or install.')) return bundle_path + def _prompt_and_apply_license(self, action_label): + """Ask which license to bundle with, then apply it to the result. + + Returns True when the learner confirms and the license is applied, + False when they cancel or the update fails. + """ + if self._generation_result is None: + return False + + options = self._get_license_options() + current = self._selected_options.get('license', 'mit') + + dialog = Gtk.Dialog( + title=_('Choose a license'), + transient_for=self.get_toplevel(), + modal=True, + ) + dialog.add_button(_('Cancel'), Gtk.ResponseType.CANCEL) + dialog.add_button(action_label, Gtk.ResponseType.ACCEPT) + dialog.set_default_response(Gtk.ResponseType.ACCEPT) + + content = dialog.get_content_area() + content.set_border_width(style.zoom(12)) + content.set_spacing(style.zoom(6)) + + heading = Gtk.Label( + _('Pick the license to bundle with this activity.')) + heading.set_xalign(0) + content.pack_start(heading, False, False, 0) + heading.show() + + buttons = [] + group = None + for option in options: + radio = Gtk.RadioButton.new_with_label_from_widget( + group, '%s — %s' % (option['label'], option['description'])) + if group is None: + group = radio + if option['value'] == current: + radio.set_active(True) + content.pack_start(radio, False, False, 0) + radio.show() + buttons.append((option['value'], radio)) + + response = dialog.run() + selected = current + for value, radio in buttons: + if radio.get_active(): + selected = value + break + dialog.destroy() + + if response != Gtk.ResponseType.ACCEPT: + return False + + self._selected_options['license'] = selected + + from jarabe.model.aodpipeline import reapply_generation_license + + license_info = self._get_selected_license() + try: + reapply_generation_license( + self._generation_result, license_info['spdx']) + except Exception as error: + logging.exception('Could not apply the selected license') + if self._prompt_status_label is not None: + self._prompt_status_label.set_text(_('License update failed')) + self._append_chat_message( + _('License update failed: %s') % error) + return False + + self._refresh_generated_context() + self._append_chat_status( + _('License set to %s.') % license_info['label']) + return True + def __export_xo_cb(self, button): if self._generation_result is None: if self._prompt_status_label is not None: self._prompt_status_label.set_text(_('Generate first')) return + if not self._prompt_and_apply_license(_('Export')): + return + try: bundle_path = self._ensure_generation_bundle() except Exception as error: @@ -7668,12 +8712,125 @@ def __export_xo_cb(self, button): self._append_chat_message( _('XO bundle exported to %s') % destination) + def __export_flatpak_cb(self, button): + if self._generation_result is None: + if self._prompt_status_label is not None: + self._prompt_status_label.set_text(_('Generate first')) + return + + if self._flatpak_export_running: + if self._prompt_status_label is not None: + self._prompt_status_label.set_text(_('Packaging Flatpak...')) + return + + if not self._prompt_and_apply_license(_('Export')): + return + + # Packaging can invoke flatpak-builder, which may run for many + # minutes, so it must not block the Sugar shell's main loop. + self._flatpak_export_running = True + if self._prompt_status_label is not None: + self._prompt_status_label.set_text(_('Packaging Flatpak...')) + self._append_chat_status(_('Packaging Flatpak export...')) + + worker = threading.Thread( + target=self._flatpak_export_worker, + args=(self._generation_result,), + ) + worker.daemon = True + worker.start() + + def _flatpak_export_worker(self, result): + from jarabe.model.aodflatpak import package_flatpak + + try: + export = package_flatpak(result) + except Exception as error: + logging.exception('Could not package Flatpak export') + GObject.idle_add( + self._flatpak_export_finished_cb, None, str(error)) + return + GObject.idle_add(self._flatpak_export_finished_cb, export, None) + + def _flatpak_export_finished_cb(self, export, error): + # Hold the guard flag through the whole interaction, including the + # (non-modal) save dialog's nested loop, so a second click cannot + # start a concurrent export that collides on the staging paths. + try: + if error is not None or export is None: + if self._prompt_status_label is not None: + self._prompt_status_label.set_text(_('Packaging failed')) + self._append_chat_message( + _('Flatpak packaging failed: %s') + % (error or _('unknown'))) + return False + + artifact_path = export['path'] + if export['kind'] == 'flatpak': + self._append_chat_status( + _('Built installable Flatpak bundle.')) + title = _('Export Flatpak bundle') + elif export.get('builder_available'): + self._append_chat_status( + _('Flatpak build did not finish; exported buildable ' + 'Flatpak sources instead.')) + title = _('Export Flatpak sources') + else: + self._append_chat_status( + _('flatpak-builder not found; exported buildable Flatpak ' + 'sources instead.')) + title = _('Export Flatpak sources') + + dialog = Gtk.FileChooserDialog( + title=title, + parent=self.get_toplevel(), + action=Gtk.FileChooserAction.SAVE, + ) + dialog.add_buttons( + _('Cancel'), Gtk.ResponseType.CANCEL, + _('Export'), Gtk.ResponseType.ACCEPT, + ) + dialog.set_do_overwrite_confirmation(True) + dialog.set_current_name(os.path.basename(artifact_path)) + response = dialog.run() + destination = dialog.get_filename() + dialog.destroy() + + if response != Gtk.ResponseType.ACCEPT or not destination: + return False + + try: + shutil.copy2(artifact_path, destination) + except OSError as copy_error: + logging.exception('Could not export Flatpak artifact') + if self._prompt_status_label is not None: + self._prompt_status_label.set_text(_('Export failed')) + self._append_chat_message( + _('Export failed: %s') % copy_error) + return False + + if self._prompt_status_label is not None: + self._prompt_status_label.set_text(_('Exported')) + if export['kind'] == 'flatpak': + self._append_chat_message( + _('Flatpak bundle exported to %s') % destination) + else: + self._append_chat_message( + _('Flatpak sources exported to %s. Run flatpak-builder ' + 'to build the bundle.') % destination) + return False + finally: + self._flatpak_export_running = False + def __install_and_open_cb(self, button): if self._generation_result is None: if self._prompt_status_label is not None: self._prompt_status_label.set_text(_('Generate first')) return + if not self._prompt_and_apply_license(_('Install & Open')): + return + from sugar3.bundle.activitybundle import ActivityBundle from sugar3.bundle.helpers import bundle_from_archive @@ -7767,6 +8924,14 @@ def __sidebar_refine_send_clicked_cb(self, button): def __live_toggle_clicked_cb(self, button, enabled): self._live_edit_enabled = enabled + self._select_start = None + self._select_rect = None + if self._preview_shell is not None: + try: + self._preview_shell.set_above_child(enabled) + self._preview_shell.queue_draw() + except Exception: + pass if self._live_edit_on_button is not None: self._live_edit_on_button.get_style_context().remove_class( 'create-ai-live-toggle-active') @@ -7781,39 +8946,55 @@ def __live_toggle_clicked_cb(self, button, enabled): self._live_edit_off_button.get_style_context().add_class( 'create-ai-live-toggle-active') + for mode_button, mode_enabled in ( + (self._ask_bar_edit_on, True), + (self._ask_bar_edit_off, False)): + if mode_button is None: + continue + mode_context = mode_button.get_style_context() + if mode_enabled == enabled: + mode_context.add_class('create-ai-ask-mode-active') + else: + mode_context.remove_class('create-ai-ask-mode-active') + + if self._ask_bar_plus is not None: + self._ask_bar_plus.set_visible(enabled) + if self._ask_bar_target_label is not None: + self._ask_bar_target_label.set_visible(enabled) + if self._ask_bar_entry is not None: + self._ask_bar_entry.set_placeholder_text( + _('Describe a change for the selected part') + if enabled else _('Ask anything')) + if self._live_edit_entry is not None: self._live_edit_entry.set_sensitive(enabled) - if self._live_edit_status_label is not None: - if enabled: - self._live_edit_status_label.set_text( - _('Ready for preview edits')) - else: - self._live_edit_status_label.set_text( - _('Live edit is paused')) + if enabled: + self._set_live_edit_status( + _('Click or drag on the preview to pick a target.')) + else: + self._set_live_edit_status( + _('Play mode: clicks go to the activity.')) def __live_edit_add_clicked_cb(self, button): if self._live_edit_entry is None: return if not self._live_edit_enabled: - if self._live_edit_status_label is not None: - self._live_edit_status_label.set_text( - _('Turn Live Edit on before adding changes.')) + self._set_live_edit_status( + _('Turn Live Edit on before adding changes.')) return text = self._live_edit_entry.get_text().strip() if not text: self._live_edit_entry.grab_focus() - if self._live_edit_status_label is not None: - self._live_edit_status_label.set_text( - _('Describe the preview change first.')) + self._set_live_edit_status( + _('Describe the preview change first.')) return self._live_edit_entry.set_text('') if self._generation_result is None: - if self._live_edit_status_label is not None: - self._live_edit_status_label.set_text( - _('Generate an activity before preview refinements.')) + self._set_live_edit_status( + _('Generate an activity before preview refinements.')) self._append_chat_message( _('Generate an activity first, then describe refinements.')) return diff --git a/src/jarabe/model/Makefile.am b/src/jarabe/model/Makefile.am index 229eeef65f..57056fc1b8 100644 --- a/src/jarabe/model/Makefile.am +++ b/src/jarabe/model/Makefile.am @@ -5,6 +5,7 @@ sugar_PYTHON = \ __init__.py \ aodcodegen.py \ aodcredentials.py \ + aodflatpak.py \ aodgenerator.py \ aodjobs.py \ aodlicenses.py \ diff --git a/src/jarabe/model/aodcodegen.py b/src/jarabe/model/aodcodegen.py index 02fdf10d83..644326e376 100644 --- a/src/jarabe/model/aodcodegen.py +++ b/src/jarabe/model/aodcodegen.py @@ -126,6 +126,12 @@ def build_codegen_system_prompt(spec, plan, references=(), code_size='standard') '- Make the canvas/work area fill the activity window naturally with ' 'Gtk containers that expand. Avoid small centered toy panels unless ' 'the requested activity is intentionally compact.\n' + '- Boards, grids, and play areas must scale with the window: ' + 'compute cell/tile sizes from the allocated space (a size-allocate ' + 'callback or an expanding Gtk.DrawingArea that redraws from its ' + 'allocation), never hardcoded small pixel sizes. On a large screen ' + 'the play area should use most of the window, centered, with ' + 'square cells staying square.\n' '- If the structured request includes "Current activity.py excerpt", ' 'this is a refinement. Preserve working behavior from that source ' 'and apply the requested change directly in the regenerated source.\n' @@ -241,12 +247,21 @@ def extract_activity_source(value): files = value.get('files') if isinstance(files, dict): source = files.get('activity.py') or files.get('activity_py') or '' + if not source: + for path, content in files.items(): + if (isinstance(path, str) + and path.endswith('activity.py') + and isinstance(content, str)): + source = content + break elif isinstance(files, list): for item in files: if not isinstance(item, dict): continue path = item.get('path') or item.get('name') - if path in ('activity.py', './activity.py'): + if (isinstance(path, str) + and (path in ('activity.py', './activity.py') + or path.endswith('/activity.py'))): source = item.get('content') or item.get('source') or '' break if not source: @@ -322,24 +337,58 @@ def extract_activity_source_from_response(text): value = extract_json_object(text) except ValueError: value = text + if isinstance(value, dict): + try: + return extract_activity_source(value) + except ValueError: + # The "JSON" was an explanation object, or a dict literal + # inside the code misread as the response wrapper. Fall back + # to reading the raw text as fenced/plain code. + return extract_activity_source(text) return extract_activity_source(value) def _strip_code_fence(source): source = (source or '').strip() - if source.startswith('```'): - first_newline = source.find('\n') - last_fence = source.rfind('```') - if first_newline >= 0 and last_fence > first_newline: - source = source[first_newline + 1:last_fence].strip() - else: - fence_start = source.find('```') - if fence_start >= 0: - first_newline = source.find('\n', fence_start) - fence_end = source.find('```', first_newline + 1) - if first_newline >= 0 and fence_end > first_newline: - source = source[first_newline + 1:fence_end].strip() - return source + if '```' not in source: + return source + + blocks = _fenced_blocks(source) + if blocks: + for block in blocks: + if 'GeneratedActivity' in block: + return block + return max(blocks, key=len) + + # Unterminated fence (usually a truncated stream): keep everything + # after the opening fence line so the caller can report a clear + # truncation/syntax problem instead of "no activity.py". + fence_start = source.find('```') + first_newline = source.find('\n', fence_start) + if first_newline >= 0: + return source[first_newline + 1:].strip() + return '' + + +def _fenced_blocks(source): + """Return the contents of all complete ``` fenced blocks.""" + blocks = [] + index = 0 + while True: + start = source.find('```', index) + if start < 0: + break + first_newline = source.find('\n', start) + if first_newline < 0: + break + end = source.find('```', first_newline + 1) + if end < 0: + break + block = source[first_newline + 1:end].strip() + if block: + blocks.append(block) + index = end + 3 + return blocks def _format_references(references): diff --git a/src/jarabe/model/aodflatpak.py b/src/jarabe/model/aodflatpak.py new file mode 100644 index 0000000000..c0a8b6e6cd --- /dev/null +++ b/src/jarabe/model/aodflatpak.py @@ -0,0 +1,319 @@ +# Copyright (C) 2026 Sugar Labs +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Flatpak export for generated Sugar activities. + +The primary artifact is a self-contained ``.tar.gz`` that holds a Flatpak +manifest, the activity sources, a launcher, and a ``build.sh``. Anyone can +unpack it and run ``flatpak-builder`` to produce an installable Flatpak. + +When ``flatpak-builder`` is available on the machine, :func:`package_flatpak` +additionally tries to build a ready-to-install ``.flatpak`` bundle. That build +needs the ``org.gnome.Platform``/``org.gnome.Sdk`` runtimes and network access, +so it is best-effort: on failure the buildable source bundle is still returned. +""" + +import json +import logging +import os +import re +import shutil +import subprocess +import tarfile + +from jarabe.model.aodgenerator import _identifier + +# Flatpak application ids are reverse-DNS: at least two dot-separated +# elements, each starting with a letter or underscore and otherwise +# alphanumeric/underscore. Validating against this keeps a provider-supplied +# bundle_id from reaching a filename, the generated build.sh, or a +# flatpak-builder argument with path-traversal or shell metacharacters. +# \A and \Z (not ^/$) so a trailing newline cannot slip through validation. +_APP_ID_RE = re.compile( + r'\A[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)+\Z') + +_RUNTIME = 'org.gnome.Platform' +_SDK = 'org.gnome.Sdk' +_RUNTIME_VERSION = '46' +_LAUNCHER_NAME = 'sugar-activity-run' +_SUGAR_TOOLKIT_GIT = 'https://github.com/sugarlabs/sugar-toolkit-gtk3.git' + + +def _is_valid_app_id(app_id): + if not isinstance(app_id, str) or len(app_id) > 255: + return False + return _APP_ID_RE.match(app_id) is not None + + +def flatpak_app_id(plan): + """Return a validated Flatpak application id for a generated activity. + + ``plan['bundle_id']`` may carry a provider/LLM-supplied value (see + ``normalize_plan``), so it is validated against the Flatpak app-id grammar + before use. Anything that does not match falls back to a safe deterministic + id derived from the activity name, so a tainted bundle_id can never reach a + filename, the generated ``build.sh``, or a ``flatpak-builder`` argument. + """ + bundle_id = plan.get('bundle_id') + if _is_valid_app_id(bundle_id): + return bundle_id + return 'org.sugarlabs.aod.%s' % _identifier(plan.get('name', 'Activity')) + + +def _flatpak_stem(spec, plan): + return _identifier(getattr(spec, 'name', '') or plan.get('name', '')) + + +def render_flatpak_manifest(spec, plan, app_id): + """Build the Flatpak manifest dictionary for the activity.""" + class_name = plan.get('class_name', 'GeneratedActivity') + manifest = { + 'app-id': app_id, + 'runtime': _RUNTIME, + 'runtime-version': _RUNTIME_VERSION, + 'sdk': _SDK, + 'command': _LAUNCHER_NAME, + 'finish-args': [ + '--share=ipc', + '--socket=fallback-x11', + '--socket=wayland', + '--socket=pulseaudio', + '--device=dri', + '--share=network', + '--filesystem=home', + ], + 'modules': [ + { + 'name': 'sugar-toolkit-gtk3', + 'buildsystem': 'autotools', + 'config-opts': ['--disable-static'], + 'sources': [ + { + 'type': 'git', + 'url': _SUGAR_TOOLKIT_GIT, + 'branch': 'master', + }, + ], + }, + { + 'name': 'activity', + 'buildsystem': 'simple', + 'build-commands': [ + 'python3 setup.py install --prefix=/app', + ], + 'sources': [ + {'type': 'dir', 'path': 'activity-src'}, + ], + }, + { + 'name': 'launcher', + 'buildsystem': 'simple', + 'build-commands': [ + 'install -D -m755 %s /app/bin/%s' + % (_LAUNCHER_NAME, _LAUNCHER_NAME), + ], + 'sources': [ + {'type': 'file', 'path': _LAUNCHER_NAME}, + ], + }, + ], + 'x-sugar': { + 'activity-class': 'activity.%s' % class_name, + 'bundle-id': app_id, + }, + } + return manifest + + +def render_flatpak_launcher(plan): + """Return the wrapper that launches the activity outside the shell.""" + class_name = plan.get('class_name', 'GeneratedActivity') + return ( + '#!/bin/sh\n' + '# SPDX-License-Identifier: GPL-3.0-or-later\n' + '# Launch the bundled Sugar activity outside the Sugar shell.\n' + 'ACTIVITY_ROOT="/app/share/sugar/activities"\n' + 'BUNDLE_DIR="$(find "$ACTIVITY_ROOT" -maxdepth 1 -name \'*.activity\' ' + '-print -quit 2>/dev/null)"\n' + 'if [ -n "$BUNDLE_DIR" ]; then\n' + ' cd "$BUNDLE_DIR" || true\n' + 'fi\n' + 'exec sugar-activity3 activity.%s "$@"\n' + ) % class_name + + +def render_flatpak_build_script(stem, app_id): + """Return a build.sh that turns the manifest into a .flatpak.""" + return ( + '#!/bin/sh\n' + '# SPDX-License-Identifier: GPL-3.0-or-later\n' + 'set -e\n' + '# Build and bundle this activity as a Flatpak.\n' + '# Requires flatpak-builder and the %(runtime)s/%(sdk)s//%(ver)s ' + 'runtimes.\n' + 'APP_ID="%(app_id)s"\n' + 'MANIFEST="$APP_ID.json"\n' + 'flatpak-builder --force-clean --repo=repo build-dir "$MANIFEST"\n' + 'flatpak build-bundle repo "%(stem)s.flatpak" "$APP_ID"\n' + 'echo "Built %(stem)s.flatpak"\n' + 'echo "Install with: flatpak install --user %(stem)s.flatpak"\n' + ) % { + 'runtime': _RUNTIME, + 'sdk': _SDK, + 'ver': _RUNTIME_VERSION, + 'app_id': app_id, + 'stem': stem, + } + + +def render_flatpak_readme(spec, plan, app_id, stem): + """Return a README describing how to build the exported Flatpak.""" + name = getattr(spec, 'name', '') or plan.get('name', 'Activity') + return ( + '# %(name)s — Flatpak export\n\n' + 'This folder contains a Flatpak manifest (`%(app_id)s.json`), the ' + 'activity sources under `activity-src/`, a launcher, and a build ' + 'script.\n\n' + '## Build\n\n' + '```sh\n' + './build.sh\n' + '```\n\n' + 'or manually:\n\n' + '```sh\n' + 'flatpak-builder --force-clean --repo=repo build-dir %(app_id)s.json\n' + 'flatpak build-bundle repo %(stem)s.flatpak %(app_id)s\n' + 'flatpak install --user %(stem)s.flatpak\n' + '```\n\n' + '## Notes\n\n' + 'This manifest targets the `%(runtime)s//%(ver)s` runtime and builds ' + '`sugar-toolkit-gtk3` from source as a module. It is a starting ' + 'point: depending on the runtime, the toolkit build may need extra ' + 'dependencies, and the launcher path may need tuning for your ' + 'activity. Validate the build against a real Sugar runtime before ' + 'distributing.\n' + ) % { + 'name': name, + 'app_id': app_id, + 'stem': stem, + 'runtime': _RUNTIME, + 'ver': _RUNTIME_VERSION, + } + + +def assemble_flatpak_sources(result, staging_dir): + """Write the buildable Flatpak sources for ``result`` into ``staging_dir``. + + Returns the path to ``staging_dir``. + """ + spec = result.spec + plan = result.plan + app_id = flatpak_app_id(plan) + stem = _flatpak_stem(spec, plan) + + if os.path.isdir(staging_dir): + shutil.rmtree(staging_dir) + os.makedirs(staging_dir) + + manifest = render_flatpak_manifest(spec, plan, app_id) + manifest_path = os.path.join(staging_dir, '%s.json' % app_id) + with open(manifest_path, 'w', encoding='utf-8') as manifest_file: + json.dump(manifest, manifest_file, indent=4, sort_keys=True) + manifest_file.write('\n') + + launcher_path = os.path.join(staging_dir, _LAUNCHER_NAME) + with open(launcher_path, 'w', encoding='utf-8') as launcher_file: + launcher_file.write(render_flatpak_launcher(plan)) + os.chmod(launcher_path, 0o755) + + build_path = os.path.join(staging_dir, 'build.sh') + with open(build_path, 'w', encoding='utf-8') as build_file: + build_file.write(render_flatpak_build_script(stem, app_id)) + os.chmod(build_path, 0o755) + + readme_path = os.path.join(staging_dir, 'README.md') + with open(readme_path, 'w', encoding='utf-8') as readme_file: + readme_file.write(render_flatpak_readme(spec, plan, app_id, stem)) + + source_dir = os.path.join(staging_dir, 'activity-src') + os.makedirs(source_dir) + for relative_path, content in (result.files or {}).items(): + destination = os.path.join(source_dir, relative_path) + os.makedirs(os.path.dirname(destination), exist_ok=True) + with open(destination, 'w', encoding='utf-8') as source_file: + source_file.write(content) + + return staging_dir + + +def _flatpak_root(result): + return os.path.abspath(result.project_path.rstrip(os.sep) + '-flatpak') + + +def flatpak_builder_available(): + return shutil.which('flatpak-builder') is not None + + +def _build_flatpak_bundle(staging_dir, flatpak_root, app_id, stem): + """Best-effort ``flatpak-builder`` run; returns .flatpak path or None.""" + manifest = '%s.json' % app_id + repo_dir = os.path.join(flatpak_root, 'repo') + build_dir = os.path.join(flatpak_root, 'build-dir') + output_path = os.path.join(flatpak_root, '%s.flatpak' % stem) + try: + subprocess.run( + ['flatpak-builder', '--force-clean', '--repo', repo_dir, + build_dir, manifest], + cwd=staging_dir, check=True, capture_output=True, timeout=1800) + subprocess.run( + ['flatpak', 'build-bundle', repo_dir, output_path, app_id], + check=True, capture_output=True, timeout=300) + except (OSError, subprocess.SubprocessError) as error: + logging.warning('flatpak-builder build failed, exporting sources ' + 'only: %s', error) + return None + if os.path.isfile(output_path): + return output_path + return None + + +def package_flatpak(result): + """Package ``result`` as a Flatpak export. + + Always produces a buildable ``.tar.gz`` of the Flatpak sources. When + ``flatpak-builder`` is available it also tries to build an installable + ``.flatpak``. Returns a dict with ``kind`` (``'flatpak'`` or ``'source'``), + ``path`` (the artifact to hand to the user), ``source_path`` (the tarball), + and ``app_id``. + """ + plan = result.plan + app_id = flatpak_app_id(plan) + stem = _flatpak_stem(result.spec, plan) + + flatpak_root = _flatpak_root(result) + os.makedirs(flatpak_root, exist_ok=True) + staging_dir = os.path.join(flatpak_root, '%s-flatpak' % stem) + assemble_flatpak_sources(result, staging_dir) + + tarball_path = os.path.join(flatpak_root, '%s-flatpak.tar.gz' % stem) + if os.path.isfile(tarball_path): + os.remove(tarball_path) + with tarfile.open(tarball_path, 'w:gz') as tar: + tar.add(staging_dir, arcname='%s-flatpak' % stem) + + builder_available = flatpak_builder_available() + export = { + 'kind': 'source', + 'path': tarball_path, + 'source_path': tarball_path, + 'app_id': app_id, + 'builder_available': builder_available, + } + + if builder_available: + built = _build_flatpak_bundle(staging_dir, flatpak_root, app_id, stem) + if built: + export['kind'] = 'flatpak' + export['path'] = built + + return export diff --git a/src/jarabe/model/aodgenerator.py b/src/jarabe/model/aodgenerator.py index 41a9e8154f..2d028ab229 100644 --- a/src/jarabe/model/aodgenerator.py +++ b/src/jarabe/model/aodgenerator.py @@ -159,11 +159,13 @@ def infer_template(spec): defaults = { 'logic_math': 'quiz', + 'science': 'utility', + 'language': 'narrative', 'tools_utils': 'utility', 'games': 'grid', 'creation': 'narrative', } - return defaults[spec.category] + return defaults.get(spec.category, 'narrative') def build_plan(spec): @@ -629,6 +631,52 @@ def read_project_files(project_path): return result +_SPDX_RE = re.compile(r'^(\s*#\s*SPDX-License-Identifier:).*$', re.MULTILINE) + + +def _replace_spdx_identifier(source, license_id): + """Rewrite the first SPDX header line to the given license id.""" + def substitute(match): + return '%s %s' % (match.group(1), license_id) + + updated, count = _SPDX_RE.subn(substitute, source, count=1) + return updated if count else source + + +def apply_license_to_project(project_path, spec, plan): + """Rewrite the on-disk license artifacts to match ``spec.license_id``. + + Generation bakes a default license into the project. When the learner + chooses a license at install or export time we regenerate the LICENSE + file, the ``activity.info`` license field, and the ``activity.py`` SPDX + header so the packaged bundle carries the selected license. Returns the + refreshed project file mapping. + """ + license_info = get_license(spec.license_id) + + license_path = os.path.join(project_path, 'LICENSE') + with open(license_path, 'w', encoding='utf-8') as license_file: + license_file.write(license_info.get_text()) + + info_path = os.path.join(project_path, 'activity', 'activity.info') + with open(info_path, 'w', encoding='utf-8') as info_file: + info_file.write(_render_activity_info(spec, plan)) + + source_path = os.path.join(project_path, 'activity.py') + try: + with open(source_path, encoding='utf-8') as source_file: + source = source_file.read() + except OSError: + source = '' + if source: + updated = _replace_spdx_identifier(source, spec.license_id) + if updated != source: + with open(source_path, 'w', encoding='utf-8') as source_file: + source_file.write(updated) + + return read_project_files(project_path) + + def _render_activity_info(spec, plan): return ( '[Activity]\n' @@ -743,6 +791,14 @@ def _category_template_order(category): 'quiz', 'grid', 'carrom', 'utility', 'canvas', 'narrative', 'chess', ], + 'science': [ + 'utility', 'canvas', 'grid', 'quiz', 'narrative', 'carrom', + 'chess', + ], + 'language': [ + 'narrative', 'quiz', 'canvas', 'grid', 'utility', 'carrom', + 'chess', + ], 'tools_utils': [ 'utility', 'grid', 'quiz', 'carrom', 'canvas', 'narrative', 'chess', diff --git a/src/jarabe/model/aodllm.py b/src/jarabe/model/aodllm.py index b58bc24ee6..bc452da3f7 100644 --- a/src/jarabe/model/aodllm.py +++ b/src/jarabe/model/aodllm.py @@ -5,6 +5,7 @@ import json import logging import os +import time import urllib.error import urllib.parse import urllib.request @@ -52,6 +53,41 @@ def _env_int(name, default): _REASONING_SAFE_MINIMAL_EFFORT = 'minimal' +# Rate limits and server-side hiccups usually clear within seconds, so a +# short backoff turns "job failed" into "job took a moment longer". +_TRANSIENT_HTTP_CODES = (429, 500, 502, 503, 504, 529) +_TRANSIENT_RETRIES = _env_int('AOD_PROVIDER_TRANSIENT_RETRIES', 2) + + +def _urlopen_with_retry(request, timeout, label): + """urlopen with short exponential backoff on transient failures. + + Only errors that usually clear on their own are retried (rate + limits, 5xx responses, dropped connections and timeouts). Auth and + other client errors raise immediately so each caller's error + handling stays unchanged. + """ + attempt = 0 + while True: + attempt += 1 + try: + return urllib.request.urlopen(request, timeout=timeout) + except urllib.error.HTTPError as error: + if attempt > _TRANSIENT_RETRIES or \ + error.code not in _TRANSIENT_HTTP_CODES: + raise + logging.warning( + '%s request got HTTP %d; retrying (%d/%d)', + label, error.code, attempt, _TRANSIENT_RETRIES) + except OSError as error: + if attempt > _TRANSIENT_RETRIES: + raise + logging.warning( + '%s request failed (%s); retrying (%d/%d)', + label, error, attempt, _TRANSIENT_RETRIES) + time.sleep(min(2.0 ** (attempt - 1), 4.0)) + + class LLMProvider: name = 'provider' model = '' @@ -211,7 +247,8 @@ def _generate_content(self, system_prompt, user_prompt, timeout, method='POST', ) try: - with urllib.request.urlopen(request, timeout=timeout) as response: + with _urlopen_with_retry( + request, timeout, 'Gemini') as response: response_data = json.loads(response.read().decode('utf-8')) except urllib.error.HTTPError as error: detail = error.read().decode('utf-8', errors='replace')[:500] @@ -277,7 +314,8 @@ def _stream_content(self, system_prompt, user_prompt, timeout, ) accumulated = '' try: - with urllib.request.urlopen(request, timeout=timeout) as response: + with _urlopen_with_retry( + request, timeout, 'Gemini') as response: buf = '' while True: chunk = response.read(4096) @@ -423,8 +461,8 @@ def _stream_text(self, system_prompt, user_prompt, timeout, ) parts = [] try: - with urllib.request.urlopen( - request, timeout=timeout) as response: + with _urlopen_with_retry( + request, timeout, self.label) as response: for raw_line in response: line = raw_line.decode( 'utf-8', errors='replace').rstrip('\r\n') @@ -461,7 +499,15 @@ def _stream_text(self, system_prompt, user_prompt, timeout, raise ProviderError( '%s stream failed: %s' % (self._request_label(), error) ) - return ''.join(parts) + text = ''.join(parts) + if not text.strip(): + raise ProviderError( + '%s streamed an empty code response. Reasoning models can ' + 'spend the whole output budget before emitting code; try ' + 'again, use a smaller prompt, or switch models.' + % self._request_label() + ) + return text def _generate_text(self, system_prompt, user_prompt, timeout, max_tokens=None, json_response=True): @@ -1150,7 +1196,7 @@ def _post_json(url, payload, headers, timeout, label): method='POST', ) try: - with urllib.request.urlopen(request, timeout=timeout) as response: + with _urlopen_with_retry(request, timeout, label) as response: response_data = json.loads(response.read().decode('utf-8')) except urllib.error.HTTPError as error: detail = error.read().decode('utf-8', errors='replace')[:500] diff --git a/src/jarabe/model/aodpipeline.py b/src/jarabe/model/aodpipeline.py index d8942c9303..663d433096 100644 --- a/src/jarabe/model/aodpipeline.py +++ b/src/jarabe/model/aodpipeline.py @@ -10,6 +10,7 @@ from jarabe.model.aodcodegen import build_codegen_system_prompt from jarabe.model.aodcodegen import build_codegen_user_prompt +from jarabe.model.aodgenerator import apply_license_to_project from jarabe.model.aodgenerator import build_plan from jarabe.model.aodgenerator import create_prototype_activity from jarabe.model.aodgenerator import enrich_plan @@ -126,19 +127,36 @@ def generate_activity(spec, output_root=None, provider=None, user_prompt = build_user_prompt(spec) progress.report('provider', 0.43, 'Asking the configured model to plan from RAG context') - try: - provider_plan = selected_provider.generate_plan( - system_prompt, - user_prompt, - ) - plan = normalize_plan(spec, provider_plan) - provider_used = selected_provider.name - model_used = selected_provider.model - progress.report('provider', 0.52, - 'Checking the model plan') - except (ProviderError, ValueError) as error: + plan_error = None + for plan_attempt in (1, 2): + try: + provider_plan = selected_provider.generate_plan( + system_prompt, + user_prompt, + ) + plan = normalize_plan(spec, provider_plan) + provider_used = selected_provider.name + model_used = selected_provider.model + plan_error = None + progress.report('provider', 0.52, + 'Checking the model plan') + break + except ValueError as error: + # A malformed plan response is usually a one-off; one + # fresh attempt is cheap compared to failing the job. + plan_error = error + if plan_attempt == 1: + progress.report( + 'provider', 0.45, + 'Model plan was malformed; asking once more') + except ProviderError as error: + # Transient network failures were already retried at the + # HTTP layer, so what reaches here is not worth repeating. + plan_error = error + break + if plan_error is not None: provider_error = _redact_provider_error( - error, + plan_error, selected_provider, ) if not template_fallback: @@ -255,6 +273,23 @@ def package_generation_result(result): return result.bundle_path +def reapply_generation_license(result, license_id): + """Switch a generated activity to ``license_id`` before packaging. + + Rewrites the license artifacts on disk, refreshes the in-memory file + mapping, and invalidates any previously built bundle so the next + :func:`package_generation_result` call repackages with the new license. + """ + if result.spec.license_id == license_id and result.bundle_path: + return result + + result.spec.license_id = license_id + result.files = apply_license_to_project( + result.project_path, result.spec, result.plan) + result.bundle_path = '' + return result + + def _progress(callback, stage, fraction, message, metadata=None): if callback is not None: try: @@ -384,8 +419,21 @@ def _generate_activity_source_with_provider(provider, spec, plan, references, msg = str(err) if 'stream_callback' not in msg and 'keyword argument' not in msg: raise - # Provider doesn't accept stream_callback/max_output_tokens. - source = generate_source(system_prompt, retry_prompt) + # Provider doesn't accept every optional kwarg; drop + # max_output_tokens first but keep streaming if the + # provider supports it. + try: + source = generate_source( + system_prompt, + retry_prompt, + stream_callback=stream_callback, + ) + except TypeError as err2: + msg2 = str(err2) + if ('stream_callback' not in msg2 + and 'keyword argument' not in msg2): + raise + source = generate_source(system_prompt, retry_prompt) except ProviderError as error: return None, _redact_provider_error(error, provider), attempt except ValueError as error: diff --git a/src/jarabe/model/aodpreview.py b/src/jarabe/model/aodpreview.py index 80417ac9aa..f5ace7ad7e 100644 --- a/src/jarabe/model/aodpreview.py +++ b/src/jarabe/model/aodpreview.py @@ -138,10 +138,20 @@ def __init__(self, handle=None, bundle_path=''): def __getattr__(self, name): # Generated activities sometimes call self.some_method() or - # access self.some_widget before it is created in __init__. + # access attributes (including private ones like + # self._lesson_steps) before creating them in __init__. # Return a safe no-op proxy that absorbs all attribute access - # and calls instead of crashing with AttributeError. - if name.startswith('_'): + # and calls instead of crashing with AttributeError. Dunder + # lookups must still fail normally so Python protocol probes + # (copy, pickle, GObject machinery) behave correctly, and once + # construction is over private attributes raise normally again + # so lazy-init idioms in event handlers keep working: + # if not hasattr(self, '_count'): + # self._count = 0 + if name.startswith('__') and name.endswith('__'): + raise AttributeError(name) + if name.startswith('_') and self.__dict__.get( + '_preview_construction_done', False): raise AttributeError(name) return _NoOpProxy() @@ -290,6 +300,16 @@ def render_activity_preview(project_path, activity_name=''): return result logging.warning('Hardened preview also failed: %s', result[1]) + + # Last attempt: wrap every import so no exotic module the model + # hallucinated can take the whole preview down. + aggressive_source = _harden_imports(patched_source, aggressive=True) + result = _try_exec_preview(aggressive_source, source_path, + bundle_path, activity_name) + if result[0] is not None: + return result + + logging.warning('Aggressive preview also failed: %s', result[1]) return None, first_error, None @@ -323,31 +343,89 @@ def _try_exec_preview(patched_source, source_path, bundle_path, if activity_class is None: return None, 'GeneratedActivity class not found in source', None + # Construct via __new__ + explicit __init__ so that when the + # generated __init__ crashes we still hold the partially-built + # instance and can salvage whatever canvas it managed to set, + # without running side effects (timers, tempdirs) a second time. try: - instance = activity_class(handle=None) + instance = activity_class.__new__(activity_class) + if isinstance(instance, PreviewActivity): + PreviewActivity.__init__(instance) except Exception as error: - logging.exception('Preview __init__ failed') + logging.exception('Preview instance allocation failed') return None, 'Activity __init__ failed: %s' % error, None + try: + instance.__init__(handle=None) + except Exception as error: + logging.exception('Preview __init__ failed') + if not _has_salvageable_canvas(instance): + _dispose_preview_instance(instance) + return None, 'Activity __init__ failed: %s' % error, None + logging.warning( + 'Preview salvaged a partial canvas after __init__ failed: %s', + error) + if not isinstance(instance, PreviewActivity): return None, 'Activity did not inherit from PreviewActivity', None - canvas = instance.get_canvas() - toolbar = instance.get_toolbar_box() + instance.__dict__['_preview_construction_done'] = True + + try: + canvas = instance.get_canvas() + except Exception: + canvas = None + try: + toolbar = instance.get_toolbar_box() + except Exception: + toolbar = None + if isinstance(toolbar, _NoOpProxy): + toolbar = None - if canvas is None: + if canvas is None or isinstance(canvas, _NoOpProxy): return None, 'Activity did not call set_canvas()', None return instance, canvas, toolbar -def _harden_imports(source): +def _has_salvageable_canvas(instance): + """Return True when a crashed instance still holds a usable canvas. + + Many generated activities crash after set_canvas() while wiring + secondary features (timers, journal hooks, decorations). The + canvas that was already built is still perfectly previewable, so + keep it instead of losing the whole preview. + """ + try: + canvas = instance.get_canvas() + except Exception: + return False + return canvas is not None and not isinstance(canvas, _NoOpProxy) + + +def _dispose_preview_instance(instance): + """Best-effort teardown of a failed preview instance.""" + try: + instance.cleanup() + except Exception: + pass + try: + if isinstance(instance, Gtk.Widget): + instance.destroy() + except Exception: + pass + + +def _harden_imports(source, aggressive=False): """Wrap problematic imports so preview survives missing modules. LLM-generated code sometimes imports sugar3 sub-modules that exist at runtime but fail during in-process exec (e.g. sugar3.datastore, sugar3.presence). We wrap known-problematic import lines in try/except so the rest of the activity can still render. + + With aggressive=True every single-line import except the gi core is + wrapped, as a last resort before giving up on the preview. """ fragile_modules = ( 'sugar3.datastore', @@ -363,7 +441,24 @@ def _harden_imports(source): for line in lines: stripped = line.lstrip() if stripped.startswith(('import ', 'from ')): - if any(module in stripped for module in fragile_modules): + if aggressive: + is_core = ( + stripped == 'import gi' or + stripped.startswith(('import gi.', 'import gi ', + 'from gi import', 'from gi.', + 'from __future__'))) + balanced = stripped.count('(') == stripped.count(')') + # Only wrap top-level imports: indented ones are often + # already inside a try/except ImportError fallback, and + # wrapping those would swallow the ImportError the + # fallback depends on. + top_level = line == stripped + wrap = (not is_core and balanced and top_level and + not stripped.endswith('\\')) + else: + wrap = any(module in stripped + for module in fragile_modules) + if wrap: indent = line[:len(line) - len(stripped)] patched_lines.append('%stry:' % indent) patched_lines.append(' %s' % line) diff --git a/src/jarabe/model/aodprompts.py b/src/jarabe/model/aodprompts.py index 3320b22dfd..56a3f1458b 100644 --- a/src/jarabe/model/aodprompts.py +++ b/src/jarabe/model/aodprompts.py @@ -8,7 +8,8 @@ def build_system_prompt(spec, references=()): - learning_direction = _CATEGORY_BLOCKS[spec.category] + learning_direction = _CATEGORY_BLOCKS.get( + spec.category, _CATEGORY_BLOCKS['creation']) if spec.template != 'auto': learning_direction = '%s\n%s' % ( learning_direction, @@ -128,6 +129,15 @@ def _format_references(references): 'Favor patterns, reasoning, explanation, immediate feedback, and ' 'multiple ways to reach or describe an answer.' ), + 'science': ( + 'Favor observing, predicting, experimenting, measuring, and ' + 'comparing results, with simple simulations or data the learner ' + 'can change and re-run.' + ), + 'language': ( + 'Favor reading, writing, vocabulary, storytelling, and word play, ' + 'with feedback that celebrates expression and revision.' + ), 'tools_utils': ( 'Favor a focused tool that helps learners measure, organize, count, ' 'compare, or investigate something meaningful.' diff --git a/src/jarabe/model/aodspec.py b/src/jarabe/model/aodspec.py index 6c81116c66..f9e923c7eb 100644 --- a/src/jarabe/model/aodspec.py +++ b/src/jarabe/model/aodspec.py @@ -14,11 +14,15 @@ CATEGORIES = ( 'logic_math', + 'science', + 'language', 'tools_utils', 'games', 'creation', ) +CATEGORY_FALLBACK = 'creation' + CODE_SIZES = ('compact', 'standard', 'full') MAX_PROMPT_LENGTH = 12000 @@ -92,16 +96,31 @@ def validate(self): return errors def normalized(self): - """Return a copy with user-entered whitespace normalized.""" + """Return a copy with whitespace normalized and soft steering + fields coerced to safe values. + + Category, template, and code size only steer the plan, so an + unrecognized value (from UI drift or an older saved session) + falls back to a sensible default instead of failing the whole + generation. + """ + name = _normalize_spaces(self.name) + if isinstance(name, str): + name = name[:80] + prompt = self.prompt.strip() if isinstance(self.prompt, str) \ + else self.prompt return ActivitySpec( - name=_normalize_spaces(self.name), - prompt=self.prompt.strip(), - category=self.category, + name=name, + prompt=prompt, + category=self.category if self.category in CATEGORIES + else CATEGORY_FALLBACK, license_id=self.license_id, - template=self.template, - age_band=_normalize_spaces(self.age_band), + template=self.template if self.template in TEMPLATES + else 'auto', + age_band=_normalize_spaces(self.age_band) or 'all', learner_goal=_normalize_spaces(self.learner_goal), - code_size=self.code_size, + code_size=self.code_size if self.code_size in CODE_SIZES + else 'standard', ) def to_dict(self): diff --git a/src/jarabe/model/aodvalidator.py b/src/jarabe/model/aodvalidator.py index 96f43d306f..a4d4e1023a 100644 --- a/src/jarabe/model/aodvalidator.py +++ b/src/jarabe/model/aodvalidator.py @@ -168,7 +168,12 @@ def validate_activity_source_for_request(source, spec, plan=None): prompt_words = _tokens(prompt) source_lower = source.lower() - if len(source) < 1200: + min_source_size = 1200 + if getattr(spec, 'code_size', 'standard') == 'compact': + # Compact activities are intentionally small; only reject sizes + # that cannot possibly hold a working Sugar activity. + min_source_size = 800 + if len(source) < min_source_size: report.errors.append( 'Generated activity is too small to be a full learner activity.' ) diff --git a/tests/test_aodflatpak.py b/tests/test_aodflatpak.py new file mode 100644 index 0000000000..2834e6f27f --- /dev/null +++ b/tests/test_aodflatpak.py @@ -0,0 +1,135 @@ +# Copyright (C) 2026 Sugar Labs +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import json +import os +import shutil +import tarfile +import tempfile +import unittest + +from jarabe.model.aodflatpak import flatpak_app_id +from jarabe.model.aodflatpak import package_flatpak +from jarabe.model.aodflatpak import render_flatpak_manifest +from jarabe.model.aodgenerator import create_prototype_activity +from jarabe.model.aodspec import ActivitySpec + + +class TestAodFlatpak(unittest.TestCase): + + def setUp(self): + self.output_root = tempfile.mkdtemp(prefix='aod-flatpak-test-') + + def tearDown(self): + shutil.rmtree(self.output_root) + + def _make_result(self): + spec = ActivitySpec( + name='Flatpak Demo', + prompt='Create a writing activity.', + category='creation', + license_id='MIT', + template='narrative', + ) + return create_prototype_activity(spec, self.output_root) + + def test_app_id_matches_bundle_id(self): + result = self._make_result() + self.assertEqual(result.plan['bundle_id'], + flatpak_app_id(result.plan)) + + def test_manifest_targets_gnome_runtime(self): + result = self._make_result() + app_id = flatpak_app_id(result.plan) + manifest = render_flatpak_manifest(result.spec, result.plan, app_id) + + self.assertEqual(app_id, manifest['app-id']) + self.assertEqual('org.gnome.Platform', manifest['runtime']) + self.assertEqual('org.gnome.Sdk', manifest['sdk']) + self.assertEqual('sugar-activity-run', manifest['command']) + module_names = [module['name'] for module in manifest['modules']] + self.assertIn('sugar-toolkit-gtk3', module_names) + self.assertIn('activity', module_names) + + def test_package_flatpak_exports_buildable_sources(self): + result = self._make_result() + export = package_flatpak(result) + + # flatpak-builder is not assumed present in CI, so the fallback + # source bundle is what we can deterministically verify. + self.assertIn(export['kind'], ('source', 'flatpak')) + self.assertTrue(os.path.isfile(export['source_path'])) + self.assertTrue(export['source_path'].endswith('.tar.gz')) + self.assertEqual(result.plan['bundle_id'], export['app_id']) + + app_id = export['app_id'] + with tarfile.open(export['source_path'], 'r:gz') as tar: + names = tar.getnames() + manifest_member = None + for name in names: + if name.endswith('%s.json' % app_id): + manifest_member = name + break + self.assertIsNotNone(manifest_member) + manifest = json.loads( + tar.extractfile(manifest_member).read().decode('utf-8')) + + self.assertEqual(app_id, manifest['app-id']) + + base = os.path.basename(manifest_member).rsplit('/', 1)[-1] + self.assertTrue(base.endswith('.json')) + joined = '\n'.join(names) + self.assertIn('build.sh', joined) + self.assertIn('README.md', joined) + self.assertIn('sugar-activity-run', joined) + self.assertIn('activity-src/activity.py', joined) + + def test_malicious_bundle_id_falls_back_to_safe_id(self): + # A provider/LLM-supplied bundle_id must never reach a filename or + # shell verbatim; invalid ids fall back to a safe deterministic id. + result = self._make_result() + for evil in ('../../evil', 'org.x"; rm -rf $HOME; echo "', '../pwn', + 'no-dots-here', '9.starts.with.digit', '', 'a/b/c', + 'org.looks.valid\n', 'org.looks.valid\nrm -rf /'): + result.plan['bundle_id'] = evil + app_id = flatpak_app_id(result.plan) + self.assertTrue(app_id.startswith('org.sugarlabs.aod.')) + self.assertRegex(app_id, r'^[A-Za-z0-9_.]+$') + + def test_valid_bundle_id_is_preserved(self): + result = self._make_result() + result.plan['bundle_id'] = 'org.sugarlabs.aod.MyThing1234' + self.assertEqual('org.sugarlabs.aod.MyThing1234', + flatpak_app_id(result.plan)) + + def test_malicious_bundle_id_does_not_escape_staging(self): + result = self._make_result() + result.plan['bundle_id'] = '../../../pwned' + export = package_flatpak(result) + + flatpak_root = os.path.abspath( + result.project_path.rstrip(os.sep) + '-flatpak') + with tarfile.open(export['source_path'], 'r:gz') as tar: + for name in tar.getnames(): + self.assertNotIn('..', name.split('/')) + # No manifest written outside the intended flatpak staging area. + self.assertFalse(os.path.exists( + os.path.join(os.path.dirname(flatpak_root), 'pwned.json'))) + + def test_export_reports_builder_availability(self): + result = self._make_result() + export = package_flatpak(result) + self.assertIn('builder_available', export) + self.assertIsInstance(export['builder_available'], bool) + + def test_package_flatpak_does_not_pollute_xo_project(self): + result = self._make_result() + package_flatpak(result) + + # Flatpak artifacts must live in a sibling directory so they never + # end up inside the .xo bundle built from the project directory. + for root, _dirs, filenames in os.walk(result.project_path): + for filename in filenames: + self.assertFalse(filename.endswith('.tar.gz')) + self.assertNotEqual(filename, 'build.sh') diff --git a/tests/test_aodgenerator.py b/tests/test_aodgenerator.py index 5222e6b62a..5a03a9633f 100644 --- a/tests/test_aodgenerator.py +++ b/tests/test_aodgenerator.py @@ -169,3 +169,43 @@ def test_license_metadata_is_consistent(self): '# SPDX-License-Identifier: BSD-3-Clause', result.files['activity.py'], ) + + def test_reapply_license_rewrites_bundle_artifacts(self): + from jarabe.model.aodpipeline import reapply_generation_license + + spec = ActivitySpec( + 'License Switch', + 'Create a writing activity.', + 'creation', + 'MIT', + template='narrative', + ) + result = create_prototype_activity(spec, self.output_root) + self.assertIn('MIT License', result.files['LICENSE']) + self.assertIn( + '# SPDX-License-Identifier: MIT', + result.files['activity.py'], + ) + self.assertTrue(os.path.isfile(result.bundle_path)) + + reapply_generation_license(result, 'BSD-3-Clause') + + self.assertEqual('BSD-3-Clause', result.spec.license_id) + self.assertEqual('', result.bundle_path) + self.assertIn('BSD 3-Clause License', result.files['LICENSE']) + self.assertIn( + 'license = BSD-3-Clause', + result.files['activity/activity.info'], + ) + self.assertIn( + '# SPDX-License-Identifier: BSD-3-Clause', + result.files['activity.py'], + ) + self.assertNotIn( + '# SPDX-License-Identifier: MIT', + result.files['activity.py'], + ) + with open(os.path.join(result.project_path, 'LICENSE'), + encoding='utf-8') as license_file: + self.assertIn('BSD 3-Clause License', license_file.read()) + self.assertTrue(validate_project(result.project_path).valid) diff --git a/tests/test_aodllm.py b/tests/test_aodllm.py index 6198145aeb..22d6c09276 100644 --- a/tests/test_aodllm.py +++ b/tests/test_aodllm.py @@ -23,6 +23,59 @@ class TestAodLLMProviders(unittest.TestCase): + def test_transient_http_error_is_retried(self): + import io + import urllib.error + + from jarabe.model import aodllm + + response = mock.Mock() + rate_limited = urllib.error.HTTPError( + 'https://api.example', 429, 'Too Many Requests', {}, + io.BytesIO(b'')) + + with mock.patch('urllib.request.urlopen', + side_effect=[rate_limited, response]) as opener: + with mock.patch('time.sleep') as sleeper: + result = aodllm._urlopen_with_retry( + mock.Mock(), 30, 'Test') + + self.assertIs(response, result) + self.assertEqual(2, opener.call_count) + sleeper.assert_called_once() + + def test_network_error_is_retried(self): + from jarabe.model import aodllm + + response = mock.Mock() + with mock.patch('urllib.request.urlopen', + side_effect=[OSError('reset'), response]) as opener: + with mock.patch('time.sleep'): + result = aodllm._urlopen_with_retry( + mock.Mock(), 30, 'Test') + + self.assertIs(response, result) + self.assertEqual(2, opener.call_count) + + def test_auth_error_is_not_retried(self): + import io + import urllib.error + + from jarabe.model import aodllm + + unauthorized = urllib.error.HTTPError( + 'https://api.example', 401, 'Unauthorized', {}, + io.BytesIO(b'')) + + with mock.patch('urllib.request.urlopen', + side_effect=unauthorized) as opener: + with mock.patch('time.sleep') as sleeper: + with self.assertRaises(urllib.error.HTTPError): + aodllm._urlopen_with_retry(mock.Mock(), 30, 'Test') + + self.assertEqual(1, opener.call_count) + sleeper.assert_not_called() + def test_local_template_provider_returns_none(self): with mock.patch.dict(os.environ, {}, clear=True): self.assertEqual('local-template', get_default_provider_name()) diff --git a/tests/test_aodprompts.py b/tests/test_aodprompts.py index 9820470139..d0382dacc9 100644 --- a/tests/test_aodprompts.py +++ b/tests/test_aodprompts.py @@ -28,6 +28,19 @@ def test_system_prompt_contains_sugar_constraints(self): self.assertNotIn('local generator owns Python source', prompt) self.assertIn('large editable area', prompt) + def test_system_prompt_supports_science_and_language(self): + for category, expected in ( + ('science', 'experimenting'), + ('language', 'storytelling')): + spec = ActivitySpec( + 'Explore', 'Measure plant growth.', category, 'MIT') + self.assertIn(expected, build_system_prompt(spec)) + + def test_system_prompt_falls_back_on_unknown_category(self): + spec = ActivitySpec( + 'Explore', 'Measure plant growth.', 'mystery', 'MIT') + self.assertIn('learner-owned', build_system_prompt(spec)) + def test_extracts_fenced_json(self): value = extract_json_object( '```json\n{"template": "quiz", "summary": "Test"}\n```' diff --git a/tests/test_aodspec.py b/tests/test_aodspec.py index aae70af891..dee95d79be 100644 --- a/tests/test_aodspec.py +++ b/tests/test_aodspec.py @@ -42,6 +42,33 @@ def test_dictionary_round_trip(self): ActivitySpec.from_dict(original.to_dict()), ) + def test_science_and_language_categories_are_valid(self): + for category in ('science', 'language'): + spec = ActivitySpec( + name='Explore Plants', + prompt='Measure plant growth over time.', + category=category, + license_id='MIT', + ) + self.assertEqual([], spec.validate()) + + def test_normalized_coerces_unknown_soft_fields(self): + spec = ActivitySpec( + name='X' * 120, + prompt=' Build something fun. ', + category='not-a-category', + license_id='MIT', + template='not-a-template', + age_band=' ', + code_size='huge', + ).normalized() + self.assertEqual('creation', spec.category) + self.assertEqual('auto', spec.template) + self.assertEqual('standard', spec.code_size) + self.assertEqual('all', spec.age_band) + self.assertEqual(80, len(spec.name)) + self.assertEqual([], spec.validate()) + def test_name_from_prompt_ignores_instruction_words(self): self.assertEqual( 'Fractions Quiz Children', From 5d3842acb436f4d7283c463c41d2a19782f8d343 Mon Sep 17 00:00:00 2001 From: Ashutoshx7 Date: Sun, 5 Jul 2026 04:07:18 +0530 Subject: [PATCH 23/28] Reject imports of runtime-missing optional modules pygame/sugargame are now validated against the actual runtime: a missing one fails validation with a rewrite-with-cairo instruction fed back through the codegen retry loop, and the codegen system prompt stops offering pygame on machines without it. --- src/jarabe/model/aodcodegen.py | 60 +++++++++++++++++++++++--------- src/jarabe/model/aodvalidator.py | 27 ++++++++++++++ tests/test_aodvalidator.py | 40 +++++++++++++++++++++ 3 files changed, 110 insertions(+), 17 deletions(-) diff --git a/src/jarabe/model/aodcodegen.py b/src/jarabe/model/aodcodegen.py index 644326e376..38a79f72fc 100644 --- a/src/jarabe/model/aodcodegen.py +++ b/src/jarabe/model/aodcodegen.py @@ -9,6 +9,7 @@ from jarabe.model.aodvalidator import ALLOWED_IMPORT_ROOTS from jarabe.model.aodvalidator import FORBIDDEN_CALLS from jarabe.model.aodvalidator import FORBIDDEN_IMPORT_ROOTS +from jarabe.model.aodvalidator import _module_available _CODE_SIZE_INSTRUCTIONS = { @@ -33,6 +34,47 @@ } +def _rendering_guidance(): + """Backend guidance for the system prompt, matched to this system. + + pygame/sugargame are only offered when the runtime actually has + them; otherwise the model is steered to cairo so generated code can + always preview and launch on this machine. + """ + if _module_available('pygame') and _module_available('sugargame'): + return ( + '## Rendering approach — cairo vs pygame/sugargame\n' + 'Choose the right rendering backend for the request:\n' + '- **GTK3 + cairo (default)**: Use for most activities — ' + 'board games, drawing apps, quizzes, writing tools, ' + 'simulations. Gtk.DrawingArea with a cairo draw callback is ' + 'idiomatic Sugar and integrates cleanly with GTK events. ' + 'Prefer this unless the request is clearly game-loop ' + 'driven.\n' + '- **pygame via sugargame**: Use ONLY when the request ' + 'explicitly asks for a pygame game or when the activity ' + 'needs a continuous game loop (e.g. real-time arcade, ' + 'physics simulation, animation-heavy game). When using ' + 'pygame, wrap the activity with sugargame.canvas.Canvas and ' + 'run pygame.display.set_mode inside the Sugar handle. ' + 'Import sugargame.canvas and pygame. Still inherit from ' + 'sugar3.activity.activity.Activity. Still use the Sugar ' + 'toolbar and Journal persistence.\n' + 'When in doubt, use cairo + GTK3. Only choose sugargame ' + 'when the learner prompt clearly describes gameplay that ' + 'needs a frame loop.\n\n' + ) + return ( + '## Rendering approach — GTK3 + cairo only\n' + 'The pygame/sugargame libraries are NOT installed on this ' + 'system, so never import them. Build every activity — ' + 'including arcade-style and real-time games — with GTK3 + ' + 'cairo: draw in a Gtk.DrawingArea draw callback, drive the ' + 'frame loop with GLib.timeout_add (about 33 ms per frame), ' + 'and handle controls with GTK key-press-event handlers.\n\n' + ) + + def build_codegen_system_prompt(spec, plan, references=(), code_size='standard'): """Build the provider prompt for a complete Sugar activity.py file.""" return ( @@ -87,23 +129,7 @@ def build_codegen_system_prompt(spec, plan, references=(), code_size='standard') '8. Implement read_file(self, file_path) and ' 'write_file(self, file_path)\n' ' for Journal persistence using json.\n\n' - '## Rendering approach — cairo vs pygame/sugargame\n' - 'Choose the right rendering backend for the request:\n' - '- **GTK3 + cairo (default)**: Use for most activities — ' - 'board games, drawing apps, quizzes, writing tools, simulations. ' - 'Gtk.DrawingArea with a cairo draw callback is idiomatic Sugar and ' - 'integrates cleanly with GTK events. Prefer this unless the request ' - 'is clearly game-loop driven.\n' - '- **pygame via sugargame**: Use ONLY when the request explicitly ' - 'asks for a pygame game or when the activity needs a continuous ' - 'game loop (e.g. real-time arcade, physics simulation, animation-' - 'heavy game). When using pygame, wrap the activity with ' - 'sugargame.canvas.Canvas and run pygame.display.set_mode inside ' - 'the Sugar handle. Import sugargame.canvas and pygame. Still ' - 'inherit from sugar3.activity.activity.Activity. Still use the ' - 'Sugar toolbar and Journal persistence.\n' - 'When in doubt, use cairo + GTK3. Only choose sugargame when the ' - 'learner prompt clearly describes gameplay that needs a frame loop.\n\n' + + _rendering_guidance() + 'Hard requirements:\n' '- Build the specific activity described by activity_kind, ' 'interaction_model, ui_regions, learner_steps, and the learner ' diff --git a/src/jarabe/model/aodvalidator.py b/src/jarabe/model/aodvalidator.py index a4d4e1023a..7fa64ec429 100644 --- a/src/jarabe/model/aodvalidator.py +++ b/src/jarabe/model/aodvalidator.py @@ -4,6 +4,7 @@ import ast import configparser +import importlib.util from dataclasses import dataclass from dataclasses import field import os @@ -30,6 +31,25 @@ 'sugargame', } +# Allowed only when the runtime actually provides them. pygame and +# sugargame are common in real Sugar games but are not installed +# everywhere; code importing a missing one would pass static checks and +# then crash in the preview and on launch. +OPTIONAL_RUNTIME_ROOTS = ('pygame', 'sugargame') + +_module_availability = {} + + +def _module_available(root): + if root not in _module_availability: + try: + _module_availability[root] = \ + importlib.util.find_spec(root) is not None + except (ImportError, ValueError): + _module_availability[root] = False + return _module_availability[root] + + FORBIDDEN_IMPORT_ROOTS = { 'ctypes', 'http', @@ -90,6 +110,13 @@ def validate_source(source): report.errors.append('Forbidden import: %s' % name) elif name not in ALLOWED_IMPORT_ROOTS: report.errors.append('Import is not allowlisted: %s' % name) + elif name in OPTIONAL_RUNTIME_ROOTS and not _module_available(name): + report.errors.append( + "The '%s' library is not installed on this system; " + 'rewrite the activity with GTK3 + cairo instead — use a ' + 'Gtk.DrawingArea draw callback with GLib.timeout_add for ' + 'the frame loop and GTK key-press-event handlers for ' + 'controls.' % name) for node in ast.walk(tree): if isinstance(node, ast.Call): diff --git a/tests/test_aodvalidator.py b/tests/test_aodvalidator.py index 507daea6f1..7e9c4644a8 100644 --- a/tests/test_aodvalidator.py +++ b/tests/test_aodvalidator.py @@ -3,6 +3,7 @@ # SPDX-License-Identifier: GPL-3.0-or-later import unittest +from unittest import mock from jarabe.model.aodspec import ActivitySpec from jarabe.model.aodtemplates import render_activity_source @@ -27,6 +28,45 @@ def test_rejects_dangerous_imports_and_calls(self): self.assertIn('Forbidden import: subprocess', report.errors) self.assertIn('Forbidden call: eval', report.errors) + def test_rejects_optional_modules_missing_at_runtime(self): + with mock.patch( + 'jarabe.model.aodvalidator._module_available', + return_value=False): + report = validate_source('import sugargame\n') + self.assertTrue(any( + "'sugargame' library is not installed" in error + for error in report.errors), report.errors) + + def test_accepts_optional_modules_present_at_runtime(self): + with mock.patch( + 'jarabe.model.aodvalidator._module_available', + return_value=True): + report = validate_source('import sugargame\nimport pygame\n') + self.assertFalse(any( + 'is not installed' in error for error in report.errors), + report.errors) + + def test_codegen_prompt_bans_pygame_when_unavailable(self): + from jarabe.model import aodcodegen + + spec = ActivitySpec( + 'Racer', 'A space racer 2d game.', 'games', 'MIT') + plan = enrich_plan(spec, { + 'name': 'Racer', 'template': 'grid', + 'bundle_id': 'org.sugarlabs.aod.Racer1234567890', + 'class_name': 'GeneratedActivity', + }) + with mock.patch.object( + aodcodegen, '_module_available', return_value=False): + prompt = aodcodegen.build_codegen_system_prompt(spec, plan) + self.assertIn('NOT installed on this system', prompt) + self.assertNotIn('pygame via sugargame', prompt) + + with mock.patch.object( + aodcodegen, '_module_available', return_value=True): + prompt = aodcodegen.build_codegen_system_prompt(spec, plan) + self.assertIn('pygame via sugargame', prompt) + def test_requires_activity_structure(self): report = validate_source('class PlainObject:\n pass\n') self.assertFalse(report.valid) From cc14c6a0a0880e46790d6e60ff883cd773ed8617 Mon Sep 17 00:00:00 2001 From: Ashutoshx7 Date: Sun, 5 Jul 2026 04:37:57 +0530 Subject: [PATCH 24/28] Insert rendering guidance via the prompt's substitution dict --- src/jarabe/model/aodcodegen.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/jarabe/model/aodcodegen.py b/src/jarabe/model/aodcodegen.py index 38a79f72fc..b4be25159b 100644 --- a/src/jarabe/model/aodcodegen.py +++ b/src/jarabe/model/aodcodegen.py @@ -129,7 +129,7 @@ def build_codegen_system_prompt(spec, plan, references=(), code_size='standard') '8. Implement read_file(self, file_path) and ' 'write_file(self, file_path)\n' ' for Journal persistence using json.\n\n' - + _rendering_guidance() + + '%(rendering_guidance)s' 'Hard requirements:\n' '- Build the specific activity described by activity_kind, ' 'interaction_model, ui_regions, learner_steps, and the learner ' @@ -230,6 +230,7 @@ def build_codegen_system_prompt(spec, plan, references=(), code_size='standard') 'Sugar Activity API reference:\n%(api_reference)s\n\n' 'Retrieved Sugar references:\n%(references)s' ) % { + 'rendering_guidance': _rendering_guidance(), 'allowed': ', '.join(sorted(ALLOWED_IMPORT_ROOTS)), 'forbidden_imports': ', '.join(sorted(FORBIDDEN_IMPORT_ROOTS)), 'forbidden_calls': ', '.join(sorted(FORBIDDEN_CALLS)), From 3c5863b0f94dc3d48d0300840627206e4a2646ee Mon Sep 17 00:00:00 2001 From: Ashutoshx7 Date: Sun, 5 Jul 2026 13:09:34 +0530 Subject: [PATCH 25/28] Add prompt enhancement: expand short ideas into activity briefs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new aodenhance module turns a learner's few words into a detailed brief. The pipeline auto-enhances short prompts before RAG/planning (fail-soft, per-job toggle), the create view gains a ✨ Enhance button and an Enhance Auto/Off chip, and the chat shows the brief the AI understood so learners see what a strong prompt looks like. Original learner words are kept for sessions, versions, and refinements. --- src/jarabe/desktop/homebox.py | 134 ++++++++++++++++++++++++++++++- src/jarabe/model/aodenhance.py | 113 ++++++++++++++++++++++++++ src/jarabe/model/aodgenerator.py | 4 +- src/jarabe/model/aodjobs.py | 9 ++- src/jarabe/model/aodpipeline.py | 23 +++++- src/jarabe/model/aodservice.py | 7 +- tests/test_aodenhance.py | 98 ++++++++++++++++++++++ tests/test_aodpipeline.py | 102 +++++++++++++++++++++++ 8 files changed, 485 insertions(+), 5 deletions(-) create mode 100644 src/jarabe/model/aodenhance.py create mode 100644 tests/test_aodenhance.py diff --git a/src/jarabe/desktop/homebox.py b/src/jarabe/desktop/homebox.py index 321bbfa235..70eb135163 100644 --- a/src/jarabe/desktop/homebox.py +++ b/src/jarabe/desktop/homebox.py @@ -316,6 +316,7 @@ def __init__(self): 'planner': 'rag', 'policy': 'creative', 'validate': 'on', + 'enhance': 'on', 'provider': 'default', 'license': 'mit', 'code_size': 'standard', @@ -433,6 +434,10 @@ def __init__(self): self._sidebar_challenge_box = None self._sidebar_level_label = None self._prompt_is_placeholder = False + self._enhance_button = None + self._enhance_chip_value_label = None + self._enhance_running = False + self._enhanced_prompt_announced = False self._preview_is_fullscreen = False self._preview_fullscreen_button = None self._studio_left_panel = None @@ -664,6 +669,23 @@ def _create_create_view(self): bottom_row.pack_start(validate_chip, False, False, 0) validate_chip.show() + enhance_chip = Gtk.ToggleButton() + enhance_chip.set_relief(Gtk.ReliefStyle.NONE) + enhance_chip.get_style_context().add_class('create-ai-prompt-chip') + enhance_chip.get_style_context().add_class( + 'create-ai-prompt-chip-active') + enhance_content, enhance_value = self._build_chip_content( + _('Enhance'), _('Auto')) + enhance_chip.add(enhance_content) + self._enhance_chip_value_label = enhance_value + enhance_chip.set_active(True) + enhance_chip.set_tooltip_text( + _('Automatically expand short prompts into a detailed ' + 'brief before generating')) + enhance_chip.connect('toggled', self.__enhance_chip_toggled_cb) + bottom_row.pack_start(enhance_chip, False, False, 0) + enhance_chip.show() + send_btn = Gtk.Button() send_icon = Icon(icon_name='go-up', pixel_size=style.SMALL_ICON_SIZE, @@ -679,6 +701,17 @@ def _create_create_view(self): bottom_row.pack_end(send_btn, False, False, 0) send_btn.show() + enhance_btn = Gtk.Button.new_with_label('✨ ' + _('Enhance')) + self._enhance_button = enhance_btn + enhance_btn.set_relief(Gtk.ReliefStyle.NONE) + enhance_btn.get_style_context().add_class('create-ai-prompt-chip') + enhance_btn.set_valign(Gtk.Align.CENTER) + enhance_btn.set_tooltip_text( + _('Expand your idea into a detailed brief you can edit')) + enhance_btn.connect('clicked', self.__enhance_button_clicked_cb) + bottom_row.pack_end(enhance_btn, False, False, style.zoom(6)) + enhance_btn.show() + thinking = Gtk.Label('') self._prompt_status_label = thinking thinking.get_style_context().add_class('create-ai-prompt-status') @@ -870,6 +903,83 @@ def _update_template_card_icons(self): icon.props.stroke_color = style.COLOR_TOOLBAR_GREY.get_svg() icon.props.fill_color = style.COLOR_INACTIVE_FILL.get_svg() + def __enhance_chip_toggled_cb(self, button): + active = button.get_active() + self._selected_options['enhance'] = 'on' if active else 'off' + if self._enhance_chip_value_label is not None: + self._enhance_chip_value_label.set_text( + _('Auto') if active else _('Off')) + if active: + button.get_style_context().add_class( + 'create-ai-prompt-chip-active') + else: + button.get_style_context().remove_class( + 'create-ai-prompt-chip-active') + if self._prompt_status_label is not None: + self._prompt_status_label.set_text( + _('Short prompts will be auto-enhanced') if active + else _('Prompts are sent exactly as written')) + + def __enhance_button_clicked_cb(self, button): + self._start_prompt_enhancement() + + def _start_prompt_enhancement(self): + if self._enhance_running: + return + prompt = '' if self._prompt_is_placeholder else \ + self._get_prompt_text() + if not prompt: + if self._prompt_status_label is not None: + self._prompt_status_label.set_text( + _('Type your idea first, then press Enhance')) + return + + from jarabe.model.aodllm import get_configured_provider + from jarabe.model.aodllm import normalize_provider_name + + provider_name = normalize_provider_name( + self._selected_options.get('provider', 'default')) + try: + provider = get_configured_provider(provider_name) + except Exception: + logging.exception('Could not resolve provider for enhance') + provider = None + if provider is None: + if self._prompt_status_label is not None: + self._prompt_status_label.set_text( + _('Enhance needs an AI provider (not the local ' + 'template)')) + return + + self._enhance_running = True + if self._enhance_button is not None: + self._enhance_button.set_sensitive(False) + if self._prompt_status_label is not None: + self._prompt_status_label.set_text( + _('Enhancing your idea...')) + + def worker(): + from jarabe.model.aodenhance import enhance_prompt + text, enhanced = enhance_prompt(provider, prompt) + GObject.idle_add(self.__enhance_finished_cb, text, enhanced) + + threading.Thread(target=worker, daemon=True).start() + + def __enhance_finished_cb(self, text, enhanced): + self._enhance_running = False + if self._enhance_button is not None: + self._enhance_button.set_sensitive(True) + if enhanced: + self._set_prompt_text(text) + if self._prompt_status_label is not None: + self._prompt_status_label.set_text( + _('Enhanced — edit it if you like, then Send')) + elif self._prompt_status_label is not None: + self._prompt_status_label.set_text( + _('Could not enhance right now; your prompt is ' + 'unchanged')) + return False + def __validate_chip_toggled_cb(self, button): active = button.get_active() self._selected_options['validate'] = 'on' if active else 'off' @@ -3642,6 +3752,9 @@ def _ensure_css(self): padding: 3px 10px; min-height: 0; } + button.create-ai-prompt-chip label { + color: #333333; + } button.create-ai-prompt-chip:hover { background-color: %(studio_lavender_soft)s; border-color: %(studio_lavender_faint)s; @@ -5580,6 +5693,7 @@ def _stop_generation_animation(self): def _generation_fun_messages(self): return [ _('Great ideas take a moment to build...'), + _('Turning "make X" into a plan learners can touch...'), _('Real Sugar activities are lending a hand as examples.'), _('Mixing colors, code, and curiosity...'), _('Teaching your activity how to play fair...'), @@ -7509,6 +7623,7 @@ def _set_generation_step_active(self, active_index): def _generation_step_index_for_stage(self, stage): stage_indexes = { 'queued': 0, + 'enhancing': 0, 'planning': 0, 'provider': 0, 'grounding': 1, @@ -8063,6 +8178,7 @@ def _submit_generation_spec(self, spec, chat_prompt=None, else: self._prompt_status_label.set_text(_('Generating')) + self._enhanced_prompt_announced = False try: job = service.submit_activity( spec, @@ -8073,6 +8189,7 @@ def _submit_generation_spec(self, spec, chat_prompt=None, parent_revision_id=( self._aod_active_revision_id if is_refinement else ''), user_prompt=chat_prompt or display_prompt, + enhance=self._selected_options.get('enhance', 'on') == 'on', ) except Exception as error: logging.exception('Could not submit Activity on Demand job') @@ -8146,6 +8263,13 @@ def _generation_job_updated_cb(self, job_id): def _generation_progress_cb(self, stage, fraction, message, job=None): if self._prompt_status_label is not None: self._prompt_status_label.set_text(message) + enhanced = getattr(job, 'enhanced_prompt', '') if job else '' + if enhanced and not self._enhanced_prompt_announced: + self._enhanced_prompt_announced = True + self._append_chat_status(_('✨ Enhanced your prompt')) + self._append_chat_message( + _('I understood your idea as:\n%s') % enhanced, + from_user=False) self._update_live_review_generation(stage, fraction, message, job) self._update_provider_call_status(stage, fraction, message) self._update_generation_animation(stage, fraction, message) @@ -8186,7 +8310,15 @@ def _update_provider_call_status(self, stage, fraction, message): provider = self._get_provider_label( self._selected_options.get('provider', 'default')) percent = int(fraction * 100) - if stage == 'grounding': + if stage == 'enhancing': + self._provider_status_label.set_text( + _('%(provider)s is clarifying the idea before planning ' + '- %(percent)d%%') % { + 'provider': provider, + 'percent': percent, + } + ) + elif stage == 'grounding': self._provider_status_label.set_text( _('RAG selected Sugar examples for %(provider)s context - ' '%(percent)d%%. No training or ingestion is happening.') % { diff --git a/src/jarabe/model/aodenhance.py b/src/jarabe/model/aodenhance.py new file mode 100644 index 0000000000..5251ed7184 --- /dev/null +++ b/src/jarabe/model/aodenhance.py @@ -0,0 +1,113 @@ +# Copyright (C) 2026 Sugar Labs +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Expand a learner's short idea into a clear activity brief. + +Learner prompts are often a handful of words ("space racer 2d game"), +which forces the planner and code generator to guess gameplay, +controls, UI, and the learning goal. This module asks the configured +provider for a compact plain-text brief that keeps the learner's +intent, and degrades to the original prompt on any failure — an +enhancement problem must never break generation. +""" + +import logging +import re + +from jarabe.model.aodspec import MAX_PROMPT_LENGTH + +_ENHANCE_TIMEOUT = 120 + +# Prompts already this detailed are left alone by auto-enhancement. +_AUTO_MAX_WORDS = 40 +_AUTO_MAX_CHARS = 400 + +_MIN_USEFUL_RESULT = 20 + + +def build_enhance_system_prompt(): + return ( + "You turn a learner's short activity idea into a clear brief " + 'for generating a Sugar (GTK3) learning activity.\n' + "Keep the learner's intent and vocabulary — clarify it, do not " + 'replace it.\n' + 'Return PLAIN TEXT only, at most 180 words: one sentence ' + 'stating what the activity is, then short lines covering:\n' + '- gameplay / interaction (what the learner actually does)\n' + '- the main screen regions and controls\n' + '- the win or completion rule\n' + '- what the learner practices or learns\n' + '- what gets saved to the Journal\n' + 'No markdown headers, no code, no questions, no preamble like ' + '"Here is". Write it as the activity request itself.' + ) + + +def build_enhance_user_prompt(prompt, spec=None): + context = '' + if spec is not None: + context = ( + '\nLearning category: %s\nAge band: %s' + % (spec.category, spec.age_band) + ) + return 'Learner idea: %s%s' % (prompt, context) + + +def needs_enhancement(prompt): + """Whether auto-enhancement should run for this prompt. + + Detailed prompts (long, or already expanded with the Enhance + button) are used as-is. + """ + text = (prompt or '').strip() + if not text: + return False + return len(text.split()) < _AUTO_MAX_WORDS and \ + len(text) < _AUTO_MAX_CHARS + + +def enhance_prompt(provider, prompt, spec=None, timeout=_ENHANCE_TIMEOUT): + """Return (prompt_text, enhanced) — fail-soft, never raises. + + On success the returned text is the cleaned brief and enhanced is + True; on any failure the original prompt comes back unchanged with + enhanced False. + """ + original = (prompt or '').strip() + if not original or provider is None: + return original, False + + try: + response = provider.generate_text( + build_enhance_system_prompt(), + build_enhance_user_prompt(original, spec), + timeout=timeout, + ) + except Exception as error: + logging.warning('Prompt enhancement failed: %s', error) + return original, False + + cleaned = _clean(response) + if len(cleaned) < _MIN_USEFUL_RESULT: + logging.warning('Prompt enhancement returned too little text') + return original, False + return cleaned, True + + +def _clean(text): + if not isinstance(text, str): + return '' + cleaned = text.strip() + if cleaned.startswith('```'): + cleaned = re.sub(r'^```[a-zA-Z0-9_-]*\n?', '', cleaned) + if cleaned.endswith('```'): + cleaned = cleaned[:-3] + cleaned = cleaned.strip() + if len(cleaned) >= 2 and cleaned[0] == cleaned[-1] and \ + cleaned[0] in ('"', "'"): + cleaned = cleaned[1:-1].strip() + cleaned = re.sub(r'\n{3,}', '\n\n', cleaned) + if len(cleaned) > MAX_PROMPT_LENGTH: + cleaned = cleaned[:MAX_PROMPT_LENGTH].rstrip() + return cleaned diff --git a/src/jarabe/model/aodgenerator.py b/src/jarabe/model/aodgenerator.py index 2d028ab229..c4f49f438c 100644 --- a/src/jarabe/model/aodgenerator.py +++ b/src/jarabe/model/aodgenerator.py @@ -552,7 +552,9 @@ def normalize_plan(spec, plan): 'codegen_provider', 'codegen_model', 'codegen_fallback_reason', - 'refine_method'): + 'refine_method', + 'original_prompt', + 'enhanced_prompt'): value = plan.get(field) if isinstance(value, str) and value: normalized[field] = value diff --git a/src/jarabe/model/aodjobs.py b/src/jarabe/model/aodjobs.py index 2a68af3fcc..6cdb65faff 100644 --- a/src/jarabe/model/aodjobs.py +++ b/src/jarabe/model/aodjobs.py @@ -46,6 +46,8 @@ class AODJob: session_id: str = '' parent_revision_id: str = '' user_prompt: str = '' + enhance: bool = True + enhanced_prompt: str = '' status: str = STATUS_QUEUED stage: str = STATUS_QUEUED progress: float = 0.0 @@ -63,7 +65,7 @@ class AODJob: @classmethod def create(cls, spec, provider_name='default', use_rag=True, validate_code=True, output_root=None, session_id='', - parent_revision_id='', user_prompt=''): + parent_revision_id='', user_prompt='', enhance=True): return cls( job_id=uuid.uuid4().hex, spec=spec.normalized(), @@ -74,6 +76,7 @@ def create(cls, spec, provider_name='default', use_rag=True, session_id=session_id or '', parent_revision_id=parent_revision_id or '', user_prompt=user_prompt or spec.prompt, + enhance=bool(enhance), ) @classmethod @@ -88,6 +91,8 @@ def from_dict(cls, data): session_id=data.get('session_id', ''), parent_revision_id=data.get('parent_revision_id', ''), user_prompt=data.get('user_prompt', ''), + enhance=data.get('enhance', True), + enhanced_prompt=data.get('enhanced_prompt', ''), status=data.get('status', STATUS_QUEUED), stage=data.get('stage', STATUS_QUEUED), progress=data.get('progress', 0.0), @@ -114,6 +119,8 @@ def to_dict(self): 'session_id': self.session_id, 'parent_revision_id': self.parent_revision_id, 'user_prompt': self.user_prompt, + 'enhance': self.enhance, + 'enhanced_prompt': self.enhanced_prompt, 'status': self.status, 'stage': self.stage, 'progress': self.progress, diff --git a/src/jarabe/model/aodpipeline.py b/src/jarabe/model/aodpipeline.py index 663d433096..fa20e88d46 100644 --- a/src/jarabe/model/aodpipeline.py +++ b/src/jarabe/model/aodpipeline.py @@ -5,10 +5,13 @@ import json import os import time +from dataclasses import replace from sugar3 import env from jarabe.model.aodcodegen import build_codegen_system_prompt +from jarabe.model.aodenhance import enhance_prompt +from jarabe.model.aodenhance import needs_enhancement from jarabe.model.aodcodegen import build_codegen_user_prompt from jarabe.model.aodgenerator import apply_license_to_project from jarabe.model.aodgenerator import build_plan @@ -51,7 +54,7 @@ def generate_activity(spec, output_root=None, provider=None, provider_name='default', use_rag=True, validate_code=True, progress_cb=None, pace=False, package_bundle=True, - template_fallback=False): + template_fallback=False, enhance=True): """Run prompt grounding, provider planning, and generation. When template_fallback is True and the provider fails to deliver valid @@ -62,6 +65,21 @@ def generate_activity(spec, output_root=None, provider=None, remains the default for tests and CLI callers that want to fail fast. """ progress = _PipelineProgress(progress_cb, pace) + + original_prompt = spec.prompt + prompt_was_enhanced = False + if provider is not None and enhance and needs_enhancement(spec.prompt): + progress.report('enhancing', 0.03, + 'Making your idea crystal clear...') + enhanced_text, prompt_was_enhanced = enhance_prompt( + provider, spec.prompt, spec) + if prompt_was_enhanced: + spec = replace(spec, prompt=enhanced_text) + progress.report( + 'enhancing', 0.05, + 'Refined your idea into a clear brief', + metadata={'enhanced_prompt': enhanced_text}) + progress.report('planning', 0.06, 'Reading the prompt and classroom goal') selected_provider = provider @@ -188,6 +206,9 @@ def generate_activity(spec, output_root=None, provider=None, plan = dict(plan) plan['provider'] = provider_used plan['model'] = model_used + if prompt_was_enhanced: + plan['original_prompt'] = original_prompt + plan['enhanced_prompt'] = spec.prompt if provider_error: plan['provider_fallback_reason'] = provider_error diff --git a/src/jarabe/model/aodservice.py b/src/jarabe/model/aodservice.py index 0a83cf9ecb..d14ab52f5e 100644 --- a/src/jarabe/model/aodservice.py +++ b/src/jarabe/model/aodservice.py @@ -62,7 +62,7 @@ def __init__(self, job_store=None, worker_count=1, def submit_activity(self, spec, provider_name='default', use_rag=True, validate_code=True, output_root=None, callback=None, session_id='', parent_revision_id='', - user_prompt=None): + user_prompt=None, enhance=True): errors = spec.validate() if errors: raise ValueError('\n'.join(errors)) @@ -81,6 +81,7 @@ def submit_activity(self, spec, provider_name='default', use_rag=True, session_id=session.session_id, parent_revision_id=parent_revision_id, user_prompt=prompt_text, + enhance=enhance, ) if callback is not None: self.watch(job.job_id, callback) @@ -367,6 +368,7 @@ def _run_job_inner(self, job): ), pace=True, package_bundle=False, + enhance=job.enhance, ) except JobCancelled: self._mark_cancelled(job) @@ -498,6 +500,9 @@ def _set_progress(self, job, status, stage, progress, message, draft_source = metadata.get('draft_activity_source') if isinstance(draft_source, str) and draft_source: job.draft_activity_source = draft_source + enhanced = metadata.get('enhanced_prompt') + if isinstance(enhanced, str) and enhanced: + job.enhanced_prompt = enhanced self._store.save(job) self._notify(job) diff --git a/tests/test_aodenhance.py b/tests/test_aodenhance.py new file mode 100644 index 0000000000..d3bc50ada6 --- /dev/null +++ b/tests/test_aodenhance.py @@ -0,0 +1,98 @@ +# Copyright (C) 2026 Sugar Labs +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import unittest + +from jarabe.model.aodenhance import _clean +from jarabe.model.aodenhance import build_enhance_system_prompt +from jarabe.model.aodenhance import enhance_prompt +from jarabe.model.aodenhance import needs_enhancement +from jarabe.model.aodspec import ActivitySpec +from jarabe.model.aodspec import MAX_PROMPT_LENGTH + + +class _FakeEnhancer: + def __init__(self, response): + self.response = response + self.calls = [] + + def generate_text(self, system_prompt, user_prompt, timeout=120, + stream_callback=None): + self.calls.append((system_prompt, user_prompt)) + return self.response + + +class _BrokenEnhancer: + def generate_text(self, *args, **kwargs): + raise RuntimeError('provider exploded') + + +class TestNeedsEnhancement(unittest.TestCase): + + def test_short_prompt_qualifies(self): + self.assertTrue(needs_enhancement('space racer 2d game')) + + def test_empty_prompt_does_not(self): + self.assertFalse(needs_enhancement('')) + self.assertFalse(needs_enhancement(' ')) + + def test_long_prompt_skips(self): + self.assertFalse(needs_enhancement('word ' * 45)) + self.assertFalse(needs_enhancement('x' * 450)) + + +class TestEnhancePrompt(unittest.TestCase): + + def test_success_uses_provider_and_cleans(self): + provider = _FakeEnhancer( + '```\nA counting game where kids tap ten ladybugs on a ' + 'meadow.\n- Tap each ladybug to count it aloud\n```') + spec = ActivitySpec('Bugs', 'counting game', 'logic_math', 'MIT') + + text, enhanced = enhance_prompt(provider, 'counting game', spec) + + self.assertTrue(enhanced) + self.assertTrue(text.startswith('A counting game')) + self.assertNotIn('```', text) + system_prompt, user_prompt = provider.calls[0] + self.assertIn('Sugar (GTK3) learning activity', system_prompt) + self.assertIn('counting game', user_prompt) + self.assertIn('logic_math', user_prompt) + + def test_provider_failure_falls_back(self): + text, enhanced = enhance_prompt(_BrokenEnhancer(), 'a quiz idea') + self.assertEqual('a quiz idea', text) + self.assertFalse(enhanced) + + def test_tiny_response_falls_back(self): + text, enhanced = enhance_prompt(_FakeEnhancer('ok'), 'a quiz idea') + self.assertEqual('a quiz idea', text) + self.assertFalse(enhanced) + + def test_no_provider_falls_back(self): + text, enhanced = enhance_prompt(None, 'a quiz idea') + self.assertEqual('a quiz idea', text) + self.assertFalse(enhanced) + + +class TestClean(unittest.TestCase): + + def test_strips_quotes_and_collapses_blank_runs(self): + self.assertEqual('line one\n\nline two', + _clean('"line one\n\n\n\nline two"')) + + def test_clamps_overlong_text(self): + cleaned = _clean('y' * (MAX_PROMPT_LENGTH + 500)) + self.assertLessEqual(len(cleaned), MAX_PROMPT_LENGTH) + + def test_non_string_is_empty(self): + self.assertEqual('', _clean(None)) + self.assertEqual('', _clean(123)) + + def test_system_prompt_mentions_plain_text(self): + self.assertIn('PLAIN TEXT', build_enhance_system_prompt()) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_aodpipeline.py b/tests/test_aodpipeline.py index 657361131b..87b29e2dc8 100644 --- a/tests/test_aodpipeline.py +++ b/tests/test_aodpipeline.py @@ -133,6 +133,38 @@ def generate_activity_source(self, system_prompt, user_prompt, raise ProviderError('codegen offline for test') +class _EnhancingCodegenProvider(_CodegenProvider): + """Codegen provider that also supports prompt enhancement.""" + + ENHANCED = ( + 'A fractions quiz where learners pick equivalent fractions.\n' + '- Tap the fraction card that matches the target\n' + '- Score panel and streak counter\n' + '- Wins after ten correct answers\n' + '- Practices equivalent fractions\n' + '- Saves score history to the Journal') + + def __init__(self, source, fail_enhance=False): + _CodegenProvider.__init__(self, source) + self.enhance_calls = 0 + self.observed_plan_prompts = [] + self.fail_enhance = fail_enhance + + def generate_text(self, system_prompt, user_prompt, timeout=120, + stream_callback=None): + self.enhance_calls += 1 + if self.fail_enhance: + raise ProviderError('enhancer offline') + if 'Sugar (GTK3) learning activity' not in system_prompt: + raise AssertionError('Missing enhancement instructions') + return self.ENHANCED + + def generate_plan(self, system_prompt, user_prompt, timeout=45): + self.observed_plan_prompts.append(user_prompt) + return _CodegenProvider.generate_plan( + self, system_prompt, user_prompt, timeout) + + class TestAodPipeline(unittest.TestCase): def setUp(self): @@ -176,6 +208,76 @@ def test_provider_plan_runs_end_to_end(self): saved_plan = json.load(plan_file) self.assertEqual('fake', saved_plan['provider']) + def test_short_prompt_is_enhanced_before_planning(self): + events = [] + provider = _EnhancingCodegenProvider( + _valid_activity_source(self.spec)) + + result = generate_activity( + self.spec, + self.output_root, + provider=provider, + progress_cb=lambda *event: events.append(event), + ) + + self.assertEqual(1, provider.enhance_calls) + stages = [event[0] for event in events] + self.assertIn('enhancing', stages) + self.assertLess(stages.index('enhancing'), stages.index('planning')) + metadata_events = [ + event[3] for event in events + if len(event) > 3 and isinstance(event[3], dict)] + self.assertTrue(any( + meta.get('enhanced_prompt') == provider.ENHANCED + for meta in metadata_events)) + self.assertIn(provider.ENHANCED.splitlines()[0], + provider.observed_plan_prompts[0]) + self.assertEqual('Make a fractions quiz.', + result.plan['original_prompt']) + self.assertEqual(provider.ENHANCED, result.plan['enhanced_prompt']) + self.assertEqual(provider.ENHANCED, result.spec.prompt) + + def test_enhancement_disabled_skips_enhancer(self): + provider = _EnhancingCodegenProvider( + _valid_activity_source(self.spec)) + result = generate_activity( + self.spec, + self.output_root, + provider=provider, + enhance=False, + ) + self.assertEqual(0, provider.enhance_calls) + self.assertNotIn('enhanced_prompt', result.plan) + + def test_detailed_prompt_skips_enhancer(self): + provider = _EnhancingCodegenProvider( + _valid_activity_source(self.spec)) + long_spec = ActivitySpec( + 'Fraction Quest', + 'Make a fractions quiz. ' + 'Learners compare cards. ' * 25, + 'logic_math', + 'MIT', + ) + generate_activity( + long_spec, + self.output_root, + provider=provider, + ) + self.assertEqual(0, provider.enhance_calls) + + def test_enhancer_failure_falls_back_to_original_prompt(self): + provider = _EnhancingCodegenProvider( + _valid_activity_source(self.spec), fail_enhance=True) + result = generate_activity( + self.spec, + self.output_root, + provider=provider, + ) + self.assertEqual(1, provider.enhance_calls) + self.assertNotIn('enhanced_prompt', result.plan) + self.assertIn('Make a fractions quiz.', + provider.observed_plan_prompts[0]) + def test_provider_rag_search_is_not_template_filtered(self): search_calls = [] From d1705940ff18d4ffe079931c2c8a601c66e4f10e Mon Sep 17 00:00:00 2001 From: Ashutoshx7 Date: Sun, 5 Jul 2026 14:51:36 +0530 Subject: [PATCH 26/28] Fix live-edit toolbar detection: ToolbarBox is a sugar3 class, not Gtk --- src/jarabe/desktop/homebox.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/jarabe/desktop/homebox.py b/src/jarabe/desktop/homebox.py index 70eb135163..fe54e2e759 100644 --- a/src/jarabe/desktop/homebox.py +++ b/src/jarabe/desktop/homebox.py @@ -5002,7 +5002,8 @@ def _describe_widget_for_live_edit(self, widget, in_toolbar): if text and not text.startswith('<') and len(text) > 2: return _('label: %s') % text[:30] return None - if isinstance(widget, (Gtk.Toolbar, Gtk.ToolbarBox)): + from sugar3.graphics.toolbarbox import ToolbarBox + if isinstance(widget, (Gtk.Toolbar, ToolbarBox)): return _('activity toolbar') if isinstance(widget, Gtk.Grid): return _('grid') From 7ec12442a80acd6d740fee7bd1b91947bb44ea49 Mon Sep 17 00:00:00 2001 From: Ashutoshx7 Date: Mon, 6 Jul 2026 01:14:53 +0530 Subject: [PATCH 27/28] Port generation-quality upgrades from the studio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated code now clears three gates before a learner sees it: static validation, an actual run in a sandboxed GTK subprocess (aodruntime + aodruntimeharness — start, pump events, Journal write/read round-trip; crashes and degraded startups become retry feedback, blocking __init__ hits a timeout), and one self-review round (aodcritic — checklist review answered with OK or minimal SEARCH/REPLACE fixes, kept only if the patched code re-passes the first two gates). Validator warnings ride along on retries as "Also consider" hints. Every activity also gets its own icon (aodicons): deterministic glyph per template/category, name-hash accent, Sugar entity header so it recolors like any shell icon. Outcomes land in the saved plan under runtime_check / critic; env knobs AOD_RUNTIME_CHECK, AOD_RUNTIME_CHECK_TIMEOUT, AOD_CRITIC. .flake8: keep W503/W504 in the ignore list (flake8 ignores both by default; a bare ignore= re-enables two mutually exclusive checks). --- .flake8 | 5 +- src/jarabe/desktop/homebox.py | 2 +- src/jarabe/model/aodcritic.py | 148 +++++++++++++++++++++++ src/jarabe/model/aodgenerator.py | 16 ++- src/jarabe/model/aodicons.py | 163 ++++++++++++++++++++++++++ src/jarabe/model/aodpipeline.py | 57 +++++++-- src/jarabe/model/aodruntime.py | 119 +++++++++++++++++++ src/jarabe/model/aodruntimeharness.py | 98 ++++++++++++++++ tests/conftest.py | 18 +++ tests/test_aodcritic.py | 162 +++++++++++++++++++++++++ tests/test_aodicons.py | 48 ++++++++ tests/test_aodpipeline.py | 82 +++++++++++++ tests/test_aodruntime.py | 93 +++++++++++++++ 13 files changed, 999 insertions(+), 12 deletions(-) create mode 100644 src/jarabe/model/aodcritic.py create mode 100644 src/jarabe/model/aodicons.py create mode 100644 src/jarabe/model/aodruntime.py create mode 100644 src/jarabe/model/aodruntimeharness.py create mode 100644 tests/conftest.py create mode 100644 tests/test_aodcritic.py create mode 100644 tests/test_aodicons.py create mode 100644 tests/test_aodruntime.py diff --git a/.flake8 b/.flake8 index 0146a5f3f6..918e77f6d6 100644 --- a/.flake8 +++ b/.flake8 @@ -2,5 +2,8 @@ # E402 module level import not at top of file # gi.require_version() is required before later imports +# W503/W504 (line break before/after binary operator) are in flake8's +# default ignore list; setting ignore= would otherwise re-enable both, +# and they can never be satisfied at the same time. -ignore = E402 +ignore = E402, W503, W504 diff --git a/src/jarabe/desktop/homebox.py b/src/jarabe/desktop/homebox.py index fe54e2e759..27bd09d862 100644 --- a/src/jarabe/desktop/homebox.py +++ b/src/jarabe/desktop/homebox.py @@ -1805,7 +1805,7 @@ def _show_generation_activity_preview(self): 'system-search'), (_('Write'), _('Writing the Python code for your activity'), 'edit-description'), - (_('Check'), _('Preparing project files and previewing'), + (_('Check'), _('Testing the code and preparing files'), 'dialog-ok'), (_('Ready'), _('Packaging your installable activity bundle'), 'package_settings'), diff --git a/src/jarabe/model/aodcritic.py b/src/jarabe/model/aodcritic.py new file mode 100644 index 0000000000..ded1dc8bfd --- /dev/null +++ b/src/jarabe/model/aodcritic.py @@ -0,0 +1,148 @@ +# Copyright (C) 2026 Sugar Labs +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""One self-review round over freshly generated activity code. + +The generated source already passed static validation and the runtime +gate, so it works — but working code can still be shallow: buttons +without handlers, win conditions that never fire, Journal methods that +save nothing real. This module asks the model to review its own +output once and return either OK or minimal SEARCH/REPLACE fixes. + +The critic is strictly fail-safe. The unpatched source is already +validated and runtime-proven, so on any doubt — unparseable reply, +failed patch, patched code failing validation or the runtime gate — +the original is kept and generation continues as if the critic had +said OK. +""" + +import logging +import os + +from jarabe.model.aodrefine import apply_patches +from jarabe.model.aodrefine import parse_search_replace +from jarabe.model.aodruntime import run_runtime_check +from jarabe.model.aodvalidator import validate_activity_source_for_request + + +def build_critic_system_prompt(): + return ( + 'You are Sugar Activity on Demand, reviewing a Sugar activity ' + 'you just wrote before it is given to a learner.\n\n' + 'Check the code against this list:\n' + '- Every button, entry, and control is connected to a handler ' + 'that does something visible.\n' + '- The win / success / feedback logic is actually reachable by ' + 'playing the activity.\n' + '- write_file saves the real activity state and read_file ' + 'restores it, so closing and reopening resumes the activity.\n' + '- The learner can tell what to do: instructions or labels are ' + 'visible on screen.\n' + '- No dead code, placeholder text, or TODO stubs remain.\n\n' + 'If the code passes the whole list, reply with exactly:\n' + 'OK\n' + '...and nothing else.\n\n' + 'Otherwise return ONLY minimal fixes as SEARCH/REPLACE blocks ' + 'in this exact format:\n\n' + '<<<<<<< SEARCH\n' + '\n' + '=======\n' + '\n' + '>>>>>>> REPLACE\n\n' + 'Rules:\n' + '- The SEARCH section must be copied EXACTLY from the current ' + 'source, including indentation and whitespace.\n' + '- Keep each SEARCH block small but unique (3-10 lines).\n' + '- Fix only real defects from the list above. Do NOT restyle, ' + 'rename, or rewrite working code.\n' + '- FULLREGEN is not allowed here. If you cannot express a fix ' + 'as small SEARCH/REPLACE blocks, reply OK instead.\n' + '- Preserve all Sugar Activity patterns: ToolbarBox, ' + 'StopButton, set_canvas, read_file/write_file, Journal ' + 'persistence.\n' + '- Keep the same class name GeneratedActivity.\n' + '- Use only classroom-safe imports. No networking, ' + 'subprocesses, or filesystem access.\n' + ) + + +def build_critic_user_prompt(spec, plan, source, warnings=None): + parts = [ + 'Review the activity.py you generated for this request.\n\n', + 'Activity: %s\n' % getattr(spec, 'name', ''), + 'Request: %s\n' % getattr(spec, 'prompt', ''), + ] + summary = plan.get('summary') if isinstance(plan, dict) else None + if summary: + parts.append('Planned summary: %s\n' % summary) + if warnings: + parts.append('\nThe validator raised these concerns:\n') + parts.extend('- %s\n' % warning for warning in warnings) + parts.append('\nCurrent activity.py (%d lines):\n' % source.count('\n')) + parts.append(source.rstrip()) + parts.append( + '\n\n---\n\n' + 'Reply with exactly OK if the code passes the checklist, or ' + 'with minimal SEARCH/REPLACE blocks copied EXACTLY from the ' + 'source above.' + ) + return ''.join(parts) + + +def run_critic_round(provider, spec, plan, source, warnings=None): + """Return the (possibly patched) source; never raises. + + Records the outcome in plan['critic']: 'ok' when the model found + nothing to fix, 'patched:N' when N fixes were applied and the + result re-passed validation and the runtime gate, 'skipped' in + every other case. + """ + plan['critic'] = 'skipped' + + if os.environ.get('AOD_CRITIC', 'on').lower() in ( + 'off', '0', 'no', 'false'): + return source + generate_text = getattr(provider, 'generate_text', None) + if not callable(generate_text): + return source + + try: + response = generate_text( + build_critic_system_prompt(), + build_critic_user_prompt(spec, plan, source, warnings), + ) + except Exception as error: + logging.warning('Critic call failed: %s', error) + return source + + if not isinstance(response, str) or response.strip() == 'OK': + plan['critic'] = 'ok' + return source + + try: + patches = parse_search_replace(response) + except ValueError: + logging.warning('Critic reply was not OK or valid patches') + return source + if patches is None: # FULLREGEN — forbidden for the critic. + return source + + patched, applied, failed = apply_patches(source, patches) + if failed or not applied: + logging.warning('Critic patches failed to apply (%d failed)', failed) + return source + + report = validate_activity_source_for_request(patched, spec, plan) + if not report.valid: + logging.warning('Critic patch broke validation; keeping original') + return source + runtime_ok, _detail = run_runtime_check( + patched, getattr(spec, 'name', 'Generated Activity')) + if not runtime_ok: + logging.warning('Critic patch broke the runtime gate; ' + 'keeping original') + return source + + plan['critic'] = 'patched:%d' % applied + return patched diff --git a/src/jarabe/model/aodgenerator.py b/src/jarabe/model/aodgenerator.py index c4f49f438c..9e6b4c1cd9 100644 --- a/src/jarabe/model/aodgenerator.py +++ b/src/jarabe/model/aodgenerator.py @@ -554,7 +554,9 @@ def normalize_plan(spec, plan): 'codegen_fallback_reason', 'refine_method', 'original_prompt', - 'enhanced_prompt'): + 'enhanced_prompt', + 'runtime_check', + 'critic'): value = plan.get(field) if isinstance(value, str) and value: normalized[field] = value @@ -594,7 +596,7 @@ def assemble_project(spec, plan, output_root, activity_source=None): 'aod_plan.json': json.dumps(plan, indent=2, sort_keys=True) + '\n', os.path.join('activity', 'activity.info'): _render_activity_info(spec, plan), - os.path.join('activity', 'activity.svg'): _ACTIVITY_ICON, + os.path.join('activity', 'activity.svg'): _activity_icon(plan), } for relative_path, content in files.items(): @@ -1060,6 +1062,16 @@ def _new_project_path(output_root, name): bundlebuilder.start() """ + +def _activity_icon(plan): + """Per-activity icon, falling back to the classic checkmark.""" + try: + from jarabe.model.aodicons import render_activity_icon + return render_activity_icon(plan) + except Exception: + return _ACTIVITY_ICON + + _ACTIVITY_ICON = """ diff --git a/src/jarabe/model/aodicons.py b/src/jarabe/model/aodicons.py new file mode 100644 index 0000000000..d90e1a23ec --- /dev/null +++ b/src/jarabe/model/aodicons.py @@ -0,0 +1,163 @@ +# Copyright (C) 2026 Sugar Labs +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Deterministic per-activity icons. + +Every generated activity used to ship the same checkmark. This module +renders a small Sugar-style SVG icon whose glyph follows the activity's +template/category and whose accent varies with the activity name, so +the home ring shows a distinct, colorizable icon per activity. Icons +use Sugar's ``&stroke_color;``/``&fill_color;`` entities so they take +the learner's XO colors wherever Sugar recolors icons. +""" + +import hashlib + +_HEADER = ( + '\n' + '\n' + ' \n' + ']>\n' +) + +_TEMPLATE = _HEADER + ( + '\n' + ' \n' + '%(glyph)s' + ' \n' + ' \n' + '\n' +) + +_S = 'stroke="&stroke_color;"' +_F = 'fill="&fill_color;"' +_SF = _S + ' ' + _F + +_GLYPHS = { + 'quiz': ( + ' \n' + ' \n' + ' \n' + ), + 'grid': ( + ' \n' + ' \n' + ' \n' + ' \n' + ), + 'canvas': ( + ' \n' + ' \n' + ), + 'narrative': ( + ' \n' + ' \n' + ), + 'utility': ( + ' \n' + ' \n' + ), + 'chess': ( + ' \n' + ), + 'carrom': ( + ' \n' + ' \n' + ), + 'science': ( + ' \n' + ' \n' + ' \n' + ), + 'language': ( + ' \n' + ' \n' + ), + 'games': ( + ' \n' + ' \n' + ), + 'default': ( + ' \n' + ' \n' + ), +} + +_CATEGORY_GLYPHS = { + 'science': 'science', + 'language': 'language', + 'games': 'games', + 'logic_math': 'grid', + 'tools_utils': 'utility', + 'creation': 'canvas', +} + +_ACCENTS = ((46, 8), (46, 46), (8, 46), (8, 8)) + + +def render_activity_icon(plan): + """Return a Sugar-style SVG icon for this plan; never raises.""" + try: + template = str(plan.get('template') or '') + category = str(plan.get('category') or '') + name = str(plan.get('name') or 'Activity') + + glyph_key = 'default' + if template in _GLYPHS: + glyph_key = template + elif category in _CATEGORY_GLYPHS: + glyph_key = _CATEGORY_GLYPHS[category] + + digest = int( + hashlib.sha256(name.encode('utf-8')).hexdigest()[:8], 16) + accent_x, accent_y = _ACCENTS[digest % len(_ACCENTS)] + angle = (digest // 7) % 13 - 6 # -6..6 degrees + + return _TEMPLATE % { + 'glyph': _GLYPHS[glyph_key], + 'angle': angle, + 'accent_x': accent_x, + 'accent_y': accent_y, + } + except Exception: + return _TEMPLATE % { + 'glyph': _GLYPHS['default'], + 'angle': 0, + 'accent_x': 46, + 'accent_y': 8, + } diff --git a/src/jarabe/model/aodpipeline.py b/src/jarabe/model/aodpipeline.py index fa20e88d46..dc053f32ed 100644 --- a/src/jarabe/model/aodpipeline.py +++ b/src/jarabe/model/aodpipeline.py @@ -10,6 +10,7 @@ from sugar3 import env from jarabe.model.aodcodegen import build_codegen_system_prompt +from jarabe.model.aodcritic import run_critic_round from jarabe.model.aodenhance import enhance_prompt from jarabe.model.aodenhance import needs_enhancement from jarabe.model.aodcodegen import build_codegen_user_prompt @@ -30,6 +31,7 @@ from jarabe.model.aodrefine import build_refine_user_prompt from jarabe.model.aodrefine import parse_search_replace from jarabe.model.aodrefine import apply_patches +from jarabe.model.aodruntime import run_runtime_check from jarabe.model.aodvalidator import validate_activity_source_for_request @@ -230,6 +232,21 @@ def generate_activity(spec, output_root=None, provider=None, plan['code_source'] = 'provider' plan['codegen_provider'] = selected_provider.name plan['codegen_model'] = selected_provider.model + if validate_code: + progress.report( + 'generating', 0.68, + 'Reviewing the code for weak spots...') + # Static validation is cheap; re-run it to hand the + # critic the accepted source's warnings as context. + accepted_report = validate_activity_source_for_request( + activity_source, spec, plan) + activity_source = run_critic_round( + selected_provider, + spec, + plan, + activity_source, + warnings=accepted_report.warnings, + ) elif code_error: if template_fallback: plan['codegen_fallback_reason'] = code_error @@ -475,14 +492,38 @@ def _generate_activity_source_with_provider(provider, spec, plan, references, report = validate_activity_source_for_request(source, spec, plan) if report.valid: - return source, '', attempt - - last_error = '\n'.join(report.errors) - progress.report( - 'generating', - 0.65, - 'Validation failed on attempt %d; retrying' % attempt, - ) + progress.report( + 'generating', + 0.66, + 'Running the activity to make sure it works...', + ) + runtime_ok, runtime_detail = run_runtime_check( + source, getattr(spec, 'name', 'Generated Activity')) + if runtime_ok: + plan['runtime_check'] = runtime_detail + return source, '', attempt + last_error = ( + 'The generated code crashed when run:\n%s\nFix the crash.' + % runtime_detail + ) + progress.report( + 'generating', + 0.65, + 'The code crashed when run (attempt %d); retrying' + % attempt, + ) + else: + last_error = '\n'.join(report.errors) + progress.report( + 'generating', + 0.65, + 'Validation failed on attempt %d; retrying' % attempt, + ) + if report.warnings: + last_error += ''.join( + '\nAlso consider: %s' % warning + for warning in report.warnings + ) # Brief backoff before the next attempt so we don't hammer the # provider immediately after a validation failure (1s, 2s, 4s…). if attempt < _CODEGEN_ATTEMPT_LIMIT: diff --git a/src/jarabe/model/aodruntime.py b/src/jarabe/model/aodruntime.py new file mode 100644 index 0000000000..9820c40eb0 --- /dev/null +++ b/src/jarabe/model/aodruntime.py @@ -0,0 +1,119 @@ +# Copyright (C) 2026 Sugar Labs +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Run generated code before accepting it. + +Static validation cannot see runtime crashes — code that imports +cleanly and has the right structure can still die in __init__ or in +its Journal methods. This gate executes each candidate activity in a +sandboxed subprocess (the same PreviewActivity path the studio preview +uses) so a crash becomes retry feedback for the model instead of a +broken activity for the learner. +""" + +import os +import shutil +import subprocess +import sys +import tempfile + +_HARNESS = os.path.join( + os.path.dirname(os.path.abspath(__file__)), 'aodruntimeharness.py') +_REPO_ROOT = os.path.dirname(os.path.dirname( + os.path.dirname(os.path.abspath(__file__)))) + +# Snap/IDE shells leak library paths that break GTK in subprocesses; +# same sanitization the offscreen tests use. +_SANITIZED_VARS = ( + 'LD_LIBRARY_PATH', 'GTK_PATH', 'GIO_MODULE_DIR', + 'GDK_PIXBUF_MODULE_FILE', 'GTK_EXE_PREFIX', 'GTK_IM_MODULE_FILE', +) + +_DETAIL_LINES = 15 + +_ACTIVITY_INFO = ( + '[Activity]\n' + 'name = %(name)s\n' + 'bundle_id = org.sugarlabs.aod.RuntimeCheck\n' + 'icon = activity\n' + 'exec = sugar-activity3 activity.GeneratedActivity\n' + 'activity_version = 1\n' + 'license = MIT\n' +) + + +def _env_int(name, default): + try: + return int(os.environ.get(name, str(default))) + except ValueError: + return default + + +def run_runtime_check(source, name='Generated Activity', timeout=None): + """Return (ok, detail); never raises. + + ok is True when the activity started, survived event pumping, and + completed a Journal round-trip — or when the check cannot run here + (no display / disabled), in which case detail says why it was + skipped. + """ + if os.environ.get('AOD_RUNTIME_CHECK', 'on').lower() in ( + 'off', '0', 'no', 'false'): + return True, 'skipped: disabled' + if not (os.environ.get('DISPLAY') + or os.environ.get('WAYLAND_DISPLAY')): + return True, 'skipped: no display' + + if timeout is None: + timeout = _env_int('AOD_RUNTIME_CHECK_TIMEOUT', 25) + + project_dir = tempfile.mkdtemp(prefix='aod-runtime-check-') + try: + os.makedirs(os.path.join(project_dir, 'activity')) + with open(os.path.join(project_dir, 'activity.py'), 'w', + encoding='utf-8') as output: + output.write(source) + with open(os.path.join(project_dir, 'activity', + 'activity.info'), 'w', + encoding='utf-8') as output: + output.write(_ACTIVITY_INFO % {'name': name}) + + env = {key: value for key, value in os.environ.items() + if key not in _SANITIZED_VARS} + env['GDK_BACKEND'] = 'x11' + env['PYTHONPATH'] = _REPO_ROOT + + try: + completed = subprocess.run( + [sys.executable, _HARNESS, project_dir], + env=env, + capture_output=True, + text=True, + timeout=timeout, + ) + except subprocess.TimeoutExpired: + return False, ( + 'The activity took longer than %d seconds to start. ' + 'Remove blocking loops from __init__; drive animation ' + 'and game loops with GLib.timeout_add instead.' + % timeout) + + if completed.returncode == 0 and \ + 'RUNTIME-OK' in completed.stdout: + return True, 'passed' + return False, _failure_detail(completed, project_dir) + except Exception as error: + # The gate itself failing must never block generation. + return True, 'skipped: %s' % error + finally: + shutil.rmtree(project_dir, ignore_errors=True) + + +def _failure_detail(completed, project_dir): + text = (completed.stderr or '') + '\n' + (completed.stdout or '') + lines = [line for line in text.splitlines() if line.strip()] + tail = lines[-_DETAIL_LINES:] + detail = '\n'.join(tail).replace(project_dir, '') + return detail or ('runtime check exited with code %d' + % completed.returncode) diff --git a/src/jarabe/model/aodruntimeharness.py b/src/jarabe/model/aodruntimeharness.py new file mode 100644 index 0000000000..2e6580002c --- /dev/null +++ b/src/jarabe/model/aodruntimeharness.py @@ -0,0 +1,98 @@ +# Copyright (C) 2026 Sugar Labs +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Subprocess body for the generation runtime check. + +Runs a generated activity the same way the studio preview does: +instantiate it against the PreviewActivity stubs, pump the GTK loop, +and exercise the generated class's own Journal round-trip. Exits 0 +and prints RUNTIME-OK on success; any crash prints the traceback and +exits nonzero so the parent can feed it back to the model. +""" + +import logging +import os +import sys +import tempfile +import traceback + + +class _StartupProblems(logging.Handler): + """Collect WARNING+ records emitted while the activity starts. + + The preview runner deliberately degrades — salvaging a partial + canvas after an __init__ crash, stubbing failed imports — so + learners always see something. The gate must not accept degraded + code, and every degradation is logged as a warning, so warnings + during startup are failures here. + """ + + def __init__(self): + logging.Handler.__init__(self) + self.setFormatter(logging.Formatter('%(message)s')) + self.problems = [] + + def emit(self, record): + # Theme noise (missing stock icons etc.) also lands on the + # root logger; every degradation message from the preview + # runner mentions "preview", so key on that. + if record.levelno >= logging.WARNING \ + and 'preview' in record.getMessage().lower(): + self.problems.append(self.format(record)) + + +def main(project_dir): + import gi + gi.require_version('Gtk', '3.0') + from gi.repository import Gtk + + from jarabe.model.aodpreview import render_activity_preview + + problems = _StartupProblems() + logging.getLogger().addHandler(problems) + try: + instance, canvas, toolbar_ = render_activity_preview(project_dir) + finally: + logging.getLogger().removeHandler(problems) + if instance is None: + sys.stderr.write('Activity failed to start: %s\n' % canvas) + return 1 + if problems.problems: + sys.stderr.write( + 'Activity started only in degraded mode:\n%s\n' + % '\n\n'.join(problems.problems)) + return 1 + + for _ in range(30): + while Gtk.events_pending(): + Gtk.main_iteration_do(False) + + # The generated class overrides read_file/write_file; run them for + # real so broken Journal persistence fails the gate. + handle, journal_path = tempfile.mkstemp(prefix='aod-runtime-journal-') + os.close(handle) + try: + instance.write_file(journal_path) + instance.read_file(journal_path) + finally: + try: + os.remove(journal_path) + except OSError: + pass + + try: + instance.cleanup() + except Exception: + pass + + print('RUNTIME-OK') + return 0 + + +if __name__ == '__main__': + try: + sys.exit(main(sys.argv[1])) + except Exception: + traceback.print_exc() + sys.exit(1) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000000..022bf08fa7 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,18 @@ +# Copyright (C) 2026 Sugar Labs +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Shared test configuration. + +The runtime smoke gate spawns a GTK subprocess for every accepted +source, which would slow the whole suite and make unrelated pipeline +tests display-dependent; the critic round issues an extra +generate_text call that would confuse fake-provider call counters. +Keep both off by default; the tests that exercise them re-enable +them explicitly. +""" + +import os + +os.environ.setdefault('AOD_RUNTIME_CHECK', 'off') +os.environ.setdefault('AOD_CRITIC', 'off') diff --git a/tests/test_aodcritic.py b/tests/test_aodcritic.py new file mode 100644 index 0000000000..e07ca0f1f4 --- /dev/null +++ b/tests/test_aodcritic.py @@ -0,0 +1,162 @@ +# Copyright (C) 2026 Sugar Labs +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import os +import unittest +from unittest import mock + +from jarabe.model.aodcritic import run_critic_round +from jarabe.model.aodgenerator import enrich_plan +from jarabe.model.aodtemplates import render_activity_source +from jarabe.model.aodspec import ActivitySpec + +_CRITIC_ENV = {'AOD_CRITIC': 'on', 'AOD_RUNTIME_CHECK': 'off'} + + +class _CriticProvider: + name = 'critic-fake' + model = 'critic-1' + + def __init__(self, response): + self.response = response + self.calls = 0 + self.observed_prompts = [] + + def generate_text(self, system_prompt, user_prompt, timeout=120, + stream_callback=None): + self.calls += 1 + self.observed_prompts.append((system_prompt, user_prompt)) + return self.response + + +class _NoTextProvider: + name = 'plan-only' + model = 'plan-1' + + +def _spec_and_source(): + spec = ActivitySpec( + 'Critic Probe', + 'Make a fractions quiz.', + 'logic_math', + 'MIT', + ) + plan = enrich_plan(spec, { + 'template': 'quiz', + 'summary': 'Critic probe.', + 'learner_goal': 'Practice fractions.', + 'learner_steps': ['Try', 'Explain', 'Share'], + }) + return spec, plan, render_activity_source(spec, plan) + + +def _patch_block(search, replace): + return ( + '<<<<<<< SEARCH\n%s\n=======\n%s\n>>>>>>> REPLACE\n' + % (search, replace) + ) + + +class TestCriticRound(unittest.TestCase): + + def setUp(self): + self.spec, self.plan, self.source = _spec_and_source() + + def test_ok_reply_keeps_source(self): + provider = _CriticProvider('OK') + with mock.patch.dict(os.environ, _CRITIC_ENV): + result = run_critic_round( + provider, self.spec, self.plan, self.source) + self.assertEqual(self.source, result) + self.assertEqual('ok', self.plan['critic']) + self.assertEqual(1, provider.calls) + + def test_valid_patch_is_applied(self): + provider = _CriticProvider(_patch_block( + ' self.max_participants = 1', + ' self.max_participants = 1 # critic-touched', + )) + with mock.patch.dict(os.environ, _CRITIC_ENV): + result = run_critic_round( + provider, self.spec, self.plan, self.source) + self.assertIn('# critic-touched', result) + self.assertEqual('patched:1', self.plan['critic']) + + def test_garbage_reply_keeps_source(self): + provider = _CriticProvider('Sure! Here are my thoughts...') + with mock.patch.dict(os.environ, _CRITIC_ENV): + result = run_critic_round( + provider, self.spec, self.plan, self.source) + self.assertEqual(self.source, result) + self.assertEqual('skipped', self.plan['critic']) + + def test_fullregen_is_refused(self): + provider = _CriticProvider('FULLREGEN') + with mock.patch.dict(os.environ, _CRITIC_ENV): + result = run_critic_round( + provider, self.spec, self.plan, self.source) + self.assertEqual(self.source, result) + self.assertEqual('skipped', self.plan['critic']) + + def test_unmatched_patch_keeps_source(self): + provider = _CriticProvider(_patch_block( + 'this line does not exist anywhere', + 'replacement', + )) + with mock.patch.dict(os.environ, _CRITIC_ENV): + result = run_critic_round( + provider, self.spec, self.plan, self.source) + self.assertEqual(self.source, result) + self.assertEqual('skipped', self.plan['critic']) + + def test_validation_breaking_patch_keeps_source(self): + provider = _CriticProvider(_patch_block( + 'class GeneratedActivity(activity.Activity):', + 'class GeneratedActivity(object):', + )) + with mock.patch.dict(os.environ, _CRITIC_ENV): + result = run_critic_round( + provider, self.spec, self.plan, self.source) + self.assertEqual(self.source, result) + self.assertEqual('skipped', self.plan['critic']) + + def test_provider_error_keeps_source(self): + provider = _CriticProvider('OK') + provider.generate_text = mock.Mock( + side_effect=RuntimeError('critic offline')) + with mock.patch.dict(os.environ, _CRITIC_ENV): + result = run_critic_round( + provider, self.spec, self.plan, self.source) + self.assertEqual(self.source, result) + self.assertEqual('skipped', self.plan['critic']) + + def test_disabled_by_env_skips_call(self): + provider = _CriticProvider('OK') + env = dict(_CRITIC_ENV, AOD_CRITIC='off') + with mock.patch.dict(os.environ, env): + result = run_critic_round( + provider, self.spec, self.plan, self.source) + self.assertEqual(self.source, result) + self.assertEqual('skipped', self.plan['critic']) + self.assertEqual(0, provider.calls) + + def test_provider_without_generate_text_skips(self): + with mock.patch.dict(os.environ, _CRITIC_ENV): + result = run_critic_round( + _NoTextProvider(), self.spec, self.plan, self.source) + self.assertEqual(self.source, result) + self.assertEqual('skipped', self.plan['critic']) + + def test_warnings_appear_in_prompt(self): + provider = _CriticProvider('OK') + with mock.patch.dict(os.environ, _CRITIC_ENV): + run_critic_round( + provider, self.spec, self.plan, self.source, + warnings=['Score is never shown to the learner.']) + _system, user = provider.observed_prompts[0] + self.assertIn('Score is never shown to the learner.', user) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_aodicons.py b/tests/test_aodicons.py new file mode 100644 index 0000000000..2330b0966a --- /dev/null +++ b/tests/test_aodicons.py @@ -0,0 +1,48 @@ +# Copyright (C) 2026 Sugar Labs +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import unittest + +from jarabe.model.aodicons import render_activity_icon + + +class TestActivityIcons(unittest.TestCase): + + def test_icon_uses_sugar_color_entities(self): + svg = render_activity_icon({'name': 'Quiz Fun', 'template': 'quiz'}) + self.assertIn(' Date: Mon, 6 Jul 2026 06:22:45 +0530 Subject: [PATCH 28/28] Let the model draw each activity's icon Port of the studio change: after code is accepted, one small generate_text call asks the model for a 55x55 SVG on Sugar's &stroke_color;/&fill_color; entities, so the icon shows the activity's own metaphor and still recolors to the learner's XO colors. Replies are strictly sanitized (no scripts, images, text, gradients, event attributes, or external references; entity header re-applied; XML parse check); anything doubtful falls back to the deterministic glyph. Outcome recorded under plan['icon_source']; disable with AOD_AI_ICON=off. --- src/jarabe/model/aodgenerator.py | 13 ++- src/jarabe/model/aodicons.py | 145 +++++++++++++++++++++++++++++-- src/jarabe/model/aodpipeline.py | 12 +++ tests/conftest.py | 1 + tests/test_aodicons.py | 121 ++++++++++++++++++++++++++ tests/test_aodpipeline.py | 58 +++++++++++++ 6 files changed, 340 insertions(+), 10 deletions(-) diff --git a/src/jarabe/model/aodgenerator.py b/src/jarabe/model/aodgenerator.py index 9e6b4c1cd9..94697f02ce 100644 --- a/src/jarabe/model/aodgenerator.py +++ b/src/jarabe/model/aodgenerator.py @@ -556,7 +556,9 @@ def normalize_plan(spec, plan): 'original_prompt', 'enhanced_prompt', 'runtime_check', - 'critic'): + 'critic', + 'icon_source', + 'icon_svg'): value = plan.get(field) if isinstance(value, str) and value: normalized[field] = value @@ -1064,8 +1066,15 @@ def _new_project_path(output_root, name): def _activity_icon(plan): - """Per-activity icon, falling back to the classic checkmark.""" + """Per-activity icon: the model's own drawing when the plan + carries one, else the deterministic glyph, else the checkmark.""" try: + icon_svg = plan.get('icon_svg') + if icon_svg: + from jarabe.model.aodicons import sanitize_icon_svg + safe = sanitize_icon_svg(icon_svg) + if safe: + return safe from jarabe.model.aodicons import render_activity_icon return render_activity_icon(plan) except Exception: diff --git a/src/jarabe/model/aodicons.py b/src/jarabe/model/aodicons.py index d90e1a23ec..0b85d2ce40 100644 --- a/src/jarabe/model/aodicons.py +++ b/src/jarabe/model/aodicons.py @@ -2,17 +2,21 @@ # # SPDX-License-Identifier: GPL-3.0-or-later -"""Deterministic per-activity icons. - -Every generated activity used to ship the same checkmark. This module -renders a small Sugar-style SVG icon whose glyph follows the activity's -template/category and whose accent varies with the activity name, so -the home ring shows a distinct, colorizable icon per activity. Icons -use Sugar's ``&stroke_color;``/``&fill_color;`` entities so they take -the learner's XO colors wherever Sugar recolors icons. +"""Per-activity icons. + +The model that wrote the activity also draws its icon: one small +``generate_text`` call returns a 55x55 Sugar-style SVG built on +Sugar's ``&stroke_color;``/``&fill_color;`` entities, so every icon +is specific to the idea ("space racer" gets a rocket, not a category +glyph) and still recolors to the learner's XO colors. The reply is +strictly sanitized; anything doubtful falls back to the deterministic +template/category glyph below, which never fails. """ import hashlib +import logging +import os +import re _HEADER = ( '\n' @@ -161,3 +165,128 @@ def render_activity_icon(plan): 'accent_x': 46, 'accent_y': 8, } + + +_MAX_ICON_CHARS = 6000 + +_FORBIDDEN_MARKUP = ( + '\n' + '- Draw with Sugar\'s color entities: stroke="&stroke_color;" ' + 'for outlines, fill="&fill_color;" for filled shapes, and ' + 'fill="&stroke_color;" for small solid accents. Never use ' + 'literal colors.\n' + '- Bold and simple so it reads at small size: 2-6 shapes, ' + 'stroke-width 3 to 4.5, stroke-linecap="round", ' + 'stroke-linejoin="round".\n' + '- Keep about 4 units of padding inside the edges.\n' + '- Use only these elements: path, rect, circle, ellipse, ' + 'line, polyline, polygon, g. No text, gradients, filters, ' + 'images, scripts, style blocks, or external references.\n' + '- Draw ONE clear visual metaphor for what the learner does ' + 'in this activity (a rocket for a space game, a flower for a ' + 'garden counter).\n' + ) + + +def build_icon_user_prompt(spec, plan): + parts = [ + 'Draw the icon for this Sugar activity.\n', + 'Name: %s\n' % getattr(spec, 'name', ''), + ] + if isinstance(plan, dict): + kind = plan.get('activity_kind') or '' + summary = plan.get('summary') or '' + if kind: + parts.append('What it is: %s\n' % kind) + if summary: + parts.append('Summary: %s\n' % summary) + parts.append('Learning area: %s\n' % getattr(spec, 'category', '')) + parts.append('\nReturn only the SVG.') + return ''.join(parts) + + +def sanitize_icon_svg(text): + """Return a safe, colorizable Sugar icon SVG, or None. + + Accepts raw model output (possibly fenced or wrapped in prose), + extracts the element, rejects anything scriptable or + externally referencing, requires Sugar's color entities and the + 55x55 viewBox, and re-heads the document with the canonical + entity declaration so the icon parses everywhere Sugar loads it. + """ + if not isinstance(text, str): + return None + + start = text.find('', start) + if start < 0 or end < 0: + return None + svg = text[start:end + len('')] + + if len(svg) > _MAX_ICON_CHARS: + return None + lowered = svg.lower() + if any(marker in lowered for marker in _FORBIDDEN_MARKUP): + return None + if _EVENT_ATTR.search(svg) or _EXTERNAL_REF.search(svg): + return None + if 'viewBox="0 0 55 55"' not in svg: + return None + if '&stroke_color;' not in svg: + return None + + candidate = _HEADER + svg + '\n' + try: + from xml.dom import minidom + document = minidom.parseString(candidate) + if document.documentElement.tagName != 'svg': + return None + except Exception: + return None + return candidate + + +def request_icon_svg(provider, spec, plan): + """Ask the model to draw this activity's icon; never raises. + + Returns a sanitized SVG string, or None when the feature is off, + the provider cannot draw, or the reply fails sanitization — the + caller then falls back to render_activity_icon(). + """ + if os.environ.get('AOD_AI_ICON', 'on').lower() in ( + 'off', '0', 'no', 'false'): + return None + generate_text = getattr(provider, 'generate_text', None) + if not callable(generate_text): + return None + + try: + response = generate_text( + build_icon_system_prompt(), + build_icon_user_prompt(spec, plan), + ) + except Exception as error: + logging.warning('Icon drawing call failed: %s', error) + return None + + icon = sanitize_icon_svg(response) + if icon is None: + logging.warning('Model icon failed sanitization; using fallback') + return icon diff --git a/src/jarabe/model/aodpipeline.py b/src/jarabe/model/aodpipeline.py index dc053f32ed..18b51dd428 100644 --- a/src/jarabe/model/aodpipeline.py +++ b/src/jarabe/model/aodpipeline.py @@ -21,6 +21,7 @@ from jarabe.model.aodgenerator import normalize_plan from jarabe.model.aodgenerator import package_project from jarabe.model.aodgenerator import read_project_files +from jarabe.model.aodicons import request_icon_svg from jarabe.model.aodllm import ProviderError from jarabe.model.aodllm import get_configured_provider from jarabe.model.aodprompts import build_system_prompt @@ -269,6 +270,17 @@ def generate_activity(spec, output_root=None, provider=None, 'using template renderer.' ) + if selected_provider is not None and provider_used != 'local' \ + and not plan.get('icon_svg'): + progress.report('generating', 0.72, + 'Drawing an icon for your activity...') + icon_svg = request_icon_svg(selected_provider, spec, plan) + if icon_svg: + plan['icon_svg'] = icon_svg + plan['icon_source'] = 'ai' + else: + plan['icon_source'] = 'generated' + progress.report('generating', 0.60, 'Expanding the plan into activity screens') result = create_prototype_activity( diff --git a/tests/conftest.py b/tests/conftest.py index 022bf08fa7..dfa729a397 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,3 +16,4 @@ os.environ.setdefault('AOD_RUNTIME_CHECK', 'off') os.environ.setdefault('AOD_CRITIC', 'off') +os.environ.setdefault('AOD_AI_ICON', 'off') diff --git a/tests/test_aodicons.py b/tests/test_aodicons.py index 2330b0966a..b4421ac4a6 100644 --- a/tests/test_aodicons.py +++ b/tests/test_aodicons.py @@ -2,9 +2,43 @@ # # SPDX-License-Identifier: GPL-3.0-or-later +import os import unittest +from unittest import mock from jarabe.model.aodicons import render_activity_icon +from jarabe.model.aodicons import request_icon_svg +from jarabe.model.aodicons import sanitize_icon_svg +from jarabe.model.aodspec import ActivitySpec + +_GOOD_SVG = ( + '\n' + ' \n' + ' \n' + '' +) + + +class _IconProvider: + name = 'icon-fake' + model = 'icon-1' + + def __init__(self, response): + self.response = response + self.calls = 0 + + def generate_text(self, system_prompt, user_prompt, timeout=120, + stream_callback=None): + self.calls += 1 + return self.response + + +def _spec(): + return ActivitySpec('Space Racer', 'Make a space racing game.', + 'games', 'MIT') class TestActivityIcons(unittest.TestCase): @@ -44,5 +78,92 @@ def test_malformed_plan_falls_back(self): self.assertIn('&stroke_color;', svg) +class TestSanitizeIconSvg(unittest.TestCase): + + def test_accepts_clean_svg_and_adds_entity_header(self): + result = sanitize_icon_svg(_GOOD_SVG) + self.assertIsNotNone(result) + self.assertIn('', '') + self.assertIsNone(sanitize_icon_svg(bad)) + + def test_rejects_event_handlers(self): + bad = _GOOD_SVG.replace('')) + + +class TestRequestIconSvg(unittest.TestCase): + + def setUp(self): + self.env = {'AOD_AI_ICON': 'on'} + + def test_good_reply_returns_sanitized_icon(self): + provider = _IconProvider(_GOOD_SVG) + with mock.patch.dict(os.environ, self.env): + icon = request_icon_svg(provider, _spec(), {'summary': 'x'}) + self.assertIsNotNone(icon) + self.assertIn('\n' + ' \n' + '' + ) + + def __init__(self, source): + _CodegenProvider.__init__(self, source) + self.icon_calls = 0 + + def generate_text(self, system_prompt, user_prompt, timeout=120, + stream_callback=None): + if 'draw icons for Sugar' not in system_prompt: + raise AssertionError('Expected the icon system prompt') + self.icon_calls += 1 + return self.ICON_SVG + + class _FailingCodegenProvider(_FakeProvider): def generate_activity_source(self, system_prompt, user_prompt, @@ -532,6 +556,40 @@ def test_critic_round_runs_after_accepted_source(self): self.assertEqual('ok', result.plan['critic']) self.assertEqual('provider', result.plan['code_source']) + def test_ai_icon_is_used_when_provider_draws_one(self): + provider = _IconDrawingProvider(_valid_activity_source(self.spec)) + with mock.patch.dict(os.environ, {'AOD_AI_ICON': 'on'}): + result = generate_activity( + self.spec, + self.output_root, + provider=provider, + enhance=False, + ) + self.assertEqual(1, provider.icon_calls) + self.assertEqual('ai', result.plan['icon_source']) + icon_path = os.path.join(result.project_path, 'activity', + 'activity.svg') + with open(icon_path, encoding='utf-8') as icon_file: + svg = icon_file.read() + self.assertIn('M27 8 L35 30', svg) + self.assertIn('