Skip to content
49 changes: 23 additions & 26 deletions besser/generators/agents/agent_personalization.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)):
Expand Down Expand Up @@ -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'.
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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

Expand All @@ -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()

Expand Down Expand Up @@ -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".
Expand Down Expand Up @@ -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).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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: <UserDiagram UML model JSON>
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(
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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/<slug>/`` 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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading