diff --git a/.flake8 b/.flake8 index 0146a5f3f..918e77f6d 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/AOD_TEST_README.md b/AOD_TEST_README.md new file mode 100644 index 000000000..baa325b1d --- /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 000000000..3cddd381c --- /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 000000000..462775f16 --- /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 000000000..9774eb960 --- /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 000000000..fc305f6f6 --- /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 40629d5ed..27bd09d86 100644 --- a/src/jarabe/desktop/homebox.py +++ b/src/jarabe/desktop/homebox.py @@ -13,11 +13,30 @@ # 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 _ + +import cairo 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 +51,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 +70,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 +79,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 +140,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 +164,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 +179,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 +199,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 +209,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 +265,8937 @@ 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 = { + 'template': 'logic_math', + 'age_band': 'all', + 'collab': 'solo', + 'planner': 'rag', + 'policy': 'creative', + 'validate': 'on', + 'enhance': 'on', + 'provider': 'default', + 'license': 'mit', + 'code_size': 'standard', + } + 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._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 + 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_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 + 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._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 + 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_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 + 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 + self._sidebar_refine_status_label = None + 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 + self._studio_right_panel = None + 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._aod_original_prompt = '' + 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(10)) + container.set_size_request(style.zoom(1280), -1) + + title = Gtk.Label() + 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, style.zoom(6)) + title.show() + + 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() + + 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), -1) + 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_scroll = Gtk.ScrolledWindow() + prompt_scroll.set_policy(Gtk.PolicyType.NEVER, + Gtk.PolicyType.AUTOMATIC) + 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() + + 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(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.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(96)) + 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) + 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(8)) + 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.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() + + 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() + + 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, + 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() + + 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') + 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 + 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.set_valign(Gtk.Align.CENTER) + code_size_combo.connect('changed', self.__code_size_combo_changed_cb) + bottom_row.pack_end(code_size_combo, False, False, 0) + code_size_combo.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() + + 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 __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' + 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') + label.set_halign(Gtk.Align.CENTER) + label.show() + return label + + def _create_provider_selector(self): + selector = Gtk.VBox(spacing=style.zoom(9)) + selector.set_border_width(style.zoom(14)) + selector.set_size_request(style.zoom(340), -1) + + 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 + 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(-1, style.zoom(40)) + combo.get_style_context().add_class('create-ai-provider-combo') + combo.connect('changed', self.__provider_combo_changed_cb) + 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(_('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(-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) + 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.connect('clicked', self.__provider_paste_clicked_cb) + key_row.pack_start(paste_button, False, False, 0) + paste_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 key')) + self._provider_remove_button = remove_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) + 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() + + # 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) + 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-provider-status') + status.set_xalign(0) + status.set_line_wrap(True) + status.set_max_width_chars(46) + 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 _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) + if title: + 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_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() + + 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) + 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(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') + 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_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) + + 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.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) + 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() + + 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) + + 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() + 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_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() + 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() + + 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( + _('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) + 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): + self._detach_live_edit_handlers() + if self._preview_content_box is None: + return + 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() + except Exception: + pass + self._live_preview_activity = 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_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 = [] + + 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): + 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') + title.set_justify(Gtk.Justification.CENTER) + self._preview_content_box.pack_start(title, False, False, 0) + title.show() + + note = Gtk.Label( + _('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() + + 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 + 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() + + 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( + steps_box, False, False, 0) + steps_box.show() + + step_defs = [ + (_('Think'), _('Reading your prompt and planning the activity'), + 'emblem-question'), + (_('Gather'), _('Finding real Sugar activity examples for context'), + 'system-search'), + (_('Write'), _('Writing the Python code for your activity'), + 'edit-description'), + (_('Check'), _('Testing the code and preparing files'), + 'dialog-ok'), + (_('Ready'), _('Packaging your installable activity bundle'), + 'package_settings'), + ] + 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: + 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) + + 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') + + 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. 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' + '# 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 once the model finishes writing it.\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 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 - generation failed' + ) % { + 'provider': provider, + } + else: + activity_summary = _( + 'Draft activity.py returned by the model. Sugar is ' + 'assembling the project.') + activity_meta = _( + 'Draft source %(provider)s - assembling - %(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 progress 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 generation completes.'), + '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 generation.\n'), + }, + 'activity_dir': { + 'title': _('activity/'), + 'summary': _('Sugar bundle metadata will be assembled after ' + 'the code is generated.'), + '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 ' + 'generation.'), + 'meta': common_meta, + 'language': 'python', + 'code': _('from sugar3.activity import bundlebuilder\n\n' + '# written after activity.py is generated\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 and provider details.'), + '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( + _('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) + 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_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') + panel.set_size_request(style.zoom(260), -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) + + 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() + + 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.'), + _('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: + self._sidebar_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-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 label { + color: #333333; + } + 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; + 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-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; + 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; + transition: background-color 250ms ease-out, + border-color 250ms ease-out; + } + .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-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: 8px; + border: 1px solid %(studio_edge)s; + background-image: none; + background-color: %(studio_surface)s; + color: %(toolbar)s; + padding: 0; + 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 2px 4px rgba(0, 0, 0, 0.18); + } + 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: 14px; + border: 1px solid #e2e2e2; + background-color: %(studio_surface)s; + 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: transparent; + border: 0; + border-radius: 0; + font-size: 13px; + } + textview.create-ai-textview text { + color: %(black)s; + background-color: transparent; + } + textview.create-ai-textview:focus { + border: 0; + box-shadow: none; + background-color: transparent; + } + .create-ai-prompt-divider { + background-color: #ebebeb; + min-height: 1px; + } + .create-ai-prompt-actions { + border-radius: 0 0 13px 13px; + border: 0; + background-color: transparent; + } + .create-ai-plus { + border-radius: 999px; + border: 1px solid #d4d4d4; + background-image: none; + background-color: #f5f5f5; + color: %(studio_lavender_text)s; + padding: 0 4px; + 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 %(toolbar)s; + background-image: none; + background-color: %(toolbar)s; + color: %(white)s; + padding: 0; + min-width: 34px; + min-height: 34px; + } + .create-ai-send label { + color: %(white)s; + } + .create-ai-send:hover { + background-color: %(black)s; + border-color: %(black)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; + } + .live-edit-selected { + 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; + 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('') + + 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('') + + 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('') + + 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, 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._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.""" + 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 = [] + self._live_edit_targets = [] + + 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: + # 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( + 'button-press-event', + self.__live_edit_widget_press_cb, + desc, + ) + self._live_edit_handler_ids.append((widget, hid)) + except Exception: + pass + 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) + 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'): + 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): + """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 + from sugar3.graphics.toolbarbox import ToolbarBox + if isinstance(widget, (Gtk.Toolbar, 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 + # 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( + 'live-edit-selected') + 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 + + 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 + + def _update_planner_hint(self): + if self._planner_hint is None: + return + + provider = self._get_provider_label( + self._selected_options['provider']) + self._planner_hint.set_text( + _('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: + return + + provider_name = self._selected_options['provider'] + cloud_provider = provider_name in ( + 'freemodel', 'openrouter', 'gemini', 'openai', 'deepseek', + 'qwen', 'moonshot', 'opencode', 'opencode-go', 'claude') + + self._provider_key_entry.set_sensitive(cloud_provider) + self._provider_paste_button.set_sensitive(cloud_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_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 + +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 '' + + text = output.decode('utf-8', 'replace') + if clean: + return self._clean_pasted_api_key(text) + return text + + 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': _('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.get(self._selected_options['template'], + 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, and the configured model.') + % 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._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() + 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() + 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...'), + _('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...'), + _('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): + 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: + 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_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( + _('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) + 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): + try: + if self._render_live_generated_activity_preview(result): + return + except Exception: + logging.exception('Could not embed live activity preview') + + self._clear_activity_preview() + + 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) + + 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() + + 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 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() + + 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 | + 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)) + 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._live_preview_canvas = canvas + self._live_preview_activity = preview + 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): + 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) + 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, + 'enhancing': 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() + self._update_template_card_icons() + 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'), + 'science': _('Science'), + 'language': _('Language'), + 'tools_utils': _('Tools/utilities'), + 'games': _('Games'), + 'creation': _('Creation'), + 'default': _('Default'), + 'rag': _('RAG'), + 'validate': _('Validate'), + 'standard': _('Standard'), + 'local': _('Local'), + 'strict': _('Strict'), + 'on': _('On'), + 'off': _('Off'), + } + 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.get(value, 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']: + 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('') + 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() + + 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 _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)) + 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) + + 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() + 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 + + 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() + + 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=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, + 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': + 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 = ( + '%(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, + } + display_refinement = _('%(target)s: %(change)s') % { + 'target': target, + 'change': refinement, + } + 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...')) + 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) + 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' + '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)s\n\n' + 'Refinement request:\n%(refinement)s' + ) % { + 'original': original_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': self._source_context_for_refinement(result), + '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', ''), + code_size=self._selected_options.get('code_size', 'standard'), + ) + + 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) <= 18000: + return prompt + keep_head = prompt[:8800].rstrip() + keep_tail = prompt[-8800:].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._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 = (planner != 'direct' + and 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 + 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, + 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')) + + self._enhanced_prompt_announced = False + try: + job = service.submit_activity( + 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 ''), + 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') + self._generation_failed_cb(str(error)) + return + + 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 + + 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) + 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) + 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._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 == '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.') % { + '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 assembling the activity ' + '- %(percent)d%%') % {'percent': percent} + ) + elif stage == 'packaging': + self._provider_status_label.set_text( + _('Activity generated. 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) + 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( + _('Generated. Type another prompt here to refine ' + 'this activity.')) + self._update_sidebar_challenges(result, plan) + return False + + 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', '') + 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 [] + + msgs = [] + + # Bubble 1 — short opener with name + kind + kind_parts = [p for p in (activity_kind, template) if p] + if kind_parts: + 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.""" + 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: + 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_chat_entry_sensitive(True) + 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 __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._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')) + 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() + 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')) + 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): + self._sidebar_visible = not self._sidebar_visible + if self._sidebar_toggle_button is not None: + self._sidebar_toggle_button.set_label( + _('◀ Sidebar') if self._sidebar_visible else _('▶ Sidebar')) + if self._sidebar_revealer is not None: + self._sidebar_revealer.set_reveal_child(self._sidebar_visible) + + 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) + 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: + 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 _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: + 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 __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 + + 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 + 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') + 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') + + 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 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: + 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() + self._set_live_edit_status( + _('Describe the preview change first.')) + return + + self._live_edit_entry.set_text('') + if self._generation_result is None: + self._set_live_edit_status( + _('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 ee3739930..3be2dc396 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 5a2ddb422..8573c184a 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 db1605882..57056fc1b 100644 --- a/src/jarabe/model/Makefile.am +++ b/src/jarabe/model/Makefile.am @@ -3,6 +3,24 @@ sugardir = $(pythondir)/jarabe/model sugar_PYTHON = \ adhoc.py \ __init__.py \ + aodcodegen.py \ + aodcredentials.py \ + aodflatpak.py \ + aodgenerator.py \ + aodjobs.py \ + aodlicenses.py \ + aodllm.py \ + aodpipeline.py \ + aodpreview.py \ + aodprompts.py \ + aodqueue.py \ + aodrag.py \ + aodrefine.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 000000000..b4be25159 --- /dev/null +++ b/src/jarabe/model/aodcodegen.py @@ -0,0 +1,429 @@ +# 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 +from jarabe.model.aodvalidator import _module_available + + +_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 _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 ( + '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 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' + ' - 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' + '%(rendering_guidance)s' + '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' + '- 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' + '- 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' + '\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_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-' + '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' + '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)), + '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']), + } + + +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 '' + 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 (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: + 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 + 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 '```' 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): + blocks = [] + for index, document in enumerate(references[:1], 1): + text = ' '.join(getattr(document, 'text', '').split()) + title = getattr(document, 'title', 'Reference') + blocks.append( + 'Reference %d - %s:\n%s' % (index, title, text[:4000]) + ) + return '\n\n'.join(blocks) diff --git a/src/jarabe/model/aodcredentials.py b/src/jarabe/model/aodcredentials.py new file mode 100644 index 000000000..bfed58679 --- /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/aodcritic.py b/src/jarabe/model/aodcritic.py new file mode 100644 index 000000000..ded1dc8bf --- /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/aodenhance.py b/src/jarabe/model/aodenhance.py new file mode 100644 index 000000000..5251ed718 --- /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/aodflatpak.py b/src/jarabe/model/aodflatpak.py new file mode 100644 index 000000000..c0a8b6e6c --- /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 new file mode 100644 index 000000000..94697f02c --- /dev/null +++ b/src/jarabe/model/aodgenerator.py @@ -0,0 +1,1093 @@ +# 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', + 'science': 'utility', + 'language': 'narrative', + 'tools_utils': 'utility', + 'games': 'grid', + 'creation': 'narrative', + } + return defaults.get(spec.category, 'narrative') + + +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', + 'refine_method', + 'original_prompt', + 'enhanced_prompt', + 'runtime_check', + 'critic', + 'icon_source', + 'icon_svg'): + 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 + + 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(plan), + } + + 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 + + +_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' + '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', + ], + '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', + ], + '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() +""" + + +def _activity_icon(plan): + """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: + return _ACTIVITY_ICON + + +_ACTIVITY_ICON = """ + + + + +""" diff --git a/src/jarabe/model/aodicons.py b/src/jarabe/model/aodicons.py new file mode 100644 index 000000000..0b85d2ce4 --- /dev/null +++ b/src/jarabe/model/aodicons.py @@ -0,0 +1,292 @@ +# Copyright (C) 2026 Sugar Labs +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""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' + '\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, + } + + +_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/aodjobs.py b/src/jarabe/model/aodjobs.py new file mode 100644 index 000000000..6cdb65faf --- /dev/null +++ b/src/jarabe/model/aodjobs.py @@ -0,0 +1,252 @@ +# 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 + validate_code: bool = True + output_root: str = '' + 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 + 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, + validate_code=True, output_root=None, session_id='', + parent_revision_id='', user_prompt='', enhance=True): + 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 '', + user_prompt=user_prompt or spec.prompt, + enhance=bool(enhance), + ) + + @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), + 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', ''), + 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), + 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, + 'validate_code': self.validate_code, + 'output_root': self.output_root, + '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, + '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): + if self.is_terminal(): + return + 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): + if self.is_terminal(): + return + 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): + if self.is_terminal(): + return + 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): + if self.is_terminal(): + return + 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 000000000..b039b6474 --- /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 000000000..bc452da3f --- /dev/null +++ b/src/jarabe/model/aodllm.py @@ -0,0 +1,1394 @@ +# Copyright (C) 2026 Sugar Labs +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import json +import logging +import os +import time +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 + + +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', 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', 300) +_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 +# 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_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 +# 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' + + +# 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 = '' + label = 'Provider' + + 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, + 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) + ) + + +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_text(self, system_prompt, user_prompt, + timeout=_PROVIDER_CODEGEN_TIMEOUT, + stream_callback=None): + 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, + ) + + def generate_activity_source(self, system_prompt, user_prompt, + timeout=_PROVIDER_CODEGEN_TIMEOUT, + 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=tokens, + stream_callback=stream_callback, + ) + else: + text = self._generate_content( + system_prompt, user_prompt, timeout, + max_output_tokens=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 _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] + 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.' + ) + if not text: + raise ProviderError('Gemini returned an empty response.') + 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 _urlopen_with_retry( + request, timeout, 'Gemini') 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: + logging.debug( + 'stream_callback raised; ignoring', + exc_info=True) + 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) + + if not accumulated: + raise ProviderError('Gemini streaming returned an empty response.') + return accumulated + + +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_text(self, system_prompt, user_prompt, + timeout=_PROVIDER_CODEGEN_TIMEOUT, + 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=tokens, + stream_callback=stream_callback, + ) + return self._generate_text( + system_prompt, user_prompt, timeout, + max_tokens=tokens, json_response=False, + ) + + def generate_activity_source(self, system_prompt, user_prompt, + timeout=_PROVIDER_CODEGEN_TIMEOUT, + 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) + + 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 _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 _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') + 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) + ) + 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): + 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': 'anthropic/claude-opus-4.8', + '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 = {} + # For codegen (non-JSON) calls on OpenRouter, set minimal reasoning. + # 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', + _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 + # 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 + 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_text(self, system_prompt, user_prompt, + timeout=_PROVIDER_CODEGEN_TIMEOUT, + stream_callback=None, max_output_tokens=None): + text = self._generate_responses_text( + system_prompt, user_prompt, timeout, + max_output_tokens=( + max_output_tokens or _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, 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) + + 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_text(self, system_prompt, user_prompt, + timeout=_PROVIDER_CODEGEN_TIMEOUT, + stream_callback=None, max_output_tokens=None): + text = self._generate_text( + system_prompt, user_prompt, timeout, + max_tokens=max_output_tokens or _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, 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) + + 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_text(self, system_prompt, user_prompt, + timeout=_PROVIDER_CODEGEN_TIMEOUT, + stream_callback=None, max_output_tokens=None): + text = self._generate_text( + system_prompt, user_prompt, timeout, + num_predict=max_output_tokens or _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, 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) + + 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 ' + 'anthropic/claude-opus-4.8.' + % (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 _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] + 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', + 'anthropic/claude-opus-4.8', + ), + '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 000000000..18b51dd42 --- /dev/null +++ b/src/jarabe/model/aodpipeline.py @@ -0,0 +1,710 @@ +# Copyright (C) 2026 Sugar Labs +# +# SPDX-License-Identifier: GPL-3.0-or-later + +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.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 +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 +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 +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.aodruntime import run_runtime_check +from jarabe.model.aodvalidator import validate_activity_source_for_request + + +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, + validate_code=True, + progress_cb=None, pace=False, package_bundle=True, + template_fallback=False, enhance=True): + """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 + 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) + + 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 + 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') + 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( + plan_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 prompt_was_enhanced: + plan['original_prompt'] = original_prompt + plan['enhanced_prompt'] = spec.prompt + 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, + validate_code=validate_code, + ) + ) + plan['codegen_attempts'] = code_attempts + if activity_source: + 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 + 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.' + ) + + 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( + spec, + output_root, + plan=plan, + package_bundle=False, + activity_source=activity_source, + ) + result.provider = provider_used + result.model = model_used + + 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) + plan_file.write('\n') + + if package_bundle: + progress.report('packaging', 0.88, + 'Packaging the XO bundle') + package_generation_result(result) + + progress.report('ready', 1.0, 'Activity project is ready') + return result + + +def package_generation_result(result): + """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) + 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 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: + 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.15 + while time.time() < end_time: + time.sleep(0.05) + _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) + api_key = getattr(provider, '_api_key', '') + if api_key: + message = message.replace(api_key, '[redacted]') + 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_immediate( + '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 + + +_CODE_SIZE_TOKENS = { + 'compact': 6000, + 'standard': 14000, + 'full': None, +} + + +def _generate_activity_source_with_provider(provider, spec, plan, references, + progress, validate_code=True): + generate_source = getattr(provider, 'generate_activity_source', None) + if not callable(generate_source): + return None, '', 0 + + code_size = getattr(spec, 'code_size', 'standard') + max_output_tokens = _CODE_SIZE_TOKENS.get(code_size) + + system_prompt = build_codegen_system_prompt( + spec, plan, references, code_size=code_size) + user_prompt = build_codegen_user_prompt(spec, plan) + 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) + ) + 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 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: + 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: + 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: + time.sleep(min(2.0 ** (attempt - 1), 4.0)) + + return None, last_error, _CODEGEN_ATTEMPT_LIMIT + + +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, + 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. + + Tries the cheap SEARCH/REPLACE path first (~1k output tokens). If + 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(). + """ + 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') + + generate_text = getattr(selected_provider, 'generate_text', None) + if not callable(generate_text): + generate_text = None + + patched_source = None + refine_method = 'search_replace' + try: + 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, + '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( + 'generating', 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: + patched_source = patched + progress.report( + 'generating', 0.70, + 'Edits applied successfully') + + 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, + validate_code=validate_code, + ) + ) + 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 + + 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/aodpreview.py b/src/jarabe/model/aodpreview.py new file mode 100644 index 000000000..f5ace7ad7 --- /dev/null +++ b/src/jarabe/model/aodpreview.py @@ -0,0 +1,601 @@ +# 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 shutil +import tempfile +from gettext import gettext as _ + +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 _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. + + 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'] + self._activity_root = '' + + def __getattr__(self, name): + # Generated activities sometimes call self.some_method() or + # 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. 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() + + 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 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 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 + + 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. + + 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) + _install_preview_compatibility() + + 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]) + + # 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 + + +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') + + namespace = { + '__name__': 'aod_preview_module', + '__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: + 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 exec 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 + + # 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.__new__(activity_class) + if isinstance(instance, PreviewActivity): + PreviewActivity.__init__(instance) + except Exception as error: + 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 + + 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 or isinstance(canvas, _NoOpProxy): + return None, 'Activity did not call set_canvas()', None + + return instance, canvas, toolbar + + +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', + '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 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) + 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. + + 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. + + 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). + # Use a letter lookbehind so we do not double-replace + # PreviewActivity.__init__ (which already contains 'Activity'). + patched = re.sub( + r'(?= 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.' + ), + '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.' + ), + '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 000000000..bbee8b430 --- /dev/null +++ b/src/jarabe/model/aodqueue.py @@ -0,0 +1,76 @@ +# Copyright (C) 2026 Sugar Labs +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import logging +import queue +import threading + + +_MAX_QUEUED_JOBS = 64 + + +class AODJobQueue: + """Small local worker queue for Activity-on-Demand jobs.""" + + 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(maxsize=max_queued) + self._workers = [] + self._shutdown = threading.Event() + self._lock = threading.Lock() + + def submit(self, job): + self.start() + 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: + 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 000000000..9a3f7d03b --- /dev/null +++ b/src/jarabe/model/aodrag.py @@ -0,0 +1,496 @@ +# Copyright (C) 2026 Sugar Labs +# +# SPDX-License-Identifier: GPL-3.0-or-later + +from dataclasses import dataclass +import os +import re +import threading + + +DEFAULT_ACTIVITY_ROOTS = ( + '/usr/share/sugar/activities', + os.path.expanduser('~/Activities'), +) + +_corpus_cache = None +_corpus_lock = threading.Lock() +_corpus_mtime = 0.0 + + +@dataclass(frozen=True) +class RagDocument: + title: str + text: str + tags: tuple = () + source_path: str = '' + + +def build_corpus(activity_roots=None): + """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 + 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 _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) + 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. +- 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 = ( + 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'), + ), + 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 new file mode 100644 index 000000000..10751d1e1 --- /dev/null +++ b/src/jarabe/model/aodrefine.py @@ -0,0 +1,251 @@ +# 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.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 + + 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) + + # 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_nl + ) + + 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( + 'Malformed SEARCH/REPLACE: empty SEARCH section at ' + 'position %d.' % search_start + ) + + blocks.append((search_text, replace_text)) + pos = replace_nl + len(_NL_REPLACE) + + 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/aodruntime.py b/src/jarabe/model/aodruntime.py new file mode 100644 index 000000000..9820c40eb --- /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 000000000..2e6580002 --- /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/src/jarabe/model/aodservice.py b/src/jarabe/model/aodservice.py new file mode 100644 index 000000000..d14ab52f5 --- /dev/null +++ b/src/jarabe/model/aodservice.py @@ -0,0 +1,593 @@ +# Copyright (C) 2026 Sugar Labs +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import json +import logging +import os +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_FAILED +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, + validate_code=True, output_root=None, callback=None, + session_id='', parent_revision_id='', + user_prompt=None, enhance=True): + 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, + validate_code=validate_code, + output_root=output_root, + 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) + + 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.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 + + 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, + ) + status = AODMessage.create( + ROLE_ASSISTANT, + 'Generating Sugar activity...', + message_type=TYPE_STATUS, + job_id=job.job_id, + ) + self._session_store.append_messages(session_id, [message, 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) + 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, + validate_code=job.validate_code, + progress_cb=lambda stage, fraction, message, metadata=None: + self._pipeline_progress( + job, + stage, + fraction, + message, + metadata, + ), + pace=True, + package_bundle=False, + enhance=job.enhance, + ) + except JobCancelled: + self._mark_cancelled(job) + return + except Exception as error: + 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) + 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() + + if job.is_terminal(): + return + + 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, + validate_code=job.validate_code, + 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, + validate_code=job.validate_code, + ) + + 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 + enhanced = metadata.get('enhanced_prompt') + if isinstance(enhanced, str) and enhanced: + job.enhanced_prompt = enhanced + 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 + + 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 "%(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_revision_and_message( + job.session_id, revision, 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, + 'assembling': STATUS_PACKAGING, + '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 000000000..7aaf327c8 --- /dev/null +++ b/src/jarabe/model/aodsessions.py @@ -0,0 +1,272 @@ +# 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_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) + if session is None: + return None + session.revisions.append(revision) + session.active_revision_id = revision.revision_id + 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 + 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 000000000..f9e923c7e --- /dev/null +++ b/src/jarabe/model/aodspec.py @@ -0,0 +1,181 @@ +# 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', + 'science', + 'language', + 'tools_utils', + 'games', + 'creation', +) + +CATEGORY_FALLBACK = 'creation' + +CODE_SIZES = ('compact', 'standard', 'full') + +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 = '' + code_size: str = 'standard' + + 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.') + + if self.code_size not in CODE_SIZES: + errors.append('Unknown code size: %s' % self.code_size) + + return errors + + def normalized(self): + """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=name, + prompt=prompt, + category=self.category if self.category in CATEGORIES + else CATEGORY_FALLBACK, + license_id=self.license_id, + 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 if self.code_size in CODE_SIZES + else 'standard', + ) + + 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', ''), + code_size=data.get('code_size', 'standard'), + ) + + +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 000000000..e9e8cca0e --- /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 000000000..7fa64ec42 --- /dev/null +++ b/src/jarabe/model/aodvalidator.py @@ -0,0 +1,485 @@ +# Copyright (C) 2026 Sugar Labs +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import ast +import configparser +import importlib.util +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', + 'pygame', + 'random', + 'sugar3', + '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', + '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) + 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): + 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.') + + 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 + + +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() + + 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.' + ) + + 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)) + 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 000000000..9317cde39 --- /dev/null +++ b/start_sugar_xephyr.sh @@ -0,0 +1,76 @@ +#!/bin/bash +# Launch Sugar inside a fullscreen nested Xephyr display 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 fullscreen Xephyr on display ${X_DISPLAY}..." +setsid Xephyr "${X_DISPLAY}" -fullscreen -ac -br -noreset \ + > "${LOG_FILE}.xephyr" 2>&1 & +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}" +setsid dbus-launch --exit-with-session python3 -m jarabe.main \ + > "${LOG_FILE}" 2>&1 & +SUGAR_PID=$! + +echo "Xephyr PID: ${XEPHYR_PID}" +echo "Sugar PID: ${SUGAR_PID}" +echo "" +echo "Sugar is running fullscreen 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/conftest.py b/tests/conftest.py new file mode 100644 index 000000000..dfa729a39 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,19 @@ +# 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') +os.environ.setdefault('AOD_AI_ICON', 'off') diff --git a/tests/test_aodcredentials.py b/tests/test_aodcredentials.py new file mode 100644 index 000000000..257bfd2ca --- /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_aodcritic.py b/tests/test_aodcritic.py new file mode 100644 index 000000000..e07ca0f1f --- /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_aodenhance.py b/tests/test_aodenhance.py new file mode 100644 index 000000000..d3bc50ada --- /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_aodflatpak.py b/tests/test_aodflatpak.py new file mode 100644 index 000000000..2834e6f27 --- /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 new file mode 100644 index 000000000..5a03a9633 --- /dev/null +++ b/tests/test_aodgenerator.py @@ -0,0 +1,211 @@ +# 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'], + ) + + 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_aodicons.py b/tests/test_aodicons.py new file mode 100644 index 000000000..b4421ac4a --- /dev/null +++ b/tests/test_aodicons.py @@ -0,0 +1,169 @@ +# Copyright (C) 2026 Sugar Labs +# +# 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): + + def test_icon_uses_sugar_color_entities(self): + svg = render_activity_icon({'name': 'Quiz Fun', 'template': 'quiz'}) + 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, + timeout=90): + 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): + 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_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 = [] + + 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_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, + 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 test_critic_round_runs_after_accepted_source(self): + provider = _CriticOkProvider(_valid_activity_source(self.spec)) + with mock.patch.dict(os.environ, {'AOD_CRITIC': 'on'}): + result = generate_activity( + self.spec, + self.output_root, + provider=provider, + enhance=False, + ) + self.assertEqual(1, provider.critic_calls) + 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('>>>>>> 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() diff --git a/tests/test_aodruntime.py b/tests/test_aodruntime.py new file mode 100644 index 000000000..4edced9b0 --- /dev/null +++ b/tests/test_aodruntime.py @@ -0,0 +1,93 @@ +# Copyright (C) 2026 Sugar Labs +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import os +import unittest +from unittest import mock + +from jarabe.model.aodgenerator import enrich_plan +from jarabe.model.aodruntime import run_runtime_check +from jarabe.model.aodtemplates import render_activity_source +from jarabe.model.aodspec import ActivitySpec + +_HAVE_DISPLAY = bool( + os.environ.get('DISPLAY') or os.environ.get('WAYLAND_DISPLAY')) + + +def _template_source(): + spec = ActivitySpec( + 'Runtime Probe', + 'Make a fractions quiz.', + 'logic_math', + 'MIT', + ) + plan = enrich_plan(spec, { + 'template': 'quiz', + 'summary': 'Runtime check probe.', + 'learner_goal': 'Practice fractions.', + 'learner_steps': ['Try', 'Explain', 'Share'], + }) + return render_activity_source(spec, plan) + + +class TestRuntimeCheck(unittest.TestCase): + + def test_disabled_by_env_is_skipped(self): + with mock.patch.dict(os.environ, {'AOD_RUNTIME_CHECK': 'off'}): + ok, detail = run_runtime_check('raise SystemExit(1)\n') + self.assertTrue(ok) + self.assertEqual('skipped: disabled', detail) + + def test_no_display_is_skipped(self): + env = {key: value for key, value in os.environ.items() + if key not in ('DISPLAY', 'WAYLAND_DISPLAY')} + env['AOD_RUNTIME_CHECK'] = 'on' + with mock.patch.dict(os.environ, env, clear=True): + ok, detail = run_runtime_check('raise SystemExit(1)\n') + self.assertTrue(ok) + self.assertEqual('skipped: no display', detail) + + @unittest.skipUnless(_HAVE_DISPLAY, 'needs a display') + def test_valid_template_source_passes(self): + with mock.patch.dict(os.environ, {'AOD_RUNTIME_CHECK': 'on'}): + ok, detail = run_runtime_check( + _template_source(), 'Runtime Probe') + self.assertTrue(ok, detail) + self.assertEqual('passed', detail) + + @unittest.skipUnless(_HAVE_DISPLAY, 'needs a display') + def test_crash_in_init_fails_with_traceback(self): + source = _template_source() + crashing = source.replace( + 'self._build_canvas()', + 'self._build_canvas()\n' + ' raise RuntimeError("boom-at-runtime")', + 1, + ) + self.assertNotEqual(source, crashing) + with mock.patch.dict(os.environ, {'AOD_RUNTIME_CHECK': 'on'}): + ok, detail = run_runtime_check(crashing, 'Runtime Probe') + self.assertFalse(ok) + self.assertIn('boom-at-runtime', detail) + + @unittest.skipUnless(_HAVE_DISPLAY, 'needs a display') + def test_blocking_init_times_out(self): + source = _template_source() + blocking = source.replace( + 'self._build_canvas()', + 'self._build_canvas()\n' + ' import time as _time\n' + ' _time.sleep(120)', + 1, + ) + self.assertNotEqual(source, blocking) + with mock.patch.dict(os.environ, {'AOD_RUNTIME_CHECK': 'on'}): + ok, detail = run_runtime_check( + blocking, 'Runtime Probe', timeout=8) + self.assertFalse(ok) + self.assertIn('blocking loops', detail) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_aodservice.py b/tests/test_aodservice.py new file mode 100644 index 000000000..0134c9955 --- /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 000000000..dee95d79b --- /dev/null +++ b/tests/test_aodspec.py @@ -0,0 +1,76 @@ +# 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_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', + 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 000000000..7e9c4644a --- /dev/null +++ b/tests/test_aodvalidator.py @@ -0,0 +1,148 @@ +# Copyright (C) 2026 Sugar Labs +# +# 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 +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_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) + self.assertIn( + 'Generated source must define exactly one Activity subclass.', + 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', + '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)