diff --git a/besser/generators/agents/agent_personalization.py b/besser/generators/agents/agent_personalization.py index e86bed2a9..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", 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. @@ -111,9 +114,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=DEFAULT_OPENAI_MODEL, 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. Returns a list of translated texts in the same order as input. """ if not isinstance(texts, (list, tuple)): @@ -170,7 +173,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=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'. @@ -331,13 +334,19 @@ 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: + 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'] - for i, msg in enumerate(personalized_messages): - personalized_messages[i] = translate_text_api(msg, target_language) + 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 +397,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 +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' + model_name = DEFAULT_OPENAI_MODEL else: model_name = model_name.strip() @@ -495,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", 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". @@ -551,7 +548,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=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 b93b8e074..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 ( @@ -228,6 +231,46 @@ 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. + """ + 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), + ) + return { + "guiPage": {"components": result["components"], "css": result["css"]}, + "source": "openai", + "model": result["model"], + "generatedAt": _utc_now_iso(), + } + + @router.get("/agent-config-manual-mapping") @handle_endpoint_errors("get_agent_config_manual_mapping") async def get_agent_config_manual_mapping( @@ -570,45 +613,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: @@ -1071,19 +1136,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") 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/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/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 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")