From ee25a238c857caff95f733c522ecc6b2e892de8e Mon Sep 17 00:00:00 2001 From: Aran30 Date: Sat, 27 Jun 2026 14:27:57 +0200 Subject: [PATCH 1/9] added llm based GUI personalization function --- .../backend/routers/generation_router.py | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/besser/utilities/web_modeling_editor/backend/routers/generation_router.py b/besser/utilities/web_modeling_editor/backend/routers/generation_router.py index b93b8e074..039558685 100644 --- a/besser/utilities/web_modeling_editor/backend/routers/generation_router.py +++ b/besser/utilities/web_modeling_editor/backend/routers/generation_router.py @@ -228,6 +228,118 @@ async def recommend_agent_config_llm( raise GenerationError("Failed to generate LLM recommendation") from exc +@router.post("/personalize-gui-page") +@handle_endpoint_errors("personalize_gui_page") +async def personalize_gui_page(payload: Dict[str, Any] = Body(...)): + """Personalize a GUI page (GrapesJS) for a user profile using an LLM. + + Open endpoint (no GitHub session required). The OpenAI API key is resolved + from the payload or the server's ``OPENAI_API_KEY`` environment variable. + + Request body: + guiPage: {"components": [...], "css": [...]} -- a GrapesJS page snapshot + userProfileModel: + pageName: str (optional) + model: str (optional OpenAI model id) + + Returns the same ``{components, css}`` shape adapted in style and content, + ready to be imported back into the editor as a page variant. + """ + gui_page = payload.get("guiPage") + if not isinstance(gui_page, dict): + raise ValidationError( + "guiPage is required and must be a JSON object with 'components' and 'css'" + ) + + user_profile_model = payload.get("userProfileModel") + if not isinstance(user_profile_model, dict): + raise ValidationError("userProfileModel is required and must be a JSON object") + + components = gui_page.get("components") + if not isinstance(components, list): + raise ValidationError("guiPage.components must be a list") + css = gui_page.get("css") + if not isinstance(css, list): + css = [] + + page_name = payload.get("pageName") if isinstance(payload.get("pageName"), str) else "page" + requested_model = payload.get("model") + llm_model = ( + requested_model + if isinstance(requested_model, str) and requested_model.strip() + else "gpt-5" + ) + + # Transform the user profile (UserDiagram) into its JSON object representation. + profile_document = _generate_user_profile_document(user_profile_model) + + system_prompt = ( + "You are a UI personalization assistant for a GrapesJS-based no-code GUI editor. " + "You receive a single GUI page as GrapesJS data: a 'components' array (the component " + "tree, each node carrying fields such as type, tagName, attributes, classes, components, " + "and text content) and a 'css' array (GrapesJS style-rule objects with 'selectors', " + "'style', and 'pageId'). You also receive a user profile. " + "Adapt the page to this specific user in terms of CONTENT (wording, tone, labels, " + "headings, copy) and STYLE (colors, spacing, font sizes, emphasis via the 'style' maps). " + "STRICT RULES: " + "1) Return ONLY a single JSON object with EXACTLY this shape: " + '{"components": [...], "css": [...]}. No markdown, no comments, no prose. ' + "2) Preserve the overall structure: keep each component's 'type', 'tagName', and " + "'attributes.id' unchanged, and do not add, remove, or reorder components. " + "3) You MAY change visible text content, attribute label values (placeholder, title, " + "alt, link text), class lists, and the 'style' objects in both components and css rules. " + "4) Keep every css rule's 'selectors' and 'pageId' values exactly as given. " + "5) The result must be valid JSON, parseable as-is, and remain a working GrapesJS page." + ) + user_prompt = ( + f'Personalize the GUI page named "{page_name}" for the following user.\n\n' + f"User profile document:\n{json.dumps(profile_document, ensure_ascii=False, indent=2)}\n\n" + "GUI page to adapt (GrapesJS):\n" + f"{json.dumps({'components': components, 'css': css}, ensure_ascii=False)}\n\n" + 'Return only the personalized JSON object {"components": [...], "css": [...]}.' + ) + + try: + response_text = await asyncio.to_thread( + call_openai_chat, + system_prompt, + user_prompt, + model=llm_model, + openai_api_key=extract_openai_api_key(payload if isinstance(payload, dict) else {}), + config=payload, + ) + parsed = extract_json_object(response_text) + personalized_components = parsed.get("components") + personalized_css = parsed.get("css", []) + if not isinstance(personalized_components, list): + raise GenerationError("LLM response did not include a valid 'components' list") + if not isinstance(personalized_css, list): + personalized_css = [] + + return { + "guiPage": { + "components": personalized_components, + "css": personalized_css, + }, + "source": "openai", + "model": llm_model, + "generatedAt": _utc_now_iso(), + } + except RuntimeError as runtime_error: + # Raised by call_openai_chat when no OpenAI API key is configured. + raise ValidationError(str(runtime_error)) from runtime_error + except ValueError as parse_error: + logger.exception("Failed to parse LLM personalization response") + raise GenerationError("Failed to parse LLM personalization response") from parse_error + except (ValidationError, GenerationError): + raise + except HTTPException: + raise + except Exception as exc: + logger.exception("Failed to personalize GUI page") + raise GenerationError("Failed to personalize GUI page") from exc + + @router.get("/agent-config-manual-mapping") @handle_endpoint_errors("get_agent_config_manual_mapping") async def get_agent_config_manual_mapping( From d43fded5e776c8d2171b86e93447a9b137f20a81 Mon Sep 17 00:00:00 2001 From: aran30 Date: Tue, 30 Jun 2026 16:36:01 +0200 Subject: [PATCH 2/9] improved the prompt for the automatic personalization of guis --- .../backend/routers/generation_router.py | 48 +++++++++++++++---- 1 file changed, 40 insertions(+), 8 deletions(-) diff --git a/besser/utilities/web_modeling_editor/backend/routers/generation_router.py b/besser/utilities/web_modeling_editor/backend/routers/generation_router.py index 039558685..f3a82461e 100644 --- a/besser/utilities/web_modeling_editor/backend/routers/generation_router.py +++ b/besser/utilities/web_modeling_editor/backend/routers/generation_router.py @@ -278,25 +278,57 @@ async def personalize_gui_page(payload: Dict[str, Any] = Body(...)): "You receive a single GUI page as GrapesJS data: a 'components' array (the component " "tree, each node carrying fields such as type, tagName, attributes, classes, components, " "and text content) and a 'css' array (GrapesJS style-rule objects with 'selectors', " - "'style', and 'pageId'). You also receive a user profile. " - "Adapt the page to this specific user in terms of CONTENT (wording, tone, labels, " - "headings, copy) and STYLE (colors, spacing, font sizes, emphasis via the 'style' maps). " - "STRICT RULES: " + "'style', and 'pageId'). You also receive a user profile.\n\n" + "Your job is NOT to redesign the page. It is to make the SMALLEST set of changes that a " + "SPECIFIC, CONCRETE attribute in the user profile actually requires — and nothing else. " + "The default outcome is to return the page UNCHANGED. Only deviate from the original when " + "an attribute in the profile creates a real, demonstrable accessibility or comprehension " + "need for THIS user.\n\n" + "DECISION PROCESS (apply silently, then output only the JSON):\n" + "1) For each attribute in the user profile, ask: 'Does this attribute imply a concrete UI " + "barrier on THIS page?' Most attributes do not. An attribute justifies a change only if " + "there is a direct, well-established causal link between it and a UI adaptation.\n" + "2) If an attribute has no such link, IGNORE it. Do not invent a need. Demographic, " + "cultural, religious, gender, name, or location attributes almost never justify visual or " + "textual changes on their own — leave the page as-is for them.\n" + "3) Only when a link exists, apply the minimal adaptation that resolves that specific need, " + "and touch only the components/styles relevant to it. Leave everything else identical.\n\n" + "EXAMPLES OF JUSTIFIED CHANGES (only act when the profile clearly states the trait):\n" + "- Low vision / visual impairment -> increase font-size, contrast, and spacing.\n" + "- Stated reading level below the page's wording (e.g. B1 English on advanced copy) -> " + "simplify the wording while keeping meaning; do NOT translate unless a different language " + "is explicitly the user's language.\n" + "- Color blindness -> avoid color as the sole signal / adjust conflicting color pairs.\n" + "- Motor impairment -> enlarge click targets (padding) on interactive elements.\n" + "- A stated preferred language different from the page's language -> translate the visible " + "text into that language.\n\n" + "EXAMPLES OF NON-JUSTIFIED (return unchanged for these): a user who reads English fluently " + "on an English page; religion, ethnicity, gender, age alone, job title, hobbies, or any " + "attribute with no direct UI consequence. NEVER map an irrelevant attribute to a styling " + "change (e.g. do not change font size because of someone's religion or nationality).\n\n" + "STRICT OUTPUT RULES: " "1) Return ONLY a single JSON object with EXACTLY this shape: " '{"components": [...], "css": [...]}. No markdown, no comments, no prose. ' "2) Preserve the overall structure: keep each component's 'type', 'tagName', and " "'attributes.id' unchanged, and do not add, remove, or reorder components. " "3) You MAY change visible text content, attribute label values (placeholder, title, " - "alt, link text), class lists, and the 'style' objects in both components and css rules. " + "alt, link text), class lists, and the 'style' objects in both components and css rules — " + "but ONLY for the specific needs you justified above. Every other value must be byte-for-byte " + "identical to the input. " "4) Keep every css rule's 'selectors' and 'pageId' values exactly as given. " - "5) The result must be valid JSON, parseable as-is, and remain a working GrapesJS page." + "5) If no profile attribute justifies any change, return the input page verbatim. " + "6) The result must be valid JSON, parseable as-is, and remain a working GrapesJS page." ) user_prompt = ( - f'Personalize the GUI page named "{page_name}" for the following user.\n\n' + f'Personalize the GUI page named "{page_name}" for the following user — but only if the ' + "profile actually warrants it.\n\n" f"User profile document:\n{json.dumps(profile_document, ensure_ascii=False, indent=2)}\n\n" "GUI page to adapt (GrapesJS):\n" f"{json.dumps({'components': components, 'css': css}, ensure_ascii=False)}\n\n" - 'Return only the personalized JSON object {"components": [...], "css": [...]}.' + "First, silently identify which (if any) profile attributes create a concrete UI need for " + "this user. Change ONLY what those needs require and leave every other part of the page " + "exactly as given. If nothing in the profile warrants a change, return the page unchanged. " + 'Return only the resulting JSON object {"components": [...], "css": [...]}.' ) try: From a8cc8b7304548881337ddc40ee0c83c975e238ba Mon Sep 17 00:00:00 2001 From: Aran30 Date: Wed, 1 Jul 2026 16:49:41 +0200 Subject: [PATCH 3/9] added the code generation of variants of the gui --- .../backend/routers/generation_router.py | 116 ++++++++++++------ 1 file changed, 76 insertions(+), 40 deletions(-) diff --git a/besser/utilities/web_modeling_editor/backend/routers/generation_router.py b/besser/utilities/web_modeling_editor/backend/routers/generation_router.py index f3a82461e..bd3531a00 100644 --- a/besser/utilities/web_modeling_editor/backend/routers/generation_router.py +++ b/besser/utilities/web_modeling_editor/backend/routers/generation_router.py @@ -714,45 +714,67 @@ async def _handle_web_app_project_generation(input_data: ProjectInput, generator detail="ClassDiagram is required for Web App generator" ) + # Personalization versions (optional). When present, the frontend has + # pre-assembled one COMPLETE GUI model per version (base + each user + # profile), each already resolved page-by-page. When absent, we generate a + # single app from the GUI diagram's own model (unchanged behavior). + web_app_versions = config.get("webAppVersions") if isinstance(config, dict) else None + if web_app_versions: + version_specs = [ + (str(v.get("slug") or f"version-{i + 1}"), v.get("guiModel")) + for i, v in enumerate(web_app_versions) + ] + else: + version_specs = [(None, gui_diagram.model)] + + multi = len(version_specs) > 1 + generator_class = generator_info.generator_class + with tempfile.TemporaryDirectory(prefix=TEMP_DIR_PREFIX) as temp_dir: - # Process class diagram to BUML - buml_model = process_class_diagram(class_diagram.model_dump()) - - gui_model = process_gui_diagram(gui_diagram.model, class_diagram.model, buml_model) - - # Collect every AgentDiagram in the project if the GUI uses agent components. - # The frontend dropdown enumerates all agents, so the generator must - # satisfy any binding — we don't filter to the active reference here. - agent_models = [] - agent_configs = {} - agent_config_yamls: dict = {} - has_agent_components = _check_for_agent_components(gui_model) - - if has_agent_components: - project_agent_config = config.get('agentConfig') if isinstance(config, dict) else None - default_cfg = project_agent_config or config - agent_models, agent_configs, agent_config_yamls = collect_agents_from_diagrams( - input_data.diagrams.get("AgentDiagram", []), - default_config=default_cfg, - ) - for name, cfg in agent_configs.items(): - logger.debug("[WebApp agent] resolved config for %s: %s", - name, - json.dumps(sanitize_config(cfg), indent=2, default=str) if cfg else 'None') - if not agent_models: - logger.warning( - "GUI contains agent components but no AgentDiagram was found. " - "Agent components will not be functional." + for slug, gui_json in version_specs: + # Re-derive the class BUML per version so the generator can't leak + # mutations from one version into the next. + buml_model = process_class_diagram(class_diagram.model_dump()) + gui_model = process_gui_diagram(gui_json, class_diagram.model, buml_model) + + # Collect every AgentDiagram in the project if this version's GUI uses + # agent components. The frontend dropdown enumerates all agents, so the + # generator must satisfy any binding — we don't filter to the active + # reference here. + agent_models = [] + agent_configs = {} + agent_config_yamls: dict = {} + if _check_for_agent_components(gui_model): + project_agent_config = config.get('agentConfig') if isinstance(config, dict) else None + default_cfg = project_agent_config or config + agent_models, agent_configs, agent_config_yamls = collect_agents_from_diagrams( + input_data.diagrams.get("AgentDiagram", []), + default_config=default_cfg, ) + for name, cfg in agent_configs.items(): + logger.debug("[WebApp agent] resolved config for %s: %s", + name, + json.dumps(sanitize_config(cfg), indent=2, default=str) if cfg else 'None') + if not agent_models: + logger.warning( + "GUI contains agent components but no AgentDiagram was found. " + "Agent components will not be functional." + ) + + # Single version → generate at the zip root (current layout). + # Multiple versions → one profile-named subfolder each. + out_dir = temp_dir + if multi: + out_dir = _safe_path(temp_dir, os.path.basename(slug)) + os.makedirs(out_dir, exist_ok=True) + + await _run_web_app_generator( + buml_model, gui_model, generator_class, out_dir, + agent_models=agent_models, agent_configs=agent_configs, + agent_config_yamls=agent_config_yamls, + ) - # Generate Web App TypeScript project - generator_class = generator_info.generator_class - - return await _generate_web_app( - buml_model, gui_model, generator_class, config, temp_dir, - agent_models=agent_models, agent_configs=agent_configs, - agent_config_yamls=agent_config_yamls, - ) + return _create_zip_response(temp_dir, "web_app") def _streaming_zip(zip_buffer: io.BytesIO, file_name: str) -> StreamingResponse: @@ -1215,19 +1237,33 @@ def _check_container_for_agent_components(container): return True return False +async def _run_web_app_generator(buml_model, gui_model, generator_class, out_dir: str, + agent_models=None, agent_configs=None, agent_config_yamls=None): + """Run the web app generator into ``out_dir`` WITHOUT zipping. + + Split out from ``_generate_web_app`` so multiple personalization versions can + each be generated into their own subdirectory before a single zip is built. + """ + generator_instance = generator_class( + buml_model, gui_model, output_dir=out_dir, + agent_models=agent_models, agent_configs=agent_configs, + agent_config_yamls=agent_config_yamls, + ) + await asyncio.to_thread(generator_instance.generate) + + async def _generate_web_app(buml_model, gui_model, generator_class, config: dict, temp_dir: str, agent_models=None, agent_configs=None, agent_config_yamls=None): - """Generate web application files. + """Generate web application files (single version) and return a ZIP response. Supports multi-agent projects: ``agent_models`` is a list and each is emitted under ``agents//`` in the generated output. """ - generator_instance = generator_class( - buml_model, gui_model, output_dir=temp_dir, + await _run_web_app_generator( + buml_model, gui_model, generator_class, temp_dir, agent_models=agent_models, agent_configs=agent_configs, agent_config_yamls=agent_config_yamls, ) - await asyncio.to_thread(generator_instance.generate) return _create_zip_response(temp_dir, "web_app") @handle_endpoint_errors("_generate_standard") From 5bd294ffbd997285856240512cf80971ca2cd6be Mon Sep 17 00:00:00 2001 From: aran30 Date: Wed, 8 Jul 2026 14:56:37 +0200 Subject: [PATCH 4/9] changed gpt version for faster generation --- .../agents/agent_personalization.py | 42 ++++++++++--------- .../backend/routers/generation_router.py | 2 +- 2 files changed, 23 insertions(+), 21 deletions(-) diff --git a/besser/generators/agents/agent_personalization.py b/besser/generators/agents/agent_personalization.py index e86bed2a9..2c670b277 100644 --- a/besser/generators/agents/agent_personalization.py +++ b/besser/generators/agents/agent_personalization.py @@ -80,7 +80,7 @@ def flatten_agent_config_structure(raw_config): return flattened -def call_openai_chat(system_prompt, user_prompt, model="gpt-5", openai_api_key=None, config=None): +def call_openai_chat(system_prompt, user_prompt, model="gpt-5.4-mini", openai_api_key=None, config=None): """ Calls OpenAI ChatCompletion with a system prompt and user prompt (openai>=1.0.0). Returns the response text only. @@ -97,6 +97,7 @@ def call_openai_chat(system_prompt, user_prompt, model="gpt-5", openai_api_key=N raise ImportError( "OpenAI personalization requires the 'agents' extra: pip install besser[agents]" ) from exc + print("calling now") client = OpenAI(api_key=resolved_api_key) response = client.chat.completions.create( model=model, @@ -111,9 +112,9 @@ def call_openai_chat(system_prompt, user_prompt, model="gpt-5", openai_api_key=N -def translate_text_batch(texts, target_language, model="gpt-5", openai_api_key=None, config=None): +def translate_text_batch(texts, target_language, model="gpt-5.4-mini", openai_api_key=None, config=None): """ - Translates each text in `texts` to the target language using OpenAI GPT-5. + Translates each text in `texts` to the target language using OpenAI gpt-5.4-mini. Returns a list of translated texts in the same order as input. """ if not isinstance(texts, (list, tuple)): @@ -170,7 +171,7 @@ def translate_text_api(text, target_language): -def style_text_batch(texts, style, model="gpt-5", openai_api_key=None, config=None): +def style_text_batch(texts, style, model="gpt-5.4-mini", openai_api_key=None, config=None): """ Rewrites each text in `texts` to the requested `style` while preserving meaning and content. `style` should be one of 'formal', 'informal', 'friendly', or 'technical'. @@ -338,6 +339,19 @@ def replace_reply_batch(messages: list[str], config: dict, openai_api_key: str = target_language = config['agentLanguage'] for i, msg in enumerate(personalized_messages): personalized_messages[i] = translate_text_api(msg, target_language) + if 'agentLanguage' in config and config['agentLanguage'] != 'none' and config['agentLanguage'] != 'original': + if not openai_api_key: + raise RuntimeError( + f"OpenAI API key is required for agent personalization. Set '{OPENAI_API_KEY_ENV_VAR}' " + "or include 'openaiApiKey'/'openai_api_key' in generator config." + ) + target_language = config['agentLanguage'] + personalized_messages = translate_text_batch( + personalized_messages, + target_language, + openai_api_key=openai_api_key, + config=config, + ) if 'agentStyle' in config and config['agentStyle'] != 'original': if not openai_api_key: raise RuntimeError( @@ -388,19 +402,7 @@ def replace_reply_batch(messages: list[str], config: dict, openai_api_key: str = config, openai_api_key=openai_api_key, ) - if 'agentLanguage' in config and config['agentLanguage'] != 'none' and config['agentLanguage'] != 'original': - if not openai_api_key: - raise RuntimeError( - f"OpenAI API key is required for agent personalization. Set '{OPENAI_API_KEY_ENV_VAR}' " - "or include 'openaiApiKey'/'openai_api_key' in generator config." - ) - target_language = config['agentLanguage'] - personalized_messages = translate_text_batch( - personalized_messages, - target_language, - openai_api_key=openai_api_key, - config=config, - ) + return personalized_messages @@ -421,7 +423,7 @@ def replace_content_profile_batch(messages: list[str], config: dict, openai_api_ model_name = flattened_config.get('llm') if not isinstance(model_name, str) or not model_name.strip(): - model_name = 'gpt-5' + model_name = 'gpt-5.4-mini' else: model_name = model_name.strip() @@ -495,7 +497,7 @@ def append_speech(match): text = match.group(1) return f"session.reply('{text}')\n platform.reply_speech(session, '{text}')" -def complexity_text_batch(texts, complexity, model="gpt-5", openai_api_key=None, config=None): +def complexity_text_batch(texts, complexity, model="gpt-5.4-mini", openai_api_key=None, config=None): """ Adjusts the complexity of each text in `texts` based on the specified `complexity` level. Complexity can be "simple", "medium", or "complex". @@ -551,7 +553,7 @@ def complexity_text_batch(texts, complexity, model="gpt-5", openai_api_key=None, return results -def sentence_length_batch(texts, preference, model="gpt-5", openai_api_key=None, config=None): +def sentence_length_batch(texts, preference, model="gpt-5.4-mini", openai_api_key=None, config=None): """ Adjusts each text in `texts` to be more concise or verbose. `preference` accepts "concise" or "verbose" (case-insensitive). diff --git a/besser/utilities/web_modeling_editor/backend/routers/generation_router.py b/besser/utilities/web_modeling_editor/backend/routers/generation_router.py index bd3531a00..181efce76 100644 --- a/besser/utilities/web_modeling_editor/backend/routers/generation_router.py +++ b/besser/utilities/web_modeling_editor/backend/routers/generation_router.py @@ -267,7 +267,7 @@ async def personalize_gui_page(payload: Dict[str, Any] = Body(...)): llm_model = ( requested_model if isinstance(requested_model, str) and requested_model.strip() - else "gpt-5" + else "gpt-5.4-mini" ) # Transform the user profile (UserDiagram) into its JSON object representation. From 679271de5b54401faa33a3ff7d516cea65df8a0b Mon Sep 17 00:00:00 2001 From: aran30 Date: Fri, 10 Jul 2026 11:19:55 +0200 Subject: [PATCH 5/9] updated prompt for GUI generation --- .../backend/routers/generation_router.py | 58 +++++++------------ 1 file changed, 21 insertions(+), 37 deletions(-) diff --git a/besser/utilities/web_modeling_editor/backend/routers/generation_router.py b/besser/utilities/web_modeling_editor/backend/routers/generation_router.py index 181efce76..46e7ec923 100644 --- a/besser/utilities/web_modeling_editor/backend/routers/generation_router.py +++ b/besser/utilities/web_modeling_editor/backend/routers/generation_router.py @@ -279,55 +279,39 @@ async def personalize_gui_page(payload: Dict[str, Any] = Body(...)): "tree, each node carrying fields such as type, tagName, attributes, classes, components, " "and text content) and a 'css' array (GrapesJS style-rule objects with 'selectors', " "'style', and 'pageId'). You also receive a user profile.\n\n" - "Your job is NOT to redesign the page. It is to make the SMALLEST set of changes that a " - "SPECIFIC, CONCRETE attribute in the user profile actually requires — and nothing else. " - "The default outcome is to return the page UNCHANGED. Only deviate from the original when " - "an attribute in the profile creates a real, demonstrable accessibility or comprehension " - "need for THIS user.\n\n" - "DECISION PROCESS (apply silently, then output only the JSON):\n" - "1) For each attribute in the user profile, ask: 'Does this attribute imply a concrete UI " - "barrier on THIS page?' Most attributes do not. An attribute justifies a change only if " - "there is a direct, well-established causal link between it and a UI adaptation.\n" - "2) If an attribute has no such link, IGNORE it. Do not invent a need. Demographic, " - "cultural, religious, gender, name, or location attributes almost never justify visual or " - "textual changes on their own — leave the page as-is for them.\n" - "3) Only when a link exists, apply the minimal adaptation that resolves that specific need, " - "and touch only the components/styles relevant to it. Leave everything else identical.\n\n" - "EXAMPLES OF JUSTIFIED CHANGES (only act when the profile clearly states the trait):\n" - "- Low vision / visual impairment -> increase font-size, contrast, and spacing.\n" - "- Stated reading level below the page's wording (e.g. B1 English on advanced copy) -> " - "simplify the wording while keeping meaning; do NOT translate unless a different language " - "is explicitly the user's language.\n" - "- Color blindness -> avoid color as the sole signal / adjust conflicting color pairs.\n" - "- Motor impairment -> enlarge click targets (padding) on interactive elements.\n" - "- A stated preferred language different from the page's language -> translate the visible " - "text into that language.\n\n" - "EXAMPLES OF NON-JUSTIFIED (return unchanged for these): a user who reads English fluently " - "on an English page; religion, ethnicity, gender, age alone, job title, hobbies, or any " - "attribute with no direct UI consequence. NEVER map an irrelevant attribute to a styling " - "change (e.g. do not change font size because of someone's religion or nationality).\n\n" + "You also receive a user profile model. Your job is to adapt the frontend to this user, " + "making decisions based on the information in the profile. Do NOT change the semantics of " + "the page (its meaning, message, and purpose stay the same) — change only its style and " + "presentation.\n\n" "STRICT OUTPUT RULES: " "1) Return ONLY a single JSON object with EXACTLY this shape: " '{"components": [...], "css": [...]}. No markdown, no comments, no prose. ' "2) Preserve the overall structure: keep each component's 'type', 'tagName', and " "'attributes.id' unchanged, and do not add, remove, or reorder components. " - "3) You MAY change visible text content, attribute label values (placeholder, title, " - "alt, link text), class lists, and the 'style' objects in both components and css rules — " - "but ONLY for the specific needs you justified above. Every other value must be byte-for-byte " + "3) You MAY change class lists and the 'style' objects in both components and css rules to " + "adapt style and presentation to the user profile. Every other value must be byte-for-byte " "identical to the input. " + "3a) Do NOT change the semantics: preserve the visible text content, attribute label " + "values (placeholder, title, alt, link text), and the page's message and purpose. A visual " + "adaptation such as enlarging the font is made ENTIRELY through 'style'/class changes; " + "never rewrite text into a description of the adaptation (e.g. do NOT turn a heading into " + "'bigger text for readability'). " "4) Keep every css rule's 'selectors' and 'pageId' values exactly as given. " - "5) If no profile attribute justifies any change, return the input page verbatim. " + "5) Return the input page verbatim if the profile gives no basis to adapt the presentation. " "6) The result must be valid JSON, parseable as-is, and remain a working GrapesJS page." ) user_prompt = ( - f'Personalize the GUI page named "{page_name}" for the following user — but only if the ' - "profile actually warrants it.\n\n" - f"User profile document:\n{json.dumps(profile_document, ensure_ascii=False, indent=2)}\n\n" + f'Personalize the GUI page named "{page_name}" for the following user profile model.\n\n' + f"User profile model:\n{json.dumps(profile_document, ensure_ascii=False, indent=2)}\n\n" "GUI page to adapt (GrapesJS):\n" f"{json.dumps({'components': components, 'css': css}, ensure_ascii=False)}\n\n" - "First, silently identify which (if any) profile attributes create a concrete UI need for " - "this user. Change ONLY what those needs require and leave every other part of the page " - "exactly as given. If nothing in the profile warrants a change, return the page unchanged. " + "Make decisions based on the information in the profile. " + "Only change something if you feel that the user profile gives a clear reason to adapt the page's style and presentation. " + "You can assume that the profile contains the most important information about the user and anything that is missing can be assumed that the user does not have that characteristic." + ". Adapt the page's style and " + "presentation to this user; do not change its core semantics. Presentation refers also to textual changes, " + "like making text more concise or verbose," + "or changing the tone of voice, language, etc. " 'Return only the resulting JSON object {"components": [...], "css": [...]}.' ) From fd35855bc44eb37528909819005349f94e7db658 Mon Sep 17 00:00:00 2001 From: aran30 Date: Wed, 15 Jul 2026 09:28:06 +0200 Subject: [PATCH 6/9] improved prompt for personalization --- .../backend/routers/generation_router.py | 63 ++++++++++++------- 1 file changed, 42 insertions(+), 21 deletions(-) diff --git a/besser/utilities/web_modeling_editor/backend/routers/generation_router.py b/besser/utilities/web_modeling_editor/backend/routers/generation_router.py index 46e7ec923..a1039fc77 100644 --- a/besser/utilities/web_modeling_editor/backend/routers/generation_router.py +++ b/besser/utilities/web_modeling_editor/backend/routers/generation_router.py @@ -267,7 +267,7 @@ async def personalize_gui_page(payload: Dict[str, Any] = Body(...)): llm_model = ( requested_model if isinstance(requested_model, str) and requested_model.strip() - else "gpt-5.4-mini" + else "gpt-5.6-luna" ) # Transform the user profile (UserDiagram) into its JSON object representation. @@ -277,26 +277,44 @@ async def personalize_gui_page(payload: Dict[str, Any] = Body(...)): "You are a UI personalization assistant for a GrapesJS-based no-code GUI editor. " "You receive a single GUI page as GrapesJS data: a 'components' array (the component " "tree, each node carrying fields such as type, tagName, attributes, classes, components, " - "and text content) and a 'css' array (GrapesJS style-rule objects with 'selectors', " - "'style', and 'pageId'). You also receive a user profile.\n\n" - "You also receive a user profile model. Your job is to adapt the frontend to this user, " - "making decisions based on the information in the profile. Do NOT change the semantics of " - "the page (its meaning, message, and purpose stay the same) — change only its style and " - "presentation.\n\n" + "style, and text content) and a 'css' array (GrapesJS style-rule objects with 'selectors', " + "'selectorsAdd', 'style', and 'pageId'). You also receive a user profile model.\n\n" + "Your job is to adapt the page's PRESENTATION to this user, making decisions based on the " + "information in the profile. Do NOT change the semantics of the page (its meaning, message, " + "and purpose stay the same) — change only its style and presentation.\n\n" + "HOW TO APPLY STYLE — TWO TIERS:\n" + "Tier 1 (PREFER THIS): express broad, consistent adaptations as high-level CSS rules keyed " + "by semantic category, appended to the 'css' array. Target a category with the " + "'selectorsAdd' field (a raw CSS selector string), NOT the 'selectors' array. Useful " + "categories: 'body' (base font-size and line-height — most text inherits from here), " + "headings ('h1, h2, h3, h4, h5, h6'), buttons ('button, .btn, a.button'), form fields " + "('input, textarea, select'), 'label', links ('a'). Many components in this editor ship " + "with baked-in inline/id-level styles that would otherwise win the cascade, so EVERY " + "declaration in a Tier-1 rule MUST end with '!important' (e.g. {\"font-size\": \"20px " + "!important\"}). A single 'body' rule with a larger font-size and line-height is the " + "correct way to enlarge text everywhere — do NOT resize each text node individually.\n" + "Tier 2 (USE SPARINGLY): for an adaptation a category rule cannot express (e.g. emphasizing " + "one specific call-to-action), you MAY edit the 'style' object of individual components in " + "the 'components' tree. Keep this to a small, deliberate set of nodes, and append " + "'!important' when overriding an existing baked-in value.\n\n" "STRICT OUTPUT RULES: " "1) Return ONLY a single JSON object with EXACTLY this shape: " '{"components": [...], "css": [...]}. No markdown, no comments, no prose. ' "2) Preserve the overall structure: keep each component's 'type', 'tagName', and " "'attributes.id' unchanged, and do not add, remove, or reorder components. " - "3) You MAY change class lists and the 'style' objects in both components and css rules to " - "adapt style and presentation to the user profile. Every other value must be byte-for-byte " - "identical to the input. " + "3) You MAY (a) append new high-level rules to the 'css' array, (b) change the 'style' " + "objects of existing css rules, and (c) change class lists and 'style' objects of " + "individual components (Tier 2). Every OTHER value must be byte-for-byte identical to the " + "input. " "3a) Do NOT change the semantics: preserve the visible text content, attribute label " "values (placeholder, title, alt, link text), and the page's message and purpose. A visual " - "adaptation such as enlarging the font is made ENTIRELY through 'style'/class changes; " - "never rewrite text into a description of the adaptation (e.g. do NOT turn a heading into " - "'bigger text for readability'). " - "4) Keep every css rule's 'selectors' and 'pageId' values exactly as given. " + "adaptation such as enlarging the font is made ENTIRELY through 'style'/class/CSS-rule " + "changes; never rewrite text into a description of the adaptation (e.g. do NOT turn a " + "heading into 'bigger text for readability'). " + "4) Keep every EXISTING css rule's 'selectors', 'selectorsAdd', and 'pageId' values exactly " + "as given (you may still edit its 'style'). For each NEW Tier-1 rule you add, set " + "'selectors' to an empty array and put the category selector in 'selectorsAdd'; you may " + "omit 'pageId'. " "5) Return the input page verbatim if the profile gives no basis to adapt the presentation. " "6) The result must be valid JSON, parseable as-is, and remain a working GrapesJS page." ) @@ -305,13 +323,16 @@ async def personalize_gui_page(payload: Dict[str, Any] = Body(...)): f"User profile model:\n{json.dumps(profile_document, ensure_ascii=False, indent=2)}\n\n" "GUI page to adapt (GrapesJS):\n" f"{json.dumps({'components': components, 'css': css}, ensure_ascii=False)}\n\n" - "Make decisions based on the information in the profile. " - "Only change something if you feel that the user profile gives a clear reason to adapt the page's style and presentation. " - "You can assume that the profile contains the most important information about the user and anything that is missing can be assumed that the user does not have that characteristic." - ". Adapt the page's style and " - "presentation to this user; do not change its core semantics. Presentation refers also to textual changes, " - "like making text more concise or verbose," - "or changing the tone of voice, language, etc. " + "Make decisions based on the information in the profile. Only adapt something when the " + "profile gives a clear reason to. You can assume the profile contains the most important " + "information about the user, and anything missing can be assumed absent.\n\n" + "Prefer Tier-1 category CSS rules (every declaration ending with '!important') for anything " + "that should apply broadly and consistently — e.g. if the user needs larger text, add a " + "'body' rule with a larger font-size and line-height rather than resizing individual " + "nodes. Use Tier-2 per-component edits only for targeted exceptions. Presentation includes " + "textual presentation too (tone, verbosity, language) as long as the core meaning is " + "preserved, as well as colors, contrast, font sizing, spacing, and forms. Your goal is the " + "best possible experience for this user with all their needs met.\n\n" 'Return only the resulting JSON object {"components": [...], "css": [...]}.' ) From bc1c5562571de61dd630674a691eef0b6e289019 Mon Sep 17 00:00:00 2001 From: aran30 Date: Wed, 15 Jul 2026 09:38:43 +0200 Subject: [PATCH 7/9] further improved prompt --- .../backend/routers/generation_router.py | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/besser/utilities/web_modeling_editor/backend/routers/generation_router.py b/besser/utilities/web_modeling_editor/backend/routers/generation_router.py index a1039fc77..c0fb2b5af 100644 --- a/besser/utilities/web_modeling_editor/backend/routers/generation_router.py +++ b/besser/utilities/web_modeling_editor/backend/routers/generation_router.py @@ -280,8 +280,12 @@ async def personalize_gui_page(payload: Dict[str, Any] = Body(...)): "style, and text content) and a 'css' array (GrapesJS style-rule objects with 'selectors', " "'selectorsAdd', 'style', and 'pageId'). You also receive a user profile model.\n\n" "Your job is to adapt the page's PRESENTATION to this user, making decisions based on the " - "information in the profile. Do NOT change the semantics of the page (its meaning, message, " - "and purpose stay the same) — change only its style and presentation.\n\n" + "information in the profile. Presentation covers both visual styling AND the wording of the " + "page — its language, tone, and verbosity. Preserve the page's MEANING (its message, " + "purpose, and the information each element conveys), but you MAY re-express that meaning in " + "different words when the profile calls for it. When the profile clearly implies a " + "preference — for example a single language the user reads, a reading level, or a tone — " + "act on it decisively across the WHOLE page rather than leaving the wording unchanged.\n\n" "HOW TO APPLY STYLE — TWO TIERS:\n" "Tier 1 (PREFER THIS): express broad, consistent adaptations as high-level CSS rules keyed " "by semantic category, appended to the 'css' array. Target a category with the " @@ -303,14 +307,18 @@ async def personalize_gui_page(payload: Dict[str, Any] = Body(...)): "2) Preserve the overall structure: keep each component's 'type', 'tagName', and " "'attributes.id' unchanged, and do not add, remove, or reorder components. " "3) You MAY (a) append new high-level rules to the 'css' array, (b) change the 'style' " - "objects of existing css rules, and (c) change class lists and 'style' objects of " - "individual components (Tier 2). Every OTHER value must be byte-for-byte identical to the " - "input. " - "3a) Do NOT change the semantics: preserve the visible text content, attribute label " - "values (placeholder, title, alt, link text), and the page's message and purpose. A visual " - "adaptation such as enlarging the font is made ENTIRELY through 'style'/class/CSS-rule " - "changes; never rewrite text into a description of the adaptation (e.g. do NOT turn a " - "heading into 'bigger text for readability'). " + "objects of existing css rules, (c) change class lists and 'style' objects of individual " + "components (Tier 2), and (d) rewrite the user-visible wording of the page — text-node " + "content and text-bearing attributes/properties (placeholder, title, alt, aria-label, link " + "text, button and field labels) — to translate it or adjust its tone/verbosity. Every " + "OTHER value must be byte-for-byte identical to the input. " + "3a) Preserve MEANING, not exact words: when you rewrite wording (per 3d) the message, " + "purpose, and information of every element must stay identical — translation and " + "tone/verbosity changes are allowed, but do NOT add, drop, or alter what the page tells the " + "user. A VISUAL adaptation such as enlarging the font is made ENTIRELY through " + "'style'/class/CSS-rule changes and must not touch wording; never rewrite text into a " + "description of the adaptation (e.g. do NOT turn a heading into 'bigger text for " + "readability'). " "4) Keep every EXISTING css rule's 'selectors', 'selectorsAdd', and 'pageId' values exactly " "as given (you may still edit its 'style'). For each NEW Tier-1 rule you add, set " "'selectors' to an empty array and put the category selector in 'selectorsAdd'; you may " From ce6d5c3c1ce331232c8f66a796f8bc0cfdd1db49 Mon Sep 17 00:00:00 2001 From: aran30 Date: Wed, 15 Jul 2026 14:53:14 +0200 Subject: [PATCH 8/9] fix empty attribute problem when using agent for user profile modeling --- .../json_to_buml/object_diagram_processor.py | 9 +++- .../converters/test_converter_roundtrip.py | 43 +++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/besser/utilities/web_modeling_editor/backend/services/converters/json_to_buml/object_diagram_processor.py b/besser/utilities/web_modeling_editor/backend/services/converters/json_to_buml/object_diagram_processor.py index 344321845..1cd887037 100644 --- a/besser/utilities/web_modeling_editor/backend/services/converters/json_to_buml/object_diagram_processor.py +++ b/besser/utilities/web_modeling_editor/backend/services/converters/json_to_buml/object_diagram_processor.py @@ -198,8 +198,13 @@ def process_object_diagram(json_data, domain_model): continue elif attr_type == "UserModelAttribute": operator = attr_element.get("attributeOperator", "==") - if operator and operator in attr_string: - attr_part, value_part = attr_string.split(operator, 1) + # The editor renders equality as a single '=' (e.g. + # "age = 18") while storing the operator as '=='. Match on + # the *displayed* symbol so equality criteria are parsed + # instead of dropped (which left every '==' attribute empty). + display_operator = "=" if operator == "==" else operator + if display_operator and display_operator in attr_string: + attr_part, value_part = attr_string.split(display_operator, 1) attr_name = attr_part.strip() value = value_part.strip() else: diff --git a/tests/utilities/web_modeling_editor/backend/services/converters/test_converter_roundtrip.py b/tests/utilities/web_modeling_editor/backend/services/converters/test_converter_roundtrip.py index 693421761..cac215deb 100644 --- a/tests/utilities/web_modeling_editor/backend/services/converters/test_converter_roundtrip.py +++ b/tests/utilities/web_modeling_editor/backend/services/converters/test_converter_roundtrip.py @@ -1322,6 +1322,49 @@ def test_object_attribute_values(self, object_diagram_with_attrs_json, minimal_c } assert slot_values.get("name") == "Fitzgerald" + def test_user_model_equality_attribute_value(self, minimal_class_diagram_json): + """UserModelAttribute equality criteria ('==') are parsed, not dropped. + + Regression test: the editor renders equality as a single '=' (e.g. + "title = TheGreatGatsby") while storing attributeOperator as '=='. The + parser must split on the displayed symbol; otherwise every '==' + criterion falls through with an empty value ("attributes are empty"). + """ + domain_model = process_class_diagram(minimal_class_diagram_json) + user_diagram_json = { + "title": "UserProfile", + "model": { + "type": "UserDiagram", + "elements": { + "u-book1": { + "id": "u-book1", + "name": "book1", + "type": "UserModelName", + "owner": None, + "bounds": {"x": 0, "y": 0, "width": 200, "height": 100}, + "attributes": ["u-title"], + "methods": [], + "className": "Book", + }, + "u-title": { + "id": "u-title", + "name": "title = TheGreatGatsby", + "type": "UserModelAttribute", + "owner": "u-book1", + "attributeOperator": "==", + "bounds": {"x": 0, "y": 30, "width": 199, "height": 30}, + }, + }, + "relationships": {}, + }, + } + + obj_model = process_object_diagram(user_diagram_json, domain_model) + + book = next(obj for obj in obj_model.objects if obj.name == "book1") + slot_values = {slot.attribute.name: slot.value.value for slot in book.slots} + assert slot_values.get("title") == "TheGreatGatsby" + # =========================================================================== # NN Diagram Roundtrip: JSON -> NN -> JSON From 12adb80d8abb9aa4aef63252022a3c71ffa08e57 Mon Sep 17 00:00:00 2001 From: ArmSL Date: Tue, 21 Jul 2026 12:10:27 +0200 Subject: [PATCH 9/9] refactor(gui-personalization): extract endpoint to a service module + cleanup Addresses review feedback on #577 (organization + correctness): - Extract the ~150-line /personalize-gui-page endpoint body into a new services/utils/gui_personalization_utils.py, mirroring the agent_personalization pattern: the ~40-line LLM system prompt is now a module constant, and personalize_gui_page() owns validation, prompt building, the OpenAI call and response parsing. The router endpoint is now a thin validate -> delegate -> shape-response handler. - Consolidate the OpenAI model default into one DEFAULT_OPENAI_MODEL = 'gpt-5' constant. Removes the inconsistent/invalid defaults the PR introduced (gpt-5.4-mini x7, gpt-5.6-luna) that would 404 at runtime. - Remove a leftover print('calling now') debug statement. - Remove two dead '... and False' branches in replace_reply_batch. - Add test_gui_personalization.py (10 tests, LLM mocked) covering validation, model selection, response parsing and error mapping. Left as-is: the translate-before-style reorder in replace_reply_batch (appears intentional); pre-existing gpt-5.5 default at generation_router line 172 (out of scope). --- .../agents/agent_personalization.py | 25 +-- .../backend/routers/generation_router.py | 160 +++------------ .../utils/gui_personalization_utils.py | 182 ++++++++++++++++++ .../utils/test_gui_personalization.py | 101 ++++++++++ 4 files changed, 316 insertions(+), 152 deletions(-) create mode 100644 besser/utilities/web_modeling_editor/backend/services/utils/gui_personalization_utils.py create mode 100644 tests/utilities/web_modeling_editor/backend/services/utils/test_gui_personalization.py diff --git a/besser/generators/agents/agent_personalization.py b/besser/generators/agents/agent_personalization.py index 2c670b277..ce3b6aa82 100644 --- a/besser/generators/agents/agent_personalization.py +++ b/besser/generators/agents/agent_personalization.py @@ -9,6 +9,9 @@ OPENAI_API_KEY_ENV_VAR = "OPENAI_API_KEY" +# Single default model for all OpenAI-backed personalization helpers. +DEFAULT_OPENAI_MODEL = "gpt-5" + def _resolve_openai_api_key(config: dict = None, openai_api_key: str = None) -> str | None: if isinstance(openai_api_key, str) and openai_api_key.strip(): @@ -80,7 +83,7 @@ def flatten_agent_config_structure(raw_config): return flattened -def call_openai_chat(system_prompt, user_prompt, model="gpt-5.4-mini", openai_api_key=None, config=None): +def call_openai_chat(system_prompt, user_prompt, model=DEFAULT_OPENAI_MODEL, openai_api_key=None, config=None): """ Calls OpenAI ChatCompletion with a system prompt and user prompt (openai>=1.0.0). Returns the response text only. @@ -97,7 +100,6 @@ def call_openai_chat(system_prompt, user_prompt, model="gpt-5.4-mini", openai_ap raise ImportError( "OpenAI personalization requires the 'agents' extra: pip install besser[agents]" ) from exc - print("calling now") client = OpenAI(api_key=resolved_api_key) response = client.chat.completions.create( model=model, @@ -112,9 +114,9 @@ def call_openai_chat(system_prompt, user_prompt, model="gpt-5.4-mini", openai_ap -def translate_text_batch(texts, target_language, model="gpt-5.4-mini", openai_api_key=None, config=None): +def translate_text_batch(texts, target_language, model=DEFAULT_OPENAI_MODEL, openai_api_key=None, config=None): """ - Translates each text in `texts` to the target language using OpenAI gpt-5.4-mini. + Translates each text in `texts` to the target language using OpenAI. Returns a list of translated texts in the same order as input. """ if not isinstance(texts, (list, tuple)): @@ -171,7 +173,7 @@ def translate_text_api(text, target_language): -def style_text_batch(texts, style, model="gpt-5.4-mini", openai_api_key=None, config=None): +def style_text_batch(texts, style, model=DEFAULT_OPENAI_MODEL, openai_api_key=None, config=None): """ Rewrites each text in `texts` to the requested `style` while preserving meaning and content. `style` should be one of 'formal', 'informal', 'friendly', or 'technical'. @@ -332,13 +334,6 @@ def replace_reply_batch(messages: list[str], config: dict, openai_api_key: str = config = flatten_agent_config_structure(config or {}) personalized_messages = messages - if 'agentLanguage' in config and config['agentLanguage'] != 'none' and False: - target_language = config['agentLanguage'] - # personalized_message = translate_text(personalized_message, target_language) - elif 'agentLanguage' in config and config['agentLanguage'] != 'none' and config['agentLanguage'] != 'original' and False: - target_language = config['agentLanguage'] - for i, msg in enumerate(personalized_messages): - personalized_messages[i] = translate_text_api(msg, target_language) if 'agentLanguage' in config and config['agentLanguage'] != 'none' and config['agentLanguage'] != 'original': if not openai_api_key: raise RuntimeError( @@ -423,7 +418,7 @@ def replace_content_profile_batch(messages: list[str], config: dict, openai_api_ model_name = flattened_config.get('llm') if not isinstance(model_name, str) or not model_name.strip(): - model_name = 'gpt-5.4-mini' + model_name = DEFAULT_OPENAI_MODEL else: model_name = model_name.strip() @@ -497,7 +492,7 @@ def append_speech(match): text = match.group(1) return f"session.reply('{text}')\n platform.reply_speech(session, '{text}')" -def complexity_text_batch(texts, complexity, model="gpt-5.4-mini", openai_api_key=None, config=None): +def complexity_text_batch(texts, complexity, model=DEFAULT_OPENAI_MODEL, openai_api_key=None, config=None): """ Adjusts the complexity of each text in `texts` based on the specified `complexity` level. Complexity can be "simple", "medium", or "complex". @@ -553,7 +548,7 @@ def complexity_text_batch(texts, complexity, model="gpt-5.4-mini", openai_api_ke return results -def sentence_length_batch(texts, preference, model="gpt-5.4-mini", openai_api_key=None, config=None): +def sentence_length_batch(texts, preference, model=DEFAULT_OPENAI_MODEL, openai_api_key=None, config=None): """ Adjusts each text in `texts` to be more concise or verbose. `preference` accepts "concise" or "verbose" (case-insensitive). diff --git a/besser/utilities/web_modeling_editor/backend/routers/generation_router.py b/besser/utilities/web_modeling_editor/backend/routers/generation_router.py index c0fb2b5af..a16c3d9b5 100644 --- a/besser/utilities/web_modeling_editor/backend/routers/generation_router.py +++ b/besser/utilities/web_modeling_editor/backend/routers/generation_router.py @@ -75,6 +75,9 @@ normalize_user_model_output as _normalize_user_model_output, safe_path as _safe_path, ) +from besser.utilities.web_modeling_editor.backend.services.utils.gui_personalization_utils import ( + personalize_gui_page as run_gui_personalization, +) # Backend configuration from besser.utilities.web_modeling_editor.backend.config import ( @@ -245,144 +248,27 @@ async def personalize_gui_page(payload: Dict[str, Any] = Body(...)): Returns the same ``{components, css}`` shape adapted in style and content, ready to be imported back into the editor as a page variant. """ - gui_page = payload.get("guiPage") - if not isinstance(gui_page, dict): - raise ValidationError( - "guiPage is required and must be a JSON object with 'components' and 'css'" - ) - - user_profile_model = payload.get("userProfileModel") - if not isinstance(user_profile_model, dict): - raise ValidationError("userProfileModel is required and must be a JSON object") - - components = gui_page.get("components") - if not isinstance(components, list): - raise ValidationError("guiPage.components must be a list") - css = gui_page.get("css") - if not isinstance(css, list): - css = [] - - page_name = payload.get("pageName") if isinstance(payload.get("pageName"), str) else "page" - requested_model = payload.get("model") - llm_model = ( - requested_model - if isinstance(requested_model, str) and requested_model.strip() - else "gpt-5.6-luna" - ) - - # Transform the user profile (UserDiagram) into its JSON object representation. - profile_document = _generate_user_profile_document(user_profile_model) - - system_prompt = ( - "You are a UI personalization assistant for a GrapesJS-based no-code GUI editor. " - "You receive a single GUI page as GrapesJS data: a 'components' array (the component " - "tree, each node carrying fields such as type, tagName, attributes, classes, components, " - "style, and text content) and a 'css' array (GrapesJS style-rule objects with 'selectors', " - "'selectorsAdd', 'style', and 'pageId'). You also receive a user profile model.\n\n" - "Your job is to adapt the page's PRESENTATION to this user, making decisions based on the " - "information in the profile. Presentation covers both visual styling AND the wording of the " - "page — its language, tone, and verbosity. Preserve the page's MEANING (its message, " - "purpose, and the information each element conveys), but you MAY re-express that meaning in " - "different words when the profile calls for it. When the profile clearly implies a " - "preference — for example a single language the user reads, a reading level, or a tone — " - "act on it decisively across the WHOLE page rather than leaving the wording unchanged.\n\n" - "HOW TO APPLY STYLE — TWO TIERS:\n" - "Tier 1 (PREFER THIS): express broad, consistent adaptations as high-level CSS rules keyed " - "by semantic category, appended to the 'css' array. Target a category with the " - "'selectorsAdd' field (a raw CSS selector string), NOT the 'selectors' array. Useful " - "categories: 'body' (base font-size and line-height — most text inherits from here), " - "headings ('h1, h2, h3, h4, h5, h6'), buttons ('button, .btn, a.button'), form fields " - "('input, textarea, select'), 'label', links ('a'). Many components in this editor ship " - "with baked-in inline/id-level styles that would otherwise win the cascade, so EVERY " - "declaration in a Tier-1 rule MUST end with '!important' (e.g. {\"font-size\": \"20px " - "!important\"}). A single 'body' rule with a larger font-size and line-height is the " - "correct way to enlarge text everywhere — do NOT resize each text node individually.\n" - "Tier 2 (USE SPARINGLY): for an adaptation a category rule cannot express (e.g. emphasizing " - "one specific call-to-action), you MAY edit the 'style' object of individual components in " - "the 'components' tree. Keep this to a small, deliberate set of nodes, and append " - "'!important' when overriding an existing baked-in value.\n\n" - "STRICT OUTPUT RULES: " - "1) Return ONLY a single JSON object with EXACTLY this shape: " - '{"components": [...], "css": [...]}. No markdown, no comments, no prose. ' - "2) Preserve the overall structure: keep each component's 'type', 'tagName', and " - "'attributes.id' unchanged, and do not add, remove, or reorder components. " - "3) You MAY (a) append new high-level rules to the 'css' array, (b) change the 'style' " - "objects of existing css rules, (c) change class lists and 'style' objects of individual " - "components (Tier 2), and (d) rewrite the user-visible wording of the page — text-node " - "content and text-bearing attributes/properties (placeholder, title, alt, aria-label, link " - "text, button and field labels) — to translate it or adjust its tone/verbosity. Every " - "OTHER value must be byte-for-byte identical to the input. " - "3a) Preserve MEANING, not exact words: when you rewrite wording (per 3d) the message, " - "purpose, and information of every element must stay identical — translation and " - "tone/verbosity changes are allowed, but do NOT add, drop, or alter what the page tells the " - "user. A VISUAL adaptation such as enlarging the font is made ENTIRELY through " - "'style'/class/CSS-rule changes and must not touch wording; never rewrite text into a " - "description of the adaptation (e.g. do NOT turn a heading into 'bigger text for " - "readability'). " - "4) Keep every EXISTING css rule's 'selectors', 'selectorsAdd', and 'pageId' values exactly " - "as given (you may still edit its 'style'). For each NEW Tier-1 rule you add, set " - "'selectors' to an empty array and put the category selector in 'selectorsAdd'; you may " - "omit 'pageId'. " - "5) Return the input page verbatim if the profile gives no basis to adapt the presentation. " - "6) The result must be valid JSON, parseable as-is, and remain a working GrapesJS page." + if not isinstance(payload, dict): + raise ValidationError("Request body must be a JSON object") + + # All validation, prompt building, the LLM call and response parsing live in + # gui_personalization_utils (mirroring agent_personalization). The router + # only resolves the API key, delegates off the event loop, and shapes the + # HTTP response; @handle_endpoint_errors maps ValidationError/GenerationError. + result = await asyncio.to_thread( + run_gui_personalization, + payload.get("guiPage"), + payload.get("userProfileModel"), + page_name=payload.get("pageName"), + model=payload.get("model"), + openai_api_key=extract_openai_api_key(payload), ) - user_prompt = ( - f'Personalize the GUI page named "{page_name}" for the following user profile model.\n\n' - f"User profile model:\n{json.dumps(profile_document, ensure_ascii=False, indent=2)}\n\n" - "GUI page to adapt (GrapesJS):\n" - f"{json.dumps({'components': components, 'css': css}, ensure_ascii=False)}\n\n" - "Make decisions based on the information in the profile. Only adapt something when the " - "profile gives a clear reason to. You can assume the profile contains the most important " - "information about the user, and anything missing can be assumed absent.\n\n" - "Prefer Tier-1 category CSS rules (every declaration ending with '!important') for anything " - "that should apply broadly and consistently — e.g. if the user needs larger text, add a " - "'body' rule with a larger font-size and line-height rather than resizing individual " - "nodes. Use Tier-2 per-component edits only for targeted exceptions. Presentation includes " - "textual presentation too (tone, verbosity, language) as long as the core meaning is " - "preserved, as well as colors, contrast, font sizing, spacing, and forms. Your goal is the " - "best possible experience for this user with all their needs met.\n\n" - 'Return only the resulting JSON object {"components": [...], "css": [...]}.' - ) - - try: - response_text = await asyncio.to_thread( - call_openai_chat, - system_prompt, - user_prompt, - model=llm_model, - openai_api_key=extract_openai_api_key(payload if isinstance(payload, dict) else {}), - config=payload, - ) - parsed = extract_json_object(response_text) - personalized_components = parsed.get("components") - personalized_css = parsed.get("css", []) - if not isinstance(personalized_components, list): - raise GenerationError("LLM response did not include a valid 'components' list") - if not isinstance(personalized_css, list): - personalized_css = [] - - return { - "guiPage": { - "components": personalized_components, - "css": personalized_css, - }, - "source": "openai", - "model": llm_model, - "generatedAt": _utc_now_iso(), - } - except RuntimeError as runtime_error: - # Raised by call_openai_chat when no OpenAI API key is configured. - raise ValidationError(str(runtime_error)) from runtime_error - except ValueError as parse_error: - logger.exception("Failed to parse LLM personalization response") - raise GenerationError("Failed to parse LLM personalization response") from parse_error - except (ValidationError, GenerationError): - raise - except HTTPException: - raise - except Exception as exc: - logger.exception("Failed to personalize GUI page") - raise GenerationError("Failed to personalize GUI page") from exc + return { + "guiPage": {"components": result["components"], "css": result["css"]}, + "source": "openai", + "model": result["model"], + "generatedAt": _utc_now_iso(), + } @router.get("/agent-config-manual-mapping") diff --git a/besser/utilities/web_modeling_editor/backend/services/utils/gui_personalization_utils.py b/besser/utilities/web_modeling_editor/backend/services/utils/gui_personalization_utils.py new file mode 100644 index 000000000..c950cb47d --- /dev/null +++ b/besser/utilities/web_modeling_editor/backend/services/utils/gui_personalization_utils.py @@ -0,0 +1,182 @@ +"""GUI page personalization via an LLM. + +Mirrors the ``agent_personalization`` module pattern: all prompt construction, +the OpenAI call and response parsing live here so the router stays a thin HTTP +layer. The router imports :func:`personalize_gui_page` and delegates to it. +""" +import json +import logging + +from besser.generators.agents.agent_personalization import ( + DEFAULT_OPENAI_MODEL, + call_openai_chat, +) +from besser.utilities.web_modeling_editor.backend.services.exceptions import ( + GenerationError, + ValidationError, +) +from besser.utilities.web_modeling_editor.backend.services.utils.agent_config_recommendation_utils import ( + extract_json_object, +) +from besser.utilities.web_modeling_editor.backend.services.utils.user_profile_utils import ( + generate_user_profile_document, +) + +logger = logging.getLogger(__name__) + + +# LLM instruction for adapting a single GrapesJS page to a user profile. +# Kept as a module constant so it can be versioned, reviewed and tested in +# isolation rather than being buried inside a request handler. +GUI_PERSONALIZATION_SYSTEM_PROMPT = ( + "You are a UI personalization assistant for a GrapesJS-based no-code GUI editor. " + "You receive a single GUI page as GrapesJS data: a 'components' array (the component " + "tree, each node carrying fields such as type, tagName, attributes, classes, components, " + "style, and text content) and a 'css' array (GrapesJS style-rule objects with 'selectors', " + "'selectorsAdd', 'style', and 'pageId'). You also receive a user profile model.\n\n" + "Your job is to adapt the page's PRESENTATION to this user, making decisions based on the " + "information in the profile. Presentation covers both visual styling AND the wording of the " + "page — its language, tone, and verbosity. Preserve the page's MEANING (its message, " + "purpose, and the information each element conveys), but you MAY re-express that meaning in " + "different words when the profile calls for it. When the profile clearly implies a " + "preference — for example a single language the user reads, a reading level, or a tone — " + "act on it decisively across the WHOLE page rather than leaving the wording unchanged.\n\n" + "HOW TO APPLY STYLE — TWO TIERS:\n" + "Tier 1 (PREFER THIS): express broad, consistent adaptations as high-level CSS rules keyed " + "by semantic category, appended to the 'css' array. Target a category with the " + "'selectorsAdd' field (a raw CSS selector string), NOT the 'selectors' array. Useful " + "categories: 'body' (base font-size and line-height — most text inherits from here), " + "headings ('h1, h2, h3, h4, h5, h6'), buttons ('button, .btn, a.button'), form fields " + "('input, textarea, select'), 'label', links ('a'). Many components in this editor ship " + "with baked-in inline/id-level styles that would otherwise win the cascade, so EVERY " + "declaration in a Tier-1 rule MUST end with '!important' (e.g. {\"font-size\": \"20px " + "!important\"}). A single 'body' rule with a larger font-size and line-height is the " + "correct way to enlarge text everywhere — do NOT resize each text node individually.\n" + "Tier 2 (USE SPARINGLY): for an adaptation a category rule cannot express (e.g. emphasizing " + "one specific call-to-action), you MAY edit the 'style' object of individual components in " + "the 'components' tree. Keep this to a small, deliberate set of nodes, and append " + "'!important' when overriding an existing baked-in value.\n\n" + "STRICT OUTPUT RULES: " + "1) Return ONLY a single JSON object with EXACTLY this shape: " + '{"components": [...], "css": [...]}. No markdown, no comments, no prose. ' + "2) Preserve the overall structure: keep each component's 'type', 'tagName', and " + "'attributes.id' unchanged, and do not add, remove, or reorder components. " + "3) You MAY (a) append new high-level rules to the 'css' array, (b) change the 'style' " + "objects of existing css rules, (c) change class lists and 'style' objects of individual " + "components (Tier 2), and (d) rewrite the user-visible wording of the page — text-node " + "content and text-bearing attributes/properties (placeholder, title, alt, aria-label, link " + "text, button and field labels) — to translate it or adjust its tone/verbosity. Every " + "OTHER value must be byte-for-byte identical to the input. " + "3a) Preserve MEANING, not exact words: when you rewrite wording (per 3d) the message, " + "purpose, and information of every element must stay identical — translation and " + "tone/verbosity changes are allowed, but do NOT add, drop, or alter what the page tells the " + "user. A VISUAL adaptation such as enlarging the font is made ENTIRELY through " + "'style'/class/CSS-rule changes and must not touch wording; never rewrite text into a " + "description of the adaptation (e.g. do NOT turn a heading into 'bigger text for " + "readability'). " + "4) Keep every EXISTING css rule's 'selectors', 'selectorsAdd', and 'pageId' values exactly " + "as given (you may still edit its 'style'). For each NEW Tier-1 rule you add, set " + "'selectors' to an empty array and put the category selector in 'selectorsAdd'; you may " + "omit 'pageId'. " + "5) Return the input page verbatim if the profile gives no basis to adapt the presentation. " + "6) The result must be valid JSON, parseable as-is, and remain a working GrapesJS page." +) + + +def build_user_prompt(page_name: str, profile_document, components, css) -> str: + """Build the per-request user prompt for GUI page personalization.""" + return ( + f'Personalize the GUI page named "{page_name}" for the following user profile model.\n\n' + f"User profile model:\n{json.dumps(profile_document, ensure_ascii=False, indent=2)}\n\n" + "GUI page to adapt (GrapesJS):\n" + f"{json.dumps({'components': components, 'css': css}, ensure_ascii=False)}\n\n" + "Make decisions based on the information in the profile. Only adapt something when the " + "profile gives a clear reason to. You can assume the profile contains the most important " + "information about the user, and anything missing can be assumed absent.\n\n" + "Prefer Tier-1 category CSS rules (every declaration ending with '!important') for anything " + "that should apply broadly and consistently — e.g. if the user needs larger text, add a " + "'body' rule with a larger font-size and line-height rather than resizing individual " + "nodes. Use Tier-2 per-component edits only for targeted exceptions. Presentation includes " + "textual presentation too (tone, verbosity, language) as long as the core meaning is " + "preserved, as well as colors, contrast, font sizing, spacing, and forms. Your goal is the " + "best possible experience for this user with all their needs met.\n\n" + 'Return only the resulting JSON object {"components": [...], "css": [...]}.' + ) + + +def personalize_gui_page( + gui_page, + user_profile_model, + *, + page_name: str = "page", + model=None, + openai_api_key=None, +) -> dict: + """Personalize a single GrapesJS page for a user profile using an LLM. + + Args: + gui_page: a GrapesJS page snapshot ``{"components": [...], "css": [...]}``. + user_profile_model: a UserDiagram UML model JSON. + page_name: the page name (used in the prompt only). + model: optional OpenAI model id; falls back to ``DEFAULT_OPENAI_MODEL``. + openai_api_key: resolved OpenAI API key (the caller resolves it). + + Returns: + ``{"components": [...], "css": [...], "model": }`` — the adapted page + plus the model actually used. + + Raises: + ValidationError: on malformed input or a missing OpenAI API key. + GenerationError: when the LLM response cannot be parsed or is malformed. + """ + if not isinstance(gui_page, dict): + raise ValidationError( + "guiPage is required and must be a JSON object with 'components' and 'css'" + ) + if not isinstance(user_profile_model, dict): + raise ValidationError("userProfileModel is required and must be a JSON object") + + components = gui_page.get("components") + if not isinstance(components, list): + raise ValidationError("guiPage.components must be a list") + css = gui_page.get("css") + if not isinstance(css, list): + css = [] + + if not isinstance(page_name, str) or not page_name.strip(): + page_name = "page" + llm_model = model if isinstance(model, str) and model.strip() else DEFAULT_OPENAI_MODEL + + # Transform the user profile (UserDiagram) into its JSON object representation. + profile_document = generate_user_profile_document(user_profile_model) + user_prompt = build_user_prompt(page_name, profile_document, components, css) + + try: + response_text = call_openai_chat( + GUI_PERSONALIZATION_SYSTEM_PROMPT, + user_prompt, + model=llm_model, + openai_api_key=openai_api_key, + ) + except RuntimeError as runtime_error: + # Raised by call_openai_chat when no OpenAI API key is configured. + raise ValidationError(str(runtime_error)) from runtime_error + + try: + parsed = extract_json_object(response_text) + except ValueError as parse_error: + logger.exception("Failed to parse LLM personalization response") + raise GenerationError("Failed to parse LLM personalization response") from parse_error + + personalized_components = parsed.get("components") + if not isinstance(personalized_components, list): + raise GenerationError("LLM response did not include a valid 'components' list") + personalized_css = parsed.get("css", []) + if not isinstance(personalized_css, list): + personalized_css = [] + + return { + "components": personalized_components, + "css": personalized_css, + "model": llm_model, + } diff --git a/tests/utilities/web_modeling_editor/backend/services/utils/test_gui_personalization.py b/tests/utilities/web_modeling_editor/backend/services/utils/test_gui_personalization.py new file mode 100644 index 000000000..99c04eed7 --- /dev/null +++ b/tests/utilities/web_modeling_editor/backend/services/utils/test_gui_personalization.py @@ -0,0 +1,101 @@ +"""Tests for gui_personalization_utils.personalize_gui_page. + +The OpenAI call is mocked, so these run offline and exercise the validation, +model-selection, response-parsing and error-mapping logic in isolation. +""" +from unittest.mock import patch + +import pytest + +from besser.utilities.web_modeling_editor.backend.services.utils.gui_personalization_utils import ( + personalize_gui_page, + DEFAULT_OPENAI_MODEL, +) +from besser.utilities.web_modeling_editor.backend.services.exceptions import ( + ValidationError, + GenerationError, +) + +MODULE = "besser.utilities.web_modeling_editor.backend.services.utils.gui_personalization_utils" + +_PAGE = {"components": [{"type": "text", "content": "Hello"}], "css": []} +_PROFILE = {"type": "UserDiagram", "elements": {}, "relationships": {}} + + +def _mock_llm(return_value=None, side_effect=None): + """Patch both the LLM call and the profile-document builder.""" + return ( + patch(f"{MODULE}.call_openai_chat", return_value=return_value, side_effect=side_effect), + patch(f"{MODULE}.generate_user_profile_document", return_value={}), + ) + + +class TestPersonalizeGuiPageValidation: + def test_gui_page_must_be_dict(self): + with pytest.raises(ValidationError): + personalize_gui_page("not-a-dict", _PROFILE) + + def test_user_profile_must_be_dict(self): + with pytest.raises(ValidationError): + personalize_gui_page(_PAGE, "not-a-dict") + + def test_components_must_be_a_list(self): + with pytest.raises(ValidationError): + personalize_gui_page({"components": "nope"}, _PROFILE) + + def test_validation_happens_before_any_llm_call(self): + # A bad payload must never reach call_openai_chat. + with patch(f"{MODULE}.call_openai_chat") as chat: + with pytest.raises(ValidationError): + personalize_gui_page("bad", _PROFILE) + chat.assert_not_called() + + +class TestPersonalizeGuiPageBehavior: + def test_happy_path_parses_llm_response(self): + llm_out = ( + '{"components": [{"type": "text", "content": "Bonjour"}], ' + '"css": [{"selectorsAdd": "body", "style": {"font-size": "20px !important"}}]}' + ) + chat, profile = _mock_llm(return_value=llm_out) + with chat as chat_mock, profile: + result = personalize_gui_page(_PAGE, _PROFILE, openai_api_key="sk-test") + assert result["components"][0]["content"] == "Bonjour" + assert result["css"][0]["selectorsAdd"] == "body" + assert result["model"] == DEFAULT_OPENAI_MODEL + # the shared default model is what was actually sent to OpenAI + assert chat_mock.call_args.kwargs["model"] == DEFAULT_OPENAI_MODEL + + def test_explicit_model_overrides_default(self): + chat, profile = _mock_llm(return_value='{"components": [], "css": []}') + with chat as chat_mock, profile: + result = personalize_gui_page(_PAGE, _PROFILE, model="gpt-5-mini", openai_api_key="sk-test") + assert result["model"] == "gpt-5-mini" + assert chat_mock.call_args.kwargs["model"] == "gpt-5-mini" + + def test_missing_css_defaults_to_empty_list(self): + chat, profile = _mock_llm(return_value='{"components": []}') + with chat, profile: + result = personalize_gui_page(_PAGE, _PROFILE, openai_api_key="sk-test") + assert result["css"] == [] + + +class TestPersonalizeGuiPageErrors: + def test_missing_api_key_becomes_validation_error(self): + # call_openai_chat raises RuntimeError when no key is configured. + chat, profile = _mock_llm(side_effect=RuntimeError("OpenAI API key not found")) + with chat, profile: + with pytest.raises(ValidationError): + personalize_gui_page(_PAGE, _PROFILE) + + def test_unparseable_llm_response_becomes_generation_error(self): + chat, profile = _mock_llm(return_value="this is not json") + with chat, profile: + with pytest.raises(GenerationError): + personalize_gui_page(_PAGE, _PROFILE, openai_api_key="sk-test") + + def test_llm_response_without_components_list_raises(self): + chat, profile = _mock_llm(return_value='{"css": []}') + with chat, profile: + with pytest.raises(GenerationError): + personalize_gui_page(_PAGE, _PROFILE, openai_api_key="sk-test")