diff --git a/application/single_app/config.py b/application/single_app/config.py index a3200961..3bf48bd2 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -94,7 +94,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.240.066" +VERSION = "0.240.085" SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production') @@ -150,7 +150,6 @@ def get_allowed_extensions(enable_video=False, enable_audio=False): Args: enable_video: Whether video file support is enabled - Returns: set: Allowed file extensions """ diff --git a/application/single_app/functions_agent_payload.py b/application/single_app/functions_agent_payload.py index 7a448606..183900e1 100644 --- a/application/single_app/functions_agent_payload.py +++ b/application/single_app/functions_agent_payload.py @@ -131,6 +131,46 @@ def is_new_foundry_agent(agent: Dict[str, Any]) -> bool: return False +def get_agent_model_binding(agent: Dict[str, Any]) -> Dict[str, str]: + """Return normalized saved model binding fields for an agent.""" + if not isinstance(agent, dict): + return { + "endpoint_id": "", + "model_id": "", + "provider": "", + } + + return { + "endpoint_id": str(agent.get("model_endpoint_id") or "").strip(), + "model_id": str(agent.get("model_id") or "").strip(), + "provider": str(agent.get("model_provider") or "").strip().lower(), + } + + +def has_complete_agent_model_binding(agent: Dict[str, Any]) -> bool: + """Return True when the agent stores both endpoint and model identifiers.""" + binding = get_agent_model_binding(agent) + return bool(binding["endpoint_id"] and binding["model_id"]) + + +def has_agent_custom_connection_override(agent: Dict[str, Any]) -> bool: + """Return True when a local agent stores explicit legacy connection values.""" + if not isinstance(agent, dict): + return False + + has_direct_fields = any(str(agent.get(field) or "").strip() for field in _GPT_FIELDS) + apim_enabled = agent.get("enable_agent_gpt_apim") in [True, 1, "true", "True"] + has_apim_fields = apim_enabled and any( + str(agent.get(field) or "").strip() for field in _APIM_FIELDS + ) + return bool(has_direct_fields or has_apim_fields) + + +def can_agent_use_default_multi_endpoint_model(agent: Dict[str, Any]) -> bool: + """Return True when a local agent should inherit the saved admin default model.""" + return not is_azure_ai_foundry_agent(agent) and not has_agent_custom_connection_override(agent) + + def _normalize_text_fields(payload: Dict[str, Any]) -> None: for field in _TEXT_FIELDS: value = payload.get(field) diff --git a/application/single_app/functions_personal_agents.py b/application/single_app/functions_personal_agents.py index 5821d655..d6720d10 100644 --- a/application/single_app/functions_personal_agents.py +++ b/application/single_app/functions_personal_agents.py @@ -106,7 +106,7 @@ def get_personal_agent(user_id, agent_id): debug_print(f"Error fetching agent {agent_id} for user {user_id}: {e}") return None -def save_personal_agent(user_id, agent_data): +def save_personal_agent(user_id, agent_data, actor_user_id=None): """ Save or update a personal agent. @@ -118,6 +118,7 @@ def save_personal_agent(user_id, agent_data): dict: Saved agent data with ID """ try: + modifying_user_id = actor_user_id or user_id cleaned_agent = sanitize_agent_payload(agent_data) for field in ['name', 'display_name', 'description', 'instructions']: cleaned_agent.setdefault(field, '') @@ -159,7 +160,7 @@ def save_personal_agent(user_id, agent_data): # New agent cleaned_agent['created_by'] = user_id cleaned_agent['created_at'] = now - cleaned_agent['modified_by'] = user_id + cleaned_agent['modified_by'] = modifying_user_id cleaned_agent['modified_at'] = now cleaned_agent['user_id'] = user_id diff --git a/application/single_app/functions_settings.py b/application/single_app/functions_settings.py index d053e9e7..8afcd940 100644 --- a/application/single_app/functions_settings.py +++ b/application/single_app/functions_settings.py @@ -111,10 +111,7 @@ def get_settings(use_cosmos=False, include_source=False): 'multi_endpoint_migrated_at': None, 'multi_endpoint_migration_notice': { 'enabled': False, - 'message': ( - 'Multi-endpoint has been enabled and your existing AI endpoint was migrated. ' - 'Agents using the default connection may need to be updated to select a new model endpoint.' - ), + 'message': '', 'created_at': None }, 'azure_apim_gpt_endpoint': '', @@ -1041,6 +1038,9 @@ def get_user_settings(user_id): if 'personal_model_endpoints' not in doc['settings']: doc['settings']['personal_model_endpoints'] = [] + if 'showTutorialButtons' not in doc['settings']: + doc['settings']['showTutorialButtons'] = True + updated = True # Try to update email/display_name if missing and available in session user = session.get("user", {}) @@ -1082,6 +1082,7 @@ def get_user_settings(user_id): display_name = user.get("name") doc = {"id": user_id, "settings": {}} doc["settings"]["personal_model_endpoints"] = [] + doc["settings"]["showTutorialButtons"] = True if email: doc["email"] = email if display_name: @@ -1343,6 +1344,12 @@ def sanitize_settings_for_user(full_settings: dict) -> dict: str(full_settings.get('support_feedback_recipient_email') or '').strip() ) + if isinstance(sanitized.get('multi_endpoint_migration_notice'), dict): + sanitized['multi_endpoint_migration_notice'] = { + **sanitized['multi_endpoint_migration_notice'], + 'enabled': False, + } + return sanitized def sanitize_settings_for_logging(full_settings: dict) -> dict: diff --git a/application/single_app/route_backend_agents.py b/application/single_app/route_backend_agents.py index bd31d4a8..561f1d18 100644 --- a/application/single_app/route_backend_agents.py +++ b/application/single_app/route_backend_agents.py @@ -4,13 +4,25 @@ import uuid import logging import builtins -from flask import Blueprint, jsonify, request, current_app +from flask import Blueprint, jsonify, request, current_app, session +from config import ( + cosmos_global_agents_container, + cosmos_group_agents_container, + cosmos_personal_agents_container, +) from semantic_kernel_loader import get_agent_orchestration_types -from functions_settings import get_settings, update_settings, get_user_settings, update_user_settings +from functions_settings import get_settings, update_settings, get_user_settings, update_user_settings, sanitize_model_endpoints_for_frontend from functions_global_agents import get_global_agents, save_global_agent, delete_global_agent from functions_personal_agents import get_personal_agents, ensure_migration_complete, save_personal_agent, delete_personal_agent from functions_group import require_active_group, assert_group_role -from functions_agent_payload import sanitize_agent_payload, AgentPayloadError +from functions_agent_payload import ( + AgentPayloadError, + can_agent_use_default_multi_endpoint_model, + get_agent_model_binding, + has_agent_custom_connection_override, + is_azure_ai_foundry_agent, + sanitize_agent_payload, +) from functions_group_agents import ( get_group_agents, get_group_agent, @@ -28,6 +40,7 @@ log_agent_creation, log_agent_update, log_agent_deletion, + log_general_admin_action, ) bpa = Blueprint('admin_agents', __name__) @@ -118,6 +131,387 @@ def scope_matches(candidate): return None + +def _strip_cosmos_metadata(document): + if not isinstance(document, dict): + return {} + return {key: value for key, value in document.items() if not str(key).startswith('_')} + + +def _format_model_provider_label(provider): + normalized_provider = str(provider or '').strip().lower() + if normalized_provider == 'aifoundry': + return 'Foundry (classic)' + if normalized_provider == 'new_foundry': + return 'New Foundry' + return 'Azure OpenAI' + + +def _summarize_model_binding(endpoint_candidates, binding): + endpoint_id = str(binding.get('endpoint_id') or '').strip() + model_id = str(binding.get('model_id') or '').strip() + provider = str(binding.get('provider') or '').strip().lower() + + if not endpoint_id and not model_id: + return { + 'valid': False, + 'state': 'missing', + 'endpoint_id': '', + 'model_id': '', + 'provider': provider, + 'label': 'Not set', + } + + if not endpoint_id or not model_id: + return { + 'valid': False, + 'state': 'incomplete', + 'endpoint_id': endpoint_id, + 'model_id': model_id, + 'provider': provider, + 'label': 'Incomplete saved model selection', + } + + endpoint_cfg = next((candidate for candidate in endpoint_candidates if candidate.get('id') == endpoint_id), None) + if not endpoint_cfg: + return { + 'valid': False, + 'state': 'endpoint_missing', + 'endpoint_id': endpoint_id, + 'model_id': model_id, + 'provider': provider, + 'label': f'Missing endpoint: {endpoint_id}', + } + + if not endpoint_cfg.get('enabled', True): + return { + 'valid': False, + 'state': 'endpoint_disabled', + 'endpoint_id': endpoint_id, + 'model_id': model_id, + 'provider': provider, + 'label': f'Disabled endpoint: {endpoint_cfg.get("name") or endpoint_id}', + } + + models = endpoint_cfg.get('models', []) or [] + model_cfg = next((model for model in models if model.get('id') == model_id), None) + if not model_cfg: + return { + 'valid': False, + 'state': 'model_missing', + 'endpoint_id': endpoint_id, + 'model_id': model_id, + 'provider': provider, + 'label': f'Missing model: {model_id}', + } + + if not model_cfg.get('enabled', True): + return { + 'valid': False, + 'state': 'model_disabled', + 'endpoint_id': endpoint_id, + 'model_id': model_id, + 'provider': provider, + 'label': f'Disabled model: {model_cfg.get("displayName") or model_id}', + } + + resolved_provider = str(endpoint_cfg.get('provider') or provider or '').strip().lower() + endpoint_name = endpoint_cfg.get('name') or endpoint_cfg.get('connection', {}).get('endpoint') or endpoint_id + model_name = model_cfg.get('displayName') or model_cfg.get('deploymentName') or model_cfg.get('modelName') or model_id + scope_name = str(endpoint_cfg.get('scope') or '').strip().title() + scope_prefix = f'{scope_name} - ' if scope_name else '' + + return { + 'valid': True, + 'state': 'valid', + 'endpoint_id': endpoint_id, + 'model_id': model_id, + 'provider': resolved_provider, + 'label': f'{scope_prefix}{endpoint_name} / {model_name} ({_format_model_provider_label(resolved_provider)})', + } + + +def _binding_matches_default_model(binding_summary, default_model_info): + return bool( + binding_summary.get('valid') + and default_model_info.get('valid') + and binding_summary.get('endpoint_id') == default_model_info.get('endpoint_id') + and binding_summary.get('model_id') == default_model_info.get('model_id') + ) + + +def _build_agent_migration_key(scope, scope_id, agent_id, agent_name): + scope_value = str(scope or '').strip() + scope_id_value = str(scope_id or '').strip() + agent_id_value = str(agent_id or agent_name or '').strip() + return f'{scope_value}:{scope_id_value}:{agent_id_value}' + + +def _clear_legacy_agent_connection_override(agent): + if not isinstance(agent, dict): + return agent + + for field_name in ( + 'azure_openai_gpt_endpoint', + 'azure_openai_gpt_key', + 'azure_openai_gpt_deployment', + 'azure_openai_gpt_api_version', + 'azure_agent_apim_gpt_endpoint', + 'azure_agent_apim_gpt_subscription_key', + 'azure_agent_apim_gpt_deployment', + 'azure_agent_apim_gpt_api_version', + ): + agent[field_name] = '' + + agent['enable_agent_gpt_apim'] = False + return agent + + +def _build_default_model_info(settings): + default_selection = settings.get('default_model_selection', {}) or {} + default_endpoint_id = str(default_selection.get('endpoint_id') or '').strip() + default_model_id = str(default_selection.get('model_id') or '').strip() + default_provider = str(default_selection.get('provider') or '').strip().lower() + binding = { + 'endpoint_id': default_endpoint_id, + 'model_id': default_model_id, + 'provider': default_provider, + } + + if not default_endpoint_id or not default_model_id: + return { + 'configured': False, + 'valid': False, + 'endpoint_id': default_endpoint_id, + 'model_id': default_model_id, + 'provider': default_provider, + 'state': 'missing', + 'label': 'No default model selected', + } + + binding_summary = _summarize_model_binding(build_combined_model_endpoints(settings), binding) + return { + 'configured': True, + 'valid': binding_summary['valid'], + 'endpoint_id': default_endpoint_id, + 'model_id': default_model_id, + 'provider': binding_summary.get('provider') or default_provider, + 'state': binding_summary['state'], + 'label': binding_summary['label'] if binding_summary['valid'] else 'Saved default model is no longer available', + } + + +def _load_all_agent_records_for_default_migration(): + records = [] + + global_agents = list( + cosmos_global_agents_container.query_items( + query='SELECT * FROM c', + enable_cross_partition_query=True, + ) + ) + for agent in global_agents: + cleaned = _strip_cosmos_metadata(agent) + cleaned['is_global'] = True + cleaned['is_group'] = False + cleaned.setdefault('agent_type', 'local') + records.append({ + 'scope': 'global', + 'scope_id': '', + 'scope_label': 'Global', + 'agent': cleaned, + }) + + group_agents = list( + cosmos_group_agents_container.query_items( + query='SELECT * FROM c', + enable_cross_partition_query=True, + ) + ) + for agent in group_agents: + cleaned = _strip_cosmos_metadata(agent) + group_id = str(cleaned.get('group_id') or '').strip() + cleaned['is_global'] = False + cleaned['is_group'] = True + cleaned.setdefault('agent_type', 'local') + records.append({ + 'scope': 'group', + 'scope_id': group_id, + 'scope_label': group_id or 'Unknown group', + 'agent': cleaned, + }) + + personal_agents = list( + cosmos_personal_agents_container.query_items( + query='SELECT * FROM c', + enable_cross_partition_query=True, + ) + ) + for agent in personal_agents: + cleaned = _strip_cosmos_metadata(agent) + user_id = str(cleaned.get('user_id') or '').strip() + cleaned['is_global'] = False + cleaned['is_group'] = False + cleaned.setdefault('agent_type', 'local') + records.append({ + 'scope': 'personal', + 'scope_id': user_id, + 'scope_label': user_id or 'Unknown user', + 'agent': cleaned, + }) + + return records + + +def _get_endpoint_candidates_for_agent(settings, record, cache): + scope = record['scope'] + scope_id = record['scope_id'] + + if scope == 'group' and scope_id: + cache_key = f'group:{scope_id}' + if cache_key not in cache: + cache[cache_key] = build_combined_model_endpoints(settings, group_id=scope_id) + return cache[cache_key] + + if scope == 'personal' and scope_id: + cache_key = f'personal:{scope_id}' + if cache_key not in cache: + cache[cache_key] = build_combined_model_endpoints(settings, user_id=scope_id) + return cache[cache_key] + + if 'global' not in cache: + cache['global'] = build_combined_model_endpoints(settings) + return cache['global'] + + +def _classify_agent_for_default_model_migration(record, settings, default_model_info, endpoint_cache): + agent = record['agent'] + endpoint_candidates = _get_endpoint_candidates_for_agent(settings, record, endpoint_cache) + binding = get_agent_model_binding(agent) + binding_summary = _summarize_model_binding(endpoint_candidates, binding) + agent_name = str(agent.get('name') or '').strip() or 'Unnamed agent' + display_name = str(agent.get('display_name') or '').strip() or agent_name + agent_id = str(agent.get('id') or '').strip() + agent_type = str(agent.get('agent_type') or 'local').strip().lower() or 'local' + selection_key = _build_agent_migration_key(record['scope'], record['scope_id'], agent_id, agent_name) + selected_by_default = False + can_force_migrate = False + migration_action = 'none' + + if is_azure_ai_foundry_agent(agent): + migration_status = 'manual_review' + reason = 'Foundry agents are managed separately and cannot be rebound from this tool.' + elif binding_summary['valid'] and _binding_matches_default_model(binding_summary, default_model_info): + migration_status = 'already_migrated' + reason = 'Agent is already bound to the saved default model.' + elif has_agent_custom_connection_override(agent): + migration_status = 'manual_review' + if default_model_info['valid']: + can_force_migrate = True + migration_action = 'force_override_to_default' + reason = 'Agent has explicit custom connection values. Select it in review to override those settings and bind it to the saved default model.' + else: + reason = 'Save a valid default model before overriding explicit custom connection values.' + elif binding_summary['valid']: + migration_status = 'manual_review' + if default_model_info['valid']: + can_force_migrate = True + migration_action = 'rebind_to_default' + reason = 'Agent is already bound to a different model than the saved default. Select it in review to rebind it intentionally.' + else: + reason = 'Save a valid default model before rebinding agents to a new default.' + elif default_model_info['valid'] and can_agent_use_default_multi_endpoint_model(agent): + migration_status = 'ready_to_migrate' + reason = 'Agent uses inherited/default routing and can be bound to the saved admin default model.' + selected_by_default = True + migration_action = 'apply_default' + else: + migration_status = 'needs_default_model' + reason = 'Save a valid default model before migrating inherited agents.' + + return { + 'scope': record['scope'], + 'scope_id': record['scope_id'], + 'scope_label': record['scope_label'], + 'agent_id': agent_id, + 'agent_name': agent_name, + 'agent_display_name': display_name, + 'agent_type': agent_type, + 'migration_status': migration_status, + 'reason': reason, + 'current_binding_state': binding_summary['state'], + 'current_binding_label': binding_summary['label'], + 'selection_key': selection_key, + 'selected_by_default': selected_by_default, + 'can_force_migrate': can_force_migrate, + 'migration_action': migration_action, + 'can_select': bool(selected_by_default or can_force_migrate), + 'can_migrate': bool(selected_by_default or can_force_migrate), + '_raw_agent': agent, + } + + +def _build_default_model_agent_migration_preview(settings): + default_model_info = _build_default_model_info(settings) + endpoint_cache = {} + records = [ + _classify_agent_for_default_model_migration(record, settings, default_model_info, endpoint_cache) + for record in _load_all_agent_records_for_default_migration() + ] + + status_order = { + 'ready_to_migrate': 0, + 'manual_review': 1, + 'needs_default_model': 2, + 'already_migrated': 3, + } + scope_order = { + 'global': 0, + 'group': 1, + 'personal': 2, + } + records.sort( + key=lambda record: ( + status_order.get(record['migration_status'], 99), + scope_order.get(record['scope'], 99), + record['scope_label'].lower(), + record['agent_display_name'].lower(), + ) + ) + + summary = { + 'total_agents': len(records), + 'ready_to_migrate': sum(record['migration_status'] == 'ready_to_migrate' for record in records), + 'needs_default_model': sum(record['migration_status'] == 'needs_default_model' for record in records), + 'manual_review': sum(record['migration_status'] == 'manual_review' for record in records), + 'already_migrated': sum(record['migration_status'] == 'already_migrated' for record in records), + 'selectable_override': sum(record['migration_status'] == 'manual_review' and record['can_force_migrate'] for record in records), + 'selected_by_default': sum(record['selected_by_default'] for record in records), + 'selectable_total': sum(record['can_select'] for record in records), + } + summary['pending_action'] = summary['ready_to_migrate'] + summary['needs_default_model'] + summary['selectable_override'] + + return { + 'default_model': default_model_info, + 'summary': summary, + 'agents': [{key: value for key, value in record.items() if key != '_raw_agent'} for record in records], + 'records': records, + 'migration_notice_enabled': bool((settings.get('multi_endpoint_migration_notice', {}) or {}).get('enabled', False)), + } + + +def _maybe_disable_multi_endpoint_migration_notice(settings, preview): + if preview['summary']['ready_to_migrate'] or preview['summary']['needs_default_model']: + return False + + notice = settings.get('multi_endpoint_migration_notice', {}) or {} + if not notice.get('enabled', False): + return False + + notice['enabled'] = False + update_settings({'multi_endpoint_migration_notice': notice}) + return True + # === AGENT GUID GENERATION ENDPOINT === @bpa.route('/api/agents/generate_id', methods=['GET']) @swagger_route( @@ -538,6 +932,7 @@ def set_user_selected_agent(): "group_name": matched_agent.get('group_name') } settings_to_update['selected_agent'] = agent + settings_to_update['enable_agents'] = True update_user_settings(user_id, settings_to_update) log_event("User selected agent set", extra={"user_id": user_id, "selected_agent": agent}) return jsonify({'success': True}) @@ -635,6 +1030,162 @@ def list_agents(): log_event(f"Error listing agents: {e}", level=logging.ERROR) return jsonify({'error': 'Failed to list agents.'}), 500 + +@bpa.route('/api/admin/agents/default-model-migration/preview', methods=['GET']) +@swagger_route( + security=get_auth_security() +) +@login_required +@admin_required +def preview_default_model_agent_migration(): + settings = get_settings() + if not settings.get('enable_semantic_kernel', False): + return jsonify({'error': 'Enable Agents before using default-model review.'}), 400 + if not settings.get('enable_multi_model_endpoints', False): + return jsonify({'error': 'Multi-endpoint model management is not enabled.'}), 400 + + preview = _build_default_model_agent_migration_preview(settings) + return jsonify({key: value for key, value in preview.items() if key != 'records'}) + + +@bpa.route('/api/admin/agents/default-model-migration/run', methods=['POST']) +@swagger_route( + security=get_auth_security() +) +@login_required +@admin_required +def run_default_model_agent_migration(): + settings = get_settings() + if not settings.get('enable_semantic_kernel', False): + return jsonify({'error': 'Enable Agents before using default-model review.'}), 400 + if not settings.get('enable_multi_model_endpoints', False): + return jsonify({'error': 'Multi-endpoint model management is not enabled.'}), 400 + + request_data = request.get_json(silent=True) or {} + requested_keys = [] + for value in request_data.get('selected_agent_keys', []) or []: + key = str(value or '').strip() + if key and key not in requested_keys: + requested_keys.append(key) + + preview = _build_default_model_agent_migration_preview(settings) + default_model = preview['default_model'] + if not default_model['valid']: + return jsonify({ + 'error': 'A saved default model is required before migrating agents.', + 'preview': {key: value for key, value in preview.items() if key != 'records'}, + }), 400 + + selectable_records = { + record['selection_key']: record + for record in preview['records'] + if record.get('can_select') + } + + invalid_requested_keys = [key for key in requested_keys if key not in selectable_records] + if invalid_requested_keys: + return jsonify({ + 'error': 'One or more selected agents cannot be migrated to the saved default model.', + 'invalid_selected_agent_keys': invalid_requested_keys, + 'preview': {key: value for key, value in preview.items() if key != 'records'}, + }), 400 + + if requested_keys: + candidates = [selectable_records[key] for key in requested_keys] + else: + candidates = [record for record in preview['records'] if record.get('selected_by_default')] + + if not candidates: + return jsonify({ + 'error': 'Select at least one eligible agent to migrate.', + 'preview': {key: value for key, value in preview.items() if key != 'records'}, + }), 400 + + migrated_by_scope = { + 'global': 0, + 'group': 0, + 'personal': 0, + } + failures = [] + admin_user_id = str(get_current_user_id() or '') + admin_profile = session.get('user', {}) or {} + admin_email = admin_profile.get('preferred_username', admin_profile.get('email', 'unknown')) + override_count = sum(record.get('migration_status') == 'manual_review' for record in candidates) + + for record in candidates: + scope = record['scope'] + scope_id = record['scope_id'] + agent = dict(record['_raw_agent']) + if record.get('migration_action') == 'force_override_to_default': + agent = _clear_legacy_agent_connection_override(agent) + agent['model_endpoint_id'] = default_model['endpoint_id'] + agent['model_id'] = default_model['model_id'] + agent['model_provider'] = default_model['provider'] + + try: + if scope == 'global': + result = save_global_agent(agent, user_id=admin_user_id) + elif scope == 'group': + result = save_group_agent(scope_id, agent, user_id=admin_user_id) + else: + result = save_personal_agent(scope_id, agent, actor_user_id=admin_user_id) + + if not result: + raise ValueError('Agent save did not return a result.') + + migrated_by_scope[scope] += 1 + except Exception as exc: + log_event( + f"Default-model migration failed for agent {record['agent_name']}: {exc}", + level=logging.ERROR, + exceptionTraceback=True, + ) + failures.append({ + 'scope': scope, + 'scope_id': scope_id, + 'agent_id': record['agent_id'], + 'agent_name': record['agent_name'], + 'error': str(exc), + }) + + migrated_count = sum(migrated_by_scope.values()) + if migrated_count: + setattr(builtins, 'kernel_reload_needed', True) + + refreshed_settings = get_settings() + refreshed_preview = _build_default_model_agent_migration_preview(refreshed_settings) + notice_cleared = _maybe_disable_multi_endpoint_migration_notice(refreshed_settings, refreshed_preview) + if notice_cleared: + refreshed_settings = get_settings() + refreshed_preview = _build_default_model_agent_migration_preview(refreshed_settings) + + log_general_admin_action( + admin_user_id=admin_user_id, + admin_email=admin_email, + action='Applied saved default model to selected agents', + description=f'Applied the saved default model endpoint to {migrated_count} selected agents.', + additional_context={ + 'migrated_by_scope': migrated_by_scope, + 'failed_count': len(failures), + 'selected_agent_count': len(candidates), + 'override_count': override_count, + 'default_model_endpoint_id': default_model['endpoint_id'], + 'default_model_id': default_model['model_id'], + 'notice_cleared': notice_cleared, + }, + ) + + return jsonify({ + 'success': len(failures) == 0, + 'selected_agent_count': len(candidates), + 'migrated_count': migrated_count, + 'override_count': override_count, + 'migrated_by_scope': migrated_by_scope, + 'failed': failures, + 'notice_cleared': notice_cleared, + 'preview': {key: value for key, value in refreshed_preview.items() if key != 'records'}, + }) + @bpa.route('/api/admin/agents', methods=['POST']) @swagger_route( security=get_auth_security() diff --git a/application/single_app/route_backend_chats.py b/application/single_app/route_backend_chats.py index 0da68dad..8bd44f01 100644 --- a/application/single_app/route_backend_chats.py +++ b/application/single_app/route_backend_chats.py @@ -35,6 +35,7 @@ from functions_agents import get_agent_id_by_name from functions_group import find_group_by_id, get_group_model_endpoints, get_user_role_in_group from functions_chat import * +from functions_content import generate_embedding, generate_embeddings_batch from functions_conversation_metadata import collect_conversation_metadata, update_conversation_with_metadata from functions_conversation_unread import mark_conversation_unread from functions_debug import debug_print @@ -70,6 +71,441 @@ def _strip_agent_citation_artifact_refs(agent_citations): return compact_citations +FACT_MEMORY_TYPE_FACT = 'fact' +FACT_MEMORY_TYPE_INSTRUCTION = 'instruction' +FACT_MEMORY_TYPE_LEGACY_DESCRIBER = 'describer' + + +def normalize_fact_memory_type(memory_type): + normalized = str(memory_type or '').strip().lower() + if normalized == FACT_MEMORY_TYPE_LEGACY_DESCRIBER: + return FACT_MEMORY_TYPE_FACT + if normalized in {FACT_MEMORY_TYPE_FACT, FACT_MEMORY_TYPE_INSTRUCTION}: + return normalized + return FACT_MEMORY_TYPE_FACT + + +def _normalize_fact_memory_item(fact_item): + normalized_item = dict(fact_item or {}) + normalized_item['memory_type'] = normalize_fact_memory_type(normalized_item.get('memory_type')) + normalized_item['value'] = str(normalized_item.get('value') or '').strip() + return normalized_item + + +def _is_embedding_vector(candidate): + return ( + isinstance(candidate, list) + and bool(candidate) + and all(isinstance(value, (int, float)) for value in candidate) + ) + + +def _coerce_embedding_result(embedding_result): + if not embedding_result: + return None, None + if isinstance(embedding_result, tuple): + return embedding_result[0], embedding_result[1] + return embedding_result, None + + +def _build_fact_memory_fact_payload(matched_facts): + fact_payload = [] + for fact in matched_facts or []: + fact_payload.append({ + 'id': fact.get('id'), + 'value': fact.get('value'), + 'memory_type': normalize_fact_memory_type(fact.get('memory_type')), + 'updated_at': fact.get('updated_at') or fact.get('created_at'), + 'conversation_id': fact.get('conversation_id'), + 'agent_id': fact.get('agent_id'), + 'similarity': fact.get('similarity'), + }) + return fact_payload + + +def _cosine_similarity(left_vector, right_vector): + if not _is_embedding_vector(left_vector) or not _is_embedding_vector(right_vector): + return 0.0 + if len(left_vector) != len(right_vector): + return 0.0 + + left_norm = sum(value * value for value in left_vector) ** 0.5 + right_norm = sum(value * value for value in right_vector) ** 0.5 + if left_norm == 0 or right_norm == 0: + return 0.0 + + dot_product = sum(left * right for left, right in zip(left_vector, right_vector)) + return float(dot_product / (left_norm * right_norm)) + + +def _backfill_missing_fact_memory_embeddings(fact_store, facts): + missing_items = [] + for fact in facts or []: + if fact.get('memory_type') != FACT_MEMORY_TYPE_FACT: + continue + if _is_embedding_vector(fact.get('value_embedding')): + continue + value = str(fact.get('value') or '').strip() + if not value: + continue + missing_items.append((fact, value)) + + if not missing_items: + return 0 + + try: + embedding_results = generate_embeddings_batch([value for _, value in missing_items]) + except Exception as exc: + debug_print(f"[Fact Memory] Failed to backfill memory embeddings: {exc}") + return 0 + + updated_count = 0 + for (fact, _), embedding_result in zip(missing_items, embedding_results): + embedding_vector, token_usage = _coerce_embedding_result(embedding_result) + if not embedding_vector: + continue + + updated_fact = fact_store.update_fact_embedding( + scope_id=fact.get('scope_id'), + fact_id=fact.get('id'), + value_embedding=embedding_vector, + embedding_model=(token_usage or {}).get('model_deployment_name') if isinstance(token_usage, dict) else None, + ) + if updated_fact: + fact.update(updated_fact) + else: + fact['value_embedding'] = embedding_vector + updated_count += 1 + + return updated_count + + +def build_instruction_memory_citation(applied_facts): + fact_payload = _build_fact_memory_fact_payload(applied_facts) + return { + 'tool_name': 'Instruction Memory', + 'function_name': 'apply_instructions', + 'plugin_name': 'fact_memory', + 'function_arguments': make_json_serializable({ + 'memory_type': FACT_MEMORY_TYPE_INSTRUCTION, + 'applied_count': len(fact_payload), + }), + 'function_result': make_json_serializable({ + 'facts': fact_payload, + }), + 'timestamp': datetime.utcnow().isoformat(), + 'success': True, + } + + +def build_fact_memory_citation(query_text, matched_facts, search_mode): + fact_payload = _build_fact_memory_fact_payload(matched_facts) + return { + 'tool_name': 'Fact Memory Recall', + 'function_name': 'search_facts', + 'plugin_name': 'fact_memory', + 'function_arguments': make_json_serializable({ + 'query': str(query_text or '').strip(), + 'search_mode': search_mode, + 'match_count': len(fact_payload), + 'memory_type': FACT_MEMORY_TYPE_FACT, + }), + 'function_result': make_json_serializable({ + 'facts': fact_payload, + }), + 'timestamp': datetime.utcnow().isoformat(), + 'success': True, + } + + +def build_instruction_memory_payload( + scope_id, + scope_type, + enabled=True, + result_limit=8, +): + payload = { + 'context_messages': [], + 'citation': None, + 'thought_content': None, + 'thought_detail': None, + 'matched_facts': [], + 'total_available': 0, + } + if not enabled or not scope_id or not scope_type: + return payload + + fact_store = FactMemoryStore() + instruction_facts = [ + _normalize_fact_memory_item(fact) + for fact in fact_store.list_facts( + scope_type=scope_type, + scope_id=scope_id, + memory_type=FACT_MEMORY_TYPE_INSTRUCTION, + ) + ] + payload['total_available'] = len(instruction_facts) + + applied_facts = [] + for fact in instruction_facts: + if not fact.get('value'): + continue + applied_facts.append(fact) + if len(applied_facts) >= max(1, int(result_limit or 8)): + break + + if not applied_facts: + return payload + + instruction_lines = [f"- {fact.get('value')}" for fact in applied_facts] + instruction_block = "\n".join(instruction_lines) + payload['matched_facts'] = applied_facts + payload['context_messages'].append({ + 'role': 'system', + 'content': ( + 'Apply these saved user instruction memories to every response in this conversation. ' + 'Treat them like durable user-specific response preferences unless the user overrides them in the current message.\n' + f"\n{instruction_block}\n" + ) + }) + payload['citation'] = build_instruction_memory_citation(applied_facts) + payload['thought_content'] = ( + f"Applied {len(applied_facts)} instruction " + f"{'memory' if len(applied_facts) == 1 else 'memories'}" + ) + payload['thought_detail'] = ' | '.join( + str(fact.get('value') or '').strip()[:80] + for fact in applied_facts[:3] + if str(fact.get('value') or '').strip() + ) + return payload + + +def retrieve_relevant_fact_memory_entries( + scope_id, + scope_type, + query_text=None, + conversation_id=None, + agent_id=None, + enabled=True, + result_limit=4, +): + result = { + 'matched_facts': [], + 'search_mode': 'disabled', + 'total_available': 0, + 'query_text': str(query_text or '').strip(), + 'embedding_backfill_count': 0, + } + if not enabled or not scope_id or not scope_type: + return result + + query_text = result['query_text'] + if not query_text: + result['search_mode'] = 'missing_query' + return result + + fact_store = FactMemoryStore() + query_kwargs = { + 'scope_type': scope_type, + 'scope_id': scope_id, + 'memory_type': FACT_MEMORY_TYPE_FACT, + } + if conversation_id: + query_kwargs['conversation_id'] = conversation_id + if agent_id: + query_kwargs['agent_id'] = agent_id + + facts = [ + _normalize_fact_memory_item(fact) + for fact in fact_store.list_facts(**query_kwargs) + ] + result['total_available'] = len(facts) + if not facts: + result['search_mode'] = 'empty' + return result + + result['embedding_backfill_count'] = _backfill_missing_fact_memory_embeddings(fact_store, facts) + + try: + query_embedding_result = generate_embedding(query_text) + except Exception as exc: + debug_print(f"[Fact Memory] Failed to generate query embedding: {exc}") + result['search_mode'] = 'embedding_unavailable' + return result + + query_embedding, _ = _coerce_embedding_result(query_embedding_result) + if not query_embedding: + result['search_mode'] = 'embedding_unavailable' + return result + + candidates = [] + for fact in facts: + value = str(fact.get('value') or '').strip() + embedding_vector = fact.get('value_embedding') + if not value or not _is_embedding_vector(embedding_vector): + continue + + similarity = _cosine_similarity(query_embedding, embedding_vector) + if similarity <= 0: + continue + + normalized_fact = dict(fact) + normalized_fact['similarity'] = round(similarity, 6) + candidates.append(normalized_fact) + + if not candidates: + result['search_mode'] = 'embedding' + return result + + candidates.sort( + key=lambda fact: ( + float(fact.get('similarity') or 0.0), + str(fact.get('updated_at') or fact.get('created_at') or ''), + ), + reverse=True, + ) + safe_limit = max(1, int(result_limit or 4)) + result['matched_facts'] = candidates[:safe_limit] + result['search_mode'] = 'embedding' + return result + + +def build_fact_memory_recall_payload( + scope_id, + scope_type, + query_text=None, + conversation_id=None, + agent_id=None, + enabled=True, + include_metadata=False, + result_limit=4, +): + retrieval = retrieve_relevant_fact_memory_entries( + scope_id=scope_id, + scope_type=scope_type, + query_text=query_text, + conversation_id=conversation_id, + agent_id=agent_id, + enabled=enabled, + result_limit=result_limit, + ) + + payload = { + 'context_messages': [], + 'citation': None, + 'thought_content': None, + 'thought_detail': None, + **retrieval, + } + matched_facts = retrieval.get('matched_facts', []) + + if not matched_facts: + if retrieval.get('total_available', 0) > 0 and enabled: + payload['thought_content'] = 'Fact memory search found no relevant facts' + payload['thought_detail'] = ( + f"mode={retrieval.get('search_mode', 'embedding')}; " + f"query={str(query_text or '').strip()[:80]}; " + f"available={retrieval.get('total_available', 0)}" + ) + return payload + + if include_metadata: + payload['context_messages'].append({ + 'role': 'system', + 'content': ( + f"\n\n\n" + f"\n\n" + ) + }) + + fact_lines = [f"- {fact.get('value')}" for fact in matched_facts if fact.get('value')] + if fact_lines: + fact_block = "\n".join(fact_lines) + payload['context_messages'].append({ + 'role': 'system', + 'content': ( + 'Retrieved saved facts relevant to the current request. ' + 'Use them only when they directly help answer the user.\n' + f"\n{fact_block}\n" + ) + }) + + fact_preview = ' | '.join( + str(fact.get('value') or '').strip()[:80] + for fact in matched_facts[:3] + if str(fact.get('value') or '').strip() + ) + payload['citation'] = build_fact_memory_citation( + query_text=query_text, + matched_facts=matched_facts, + search_mode=retrieval.get('search_mode', 'embedding'), + ) + payload['thought_content'] = ( + f"Fact memory search found {len(matched_facts)} relevant " + f"{'fact' if len(matched_facts) == 1 else 'facts'}" + ) + payload['thought_detail'] = ( + f"mode={retrieval.get('search_mode', 'embedding')}; " + f"query={str(query_text or '').strip()[:80]}; " + f"matched={len(matched_facts)} of {retrieval.get('total_available', 0)}; " + f"values={fact_preview}" + ) + return payload + + +def build_fact_memory_prompt_payload( + scope_id, + scope_type, + query_text=None, + conversation_id=None, + agent_id=None, + enabled=True, + include_metadata=False, + instruction_limit=8, + fact_limit=4, +): + instruction_payload = build_instruction_memory_payload( + scope_id=scope_id, + scope_type=scope_type, + enabled=enabled, + result_limit=instruction_limit, + ) + recall_payload = build_fact_memory_recall_payload( + scope_id=scope_id, + scope_type=scope_type, + query_text=query_text, + conversation_id=conversation_id, + agent_id=agent_id, + enabled=enabled, + include_metadata=include_metadata, + result_limit=fact_limit, + ) + + context_messages = [] + thoughts = [] + citations = [] + + for payload in (instruction_payload, recall_payload): + context_messages.extend(payload.get('context_messages', [])) + if payload.get('thought_content'): + thoughts.append({ + 'step_type': 'fact_memory', + 'content': payload['thought_content'], + 'detail': payload.get('thought_detail'), + }) + if payload.get('citation'): + citations.append(payload['citation']) + + return { + 'context_messages': context_messages, + 'thoughts': thoughts, + 'citations': citations, + 'instruction_payload': instruction_payload, + 'recall_payload': recall_payload, + } + + def persist_agent_citation_artifacts( conversation_id, assistant_message_id, @@ -108,6 +544,136 @@ def persist_agent_citation_artifacts( return _strip_agent_citation_artifact_refs(compact_citations) +def _load_user_message_response_context( + conversation_id, + user_message_id, + fallback_thread_id=None, + fallback_previous_thread_id=None, +): + """Return user/thread metadata for assistant-style responses.""" + response_context = { + 'user_info': None, + 'thread_id': fallback_thread_id, + 'previous_thread_id': fallback_previous_thread_id, + } + + try: + user_message_doc = cosmos_messages_container.read_item( + item=user_message_id, + partition_key=conversation_id, + ) + metadata = user_message_doc.get('metadata') or {} + thread_info = metadata.get('thread_info') or {} + + response_context['user_info'] = metadata.get('user_info') + response_context['thread_id'] = thread_info.get('thread_id') or fallback_thread_id + + if 'previous_thread_id' in thread_info: + response_context['previous_thread_id'] = thread_info.get('previous_thread_id') + except Exception as exc: + debug_print( + f"[Threading] Could not load response context for user message {user_message_id}: {exc}" + ) + + return response_context + + +def _build_safety_message_doc( + conversation_id, + message_id, + content, + response_context, + thread_attempt, +): + """Build a persisted safety message aligned with the active conversation thread.""" + return make_json_serializable({ + 'id': message_id, + 'conversation_id': conversation_id, + 'role': 'safety', + 'content': content, + 'timestamp': datetime.utcnow().isoformat(), + 'model_deployment_name': None, + 'metadata': { + 'user_info': response_context.get('user_info'), + 'thread_info': { + 'thread_id': response_context.get('thread_id'), + 'previous_thread_id': response_context.get('previous_thread_id'), + 'active_thread': True, + 'thread_attempt': thread_attempt, + }, + }, + }) + + +def _build_fact_memory_context_lines( + scope_id, + scope_type, + query_text=None, + conversation_id=None, + agent_id=None, + enabled=True, + result_limit=4, +): + """Build a flat fact-memory context block for the current scope.""" + prompt_payload = build_fact_memory_prompt_payload( + scope_id=scope_id, + scope_type=scope_type, + query_text=query_text, + conversation_id=conversation_id, + agent_id=agent_id, + enabled=enabled, + include_metadata=False, + instruction_limit=8, + fact_limit=result_limit, + ) + + fact_lines = [] + instruction_facts = prompt_payload.get('instruction_payload', {}).get('matched_facts', []) + if instruction_facts: + fact_lines.append('[Instruction Memory]') + fact_lines.extend( + f"- {fact.get('value')}" + for fact in instruction_facts + if fact.get('value') + ) + + fact_memories = prompt_payload.get('recall_payload', {}).get('matched_facts', []) + if fact_memories: + if fact_lines: + fact_lines.append('') + fact_lines.append('[Fact Memory]') + fact_lines.extend( + f"- {fact.get('value')}" + for fact in fact_memories + if fact.get('value') + ) + + if not fact_lines: + return "" + return "\n".join(fact_lines) + + +def build_tabular_fact_memory_messages( + scope_id, + scope_type, + query_text=None, + conversation_id=None, + agent_id=None, + enabled=True, +): + """Return system-message payloads that expose fact memory to mini SK analysis.""" + prompt_payload = build_fact_memory_prompt_payload( + scope_id=scope_id, + scope_type=scope_type, + query_text=query_text, + conversation_id=conversation_id, + agent_id=agent_id, + enabled=enabled, + include_metadata=True, + ) + return prompt_payload.get('context_messages', []) + + def get_tabular_discovery_function_names(): """Return discovery-oriented tabular function names from the plugin.""" from semantic_kernel_plugins.tabular_processing_plugin import TabularProcessingPlugin @@ -3349,6 +3915,7 @@ async def run_tabular_sk_analysis(user_question, tabular_filenames, user_id, from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior from semantic_kernel.connectors.ai.open_ai.prompt_execution_settings.azure_chat_prompt_execution_settings import AzureChatPromptExecutionSettings from semantic_kernel.contents.chat_history import ChatHistory as SKChatHistory + from semantic_kernel_plugins.fact_memory_plugin import FactMemoryPlugin from semantic_kernel_plugins.tabular_processing_plugin import TabularProcessingPlugin try: @@ -3356,6 +3923,9 @@ async def run_tabular_sk_analysis(user_question, tabular_filenames, user_id, execution_mode = execution_mode if execution_mode in {'analysis', 'schema_summary', 'entity_lookup'} else 'analysis' schema_summary_mode = execution_mode == 'schema_summary' entity_lookup_mode = execution_mode == 'entity_lookup' + fact_memory_enabled = bool(settings.get('enable_fact_memory_plugin', False)) + fact_memory_scope_id = group_id or user_id + fact_memory_scope_type = 'group' if group_id else 'user' analysis_file_contexts = normalize_tabular_file_contexts_for_analysis( tabular_filenames=tabular_filenames, tabular_file_contexts=tabular_file_contexts, @@ -3373,6 +3943,8 @@ async def run_tabular_sk_analysis(user_question, tabular_filenames, user_id, kernel = SKKernel() tabular_plugin = TabularProcessingPlugin() kernel.add_plugin(tabular_plugin, plugin_name="tabular_processing") + if fact_memory_enabled: + kernel.add_plugin(FactMemoryPlugin(), plugin_name="fact_memory") # 2. Create chat service using same config as main chat enable_gpt_apim = settings.get('enable_gpt_apim', False) @@ -3887,6 +4459,15 @@ def build_system_prompt(force_tool_use=False, tool_error_messages=None, execution_gap_messages=previous_execution_gap_messages, discovery_feedback_messages=previous_discovery_feedback_messages, )) + for system_message in build_tabular_fact_memory_messages( + scope_id=fact_memory_scope_id, + scope_type=fact_memory_scope_type, + query_text=user_question, + conversation_id=conversation_id, + agent_id=None, + enabled=fact_memory_enabled, + ): + chat_history.add_system_message(system_message['content']) chat_history.add_user_message( f"Analyze the tabular data to answer: {user_question}\n" @@ -5177,50 +5758,38 @@ def consume_stream(): } ) - def get_facts_for_context(scope_id, scope_type, conversation_id: str = None, agent_id: str = None): - if not scope_id or not scope_type: - return "" - fact_store = FactMemoryStore() - kwargs = dict( - scope_type=scope_type, + def get_facts_for_context(scope_id, scope_type, query_text: str = None, conversation_id: str = None, agent_id: str = None, enabled: bool = True): + return _build_fact_memory_context_lines( scope_id=scope_id, + scope_type=scope_type, + query_text=query_text, + conversation_id=conversation_id, + agent_id=agent_id, + enabled=enabled, ) - if agent_id: - kwargs['agent_id'] = agent_id - if conversation_id: - kwargs['conversation_id'] = conversation_id - facts = fact_store.get_facts(**kwargs) - if not facts: - return "" - fact_lines = [] - for fact in facts: - value = str(fact.get('value') or '').strip() - if value: - fact_lines.append(f"- {value}") - if not fact_lines: - return "" - fact_lines.append(f"- agent_id: {agent_id or 'None'}") - fact_lines.append(f"- scope_type: {scope_type}") - fact_lines.append(f"- scope_id: {scope_id}") - fact_lines.append(f"- conversation_id: {conversation_id or 'None'}") - return "\n".join(fact_lines) - - def inject_fact_memory_context(conversation_history, scope_id, scope_type, conversation_id: str = None, agent_id: str = None): - facts = get_facts_for_context( + + def inject_fact_memory_context( + conversation_history, + scope_id, + scope_type, + query_text: str = None, + conversation_id: str = None, + agent_id: str = None, + enabled: bool = True, + include_metadata: bool = False, + ): + prompt_payload = build_fact_memory_prompt_payload( scope_id=scope_id, scope_type=scope_type, + query_text=query_text, conversation_id=conversation_id, agent_id=agent_id, + enabled=enabled, + include_metadata=include_metadata, ) - if facts: - conversation_history.insert(0, { - "role": "system", - "content": f"\n{facts}\n" - }) - conversation_history.insert(0, { - "role": "system", - "content": f"""\n\n\n\n\n""" - }) + for message in reversed(prompt_payload.get('context_messages', [])): + conversation_history.insert(0, message) + return prompt_payload @app.route('/api/chat', methods=['POST']) @swagger_route(security=get_auth_security()) @@ -5928,6 +6497,16 @@ def result_requires_message_reload(result: Any) -> bool: thread_id=current_user_thread_id, user_id=user_id ) + assistant_thread_attempt = retry_thread_attempt if is_retry else 1 + response_message_context = _load_user_message_response_context( + conversation_id=conversation_id, + user_message_id=user_message_id, + fallback_thread_id=current_user_thread_id, + fallback_previous_thread_id=previous_thread_id, + ) + user_info_for_assistant = response_message_context.get('user_info') + user_thread_id = response_message_context.get('thread_id') + user_previous_thread_id = response_message_context.get('previous_thread_id') # region 3 - Content Safety # --------------------------------------------------------------------- @@ -5982,7 +6561,14 @@ def result_requires_message_reload(result: Any) -> bool: 'blocklist_matches': blocklist_matches, 'timestamp': datetime.utcnow().isoformat(), 'reason': "; ".join(block_reasons), - 'metadata': {} + 'metadata': { + 'message_id': assistant_message_id, + 'thread_info': { + 'thread_id': response_message_context.get('thread_id'), + 'previous_thread_id': response_message_context.get('previous_thread_id'), + 'thread_attempt': assistant_thread_attempt, + }, + } } cosmos_safety_container.upsert_item(safety_item) @@ -6004,17 +6590,13 @@ def result_requires_message_reload(result: Any) -> bool: ) # Insert a special "role": "safety" or "blocked" - safety_message_id = f"{conversation_id}_safety_{int(time.time())}_{random.randint(1000,9999)}" - - safety_doc = { - 'id': safety_message_id, - 'conversation_id': conversation_id, - 'role': 'safety', - 'content': blocked_msg_content.strip(), - 'timestamp': datetime.utcnow().isoformat(), - 'model_deployment_name': None, - 'metadata': {}, # No metadata needed for safety messages - } + safety_doc = _build_safety_message_doc( + conversation_id=conversation_id, + message_id=assistant_message_id, + content=blocked_msg_content.strip(), + response_context=response_message_context, + thread_attempt=assistant_thread_attempt, + ) cosmos_messages_container.upsert_item(safety_doc) # Update conversation's last_updated @@ -6025,11 +6607,12 @@ def result_requires_message_reload(result: Any) -> bool: return jsonify({ 'reply': blocked_msg_content.strip(), 'blocked': True, + 'role': 'safety', 'triggered_categories': triggered_categories, 'blocklist_matches': blocklist_matches, 'conversation_id': conversation_id, 'conversation_title': conversation_item['title'], - 'message_id': safety_message_id + 'message_id': assistant_message_id }), 200 except HttpResponseError as e: @@ -7373,6 +7956,27 @@ async def run_sk_call(callable_obj, *args, **kwargs): log_event(f"[SKChat] No agents loaded - proceeding in model-only mode", level=logging.INFO) log_event(f"[SKChat] Semantic Kernel enabled. Per-user mode: {per_user_semantic_kernel}, Multi-agent orchestration: {enable_multi_agent_orchestration}, agents enabled: {user_enable_agents}") + + fact_memory_enabled = bool(settings.get('enable_fact_memory_plugin', False)) + fact_memory_payload = inject_fact_memory_context( + conversation_history=conversation_history_for_api, + scope_id=scope_id, + scope_type=scope_type, + query_text=user_message, + conversation_id=conversation_id, + agent_id=None, + enabled=fact_memory_enabled, + include_metadata=bool(enable_semantic_kernel and user_enable_agents), + ) + for thought in fact_memory_payload.get('thoughts', []): + thought_tracker.add_thought( + thought.get('step_type') or 'fact_memory', + thought.get('content'), + thought.get('detail'), + ) + for citation in fact_memory_payload.get('citations', []): + agent_citations_list.append(citation) + if enable_semantic_kernel and user_enable_agents: # PATCH: Use new agent selection logic agent_name_to_select = None @@ -7440,19 +8044,6 @@ async def run_sk_call(callable_obj, *args, **kwargs): "kernel": bool(kernel is not None), } - # Use the orchestrator agent as the default agent - - - # Add additional metadata here to scope the facts to be returned - # Allows for additional per agent and per conversation scoping. - inject_fact_memory_context( - conversation_history=conversation_history_for_api, - scope_id=scope_id, - scope_type=scope_type, - conversation_id=conversation_id, - agent_id=agent_id, - ) - agent_message_history = [ ChatMessageContent( role=msg["role"], @@ -7956,20 +8547,9 @@ def gpt_error(e): # assistant_message_id was generated earlier for thought tracking - # Get user_info and thread_id from the user message for ownership tracking and threading - user_info_for_assistant = None - user_thread_id = None - user_previous_thread_id = None - try: - user_msg = cosmos_messages_container.read_item( - item=user_message_id, - partition_key=conversation_id - ) - user_info_for_assistant = user_msg.get('metadata', {}).get('user_info') - user_thread_id = user_msg.get('metadata', {}).get('thread_info', {}).get('thread_id') - user_previous_thread_id = user_msg.get('metadata', {}).get('thread_info', {}).get('previous_thread_id') - except Exception as e: - debug_print(f"Warning: Could not retrieve user_info from user message: {e}") + user_info_for_assistant = response_message_context.get('user_info') + user_thread_id = response_message_context.get('thread_id') + user_previous_thread_id = response_message_context.get('previous_thread_id') # Assistant message should be part of the same thread as the user message # Only system/augmentation messages create new threads within a conversation @@ -8004,7 +8584,7 @@ def gpt_error(e): 'thread_id': user_thread_id, # Same thread as user message 'previous_thread_id': user_previous_thread_id, # Same previous_thread_id as user message 'active_thread': True, - 'thread_attempt': retry_thread_attempt if is_retry else 1 + 'thread_attempt': assistant_thread_attempt }, 'token_usage': token_usage_data # Store token usage information } # Used by SK and reasoning effort @@ -8013,7 +8593,7 @@ def gpt_error(e): debug_print(f"๐Ÿ” Chat API - Creating assistant message with thread_info:") debug_print(f" thread_id: {user_thread_id}") debug_print(f" previous_thread_id: {user_previous_thread_id}") - debug_print(f" attempt: {retry_thread_attempt if is_retry else 1}") + debug_print(f" attempt: {assistant_thread_attempt}") debug_print(f" is_retry: {is_retry}") cosmos_messages_container.upsert_item(assistant_doc) @@ -8179,9 +8759,13 @@ def chat_stream_api(): except Exception as e: return jsonify({'error': f'Failed to parse request: {str(e)}'}), 400 - compatibility_mode = bool(data.get('image_generation')) or bool( - data.get('retry_user_message_id') or data.get('edited_user_message_id') - ) + retry_user_message_id = data.get('retry_user_message_id') or data.get('edited_user_message_id') + retry_thread_id = data.get('retry_thread_id') + retry_thread_attempt = data.get('retry_thread_attempt') + is_retry = bool(retry_user_message_id) + is_edit = bool(data.get('edited_user_message_id')) + + compatibility_mode = bool(data.get('image_generation')) or is_retry requested_conversation_id = str(data.get('conversation_id') or '').strip() or None finalized_conversation_id = requested_conversation_id or str(uuid.uuid4()) is_new_stream_conversation = requested_conversation_id is None @@ -8195,6 +8779,7 @@ def chat_stream_api(): f"requested_conversation_id={requested_conversation_id} | " f"conversation_id={finalized_conversation_id} | " f"compatibility_mode={compatibility_mode} | " + f"is_retry={is_retry} | " f"hybrid_search={data.get('hybrid_search')} | " f"web_search={data.get('web_search_enabled')} | " f"doc_scope={data.get('doc_scope')} | " @@ -8208,6 +8793,15 @@ def chat_stream_api(): f"message_preview={request_preview!r}" ) + if is_retry: + operation_type = 'Edit' if is_edit else 'Retry' + debug_print( + f"[Streaming] {operation_type} detected | " + f"user_message_id={retry_user_message_id} | " + f"thread_id={retry_thread_id} | " + f"attempt={retry_thread_attempt}" + ) + def normalize_legacy_chat_payload(payload): """Convert the legacy JSON response shape into the streaming terminal payload.""" return make_json_serializable({ @@ -8364,9 +8958,19 @@ def generate(publish_background_event=None): enable_semantic_kernel = settings.get('enable_semantic_kernel', False) per_user_semantic_kernel = settings.get('per_user_semantic_kernel', False) user_settings = {} - user_enable_agents = False + user_enable_agents = True + force_enable_agents = bool(request_agent_info) debug_print(f"[DEBUG] enable_semantic_kernel={enable_semantic_kernel}, per_user_semantic_kernel={per_user_semantic_kernel}") + + if force_enable_agents: + g.force_enable_agents = True + if isinstance(request_agent_info, dict): + g.request_agent_info = request_agent_info + g.request_agent_name = request_agent_info.get('name') + else: + g.request_agent_info = {'name': request_agent_info} + g.request_agent_name = request_agent_info # Initialize Semantic Kernel if needed redis_client = None @@ -8399,7 +9003,9 @@ def generate(publish_background_event=None): sanitized_user_settings = sanitize_settings_for_logging(user_settings) if isinstance(user_settings, dict) else user_settings debug_print(f"[DEBUG] Using user_settings_obj directly (sanitized): {sanitized_user_settings}") - user_enable_agents = user_settings.get('enable_agents', False) + user_enable_agents = user_settings.get('enable_agents', True) + if force_enable_agents: + user_enable_agents = True debug_print(f"[DEBUG] user_enable_agents={user_enable_agents}") except Exception as e: debug_print(f"Error loading user settings: {e}") @@ -8806,6 +9412,16 @@ def generate(publish_background_event=None): thread_id=current_user_thread_id, user_id=user_id ) + assistant_thread_attempt = retry_thread_attempt if is_retry else 1 + response_message_context = _load_user_message_response_context( + conversation_id=conversation_id, + user_message_id=user_message_id, + fallback_thread_id=current_user_thread_id, + fallback_previous_thread_id=previous_thread_id, + ) + user_info_for_assistant = response_message_context.get('user_info') + user_thread_id = response_message_context.get('thread_id') + user_previous_thread_id = response_message_context.get('previous_thread_id') def serialize_thought_event(step_type, content, step_index, message_id=None): return f"data: {json.dumps({'type': 'thought', 'message_id': message_id or assistant_message_id, 'step_index': step_index, 'step_type': step_type, 'content': content})}\n\n" @@ -8880,7 +9496,14 @@ def publish_live_plugin_thought(thought_payload): 'blocklist_matches': blocklist_matches, 'timestamp': datetime.utcnow().isoformat(), 'reason': "; ".join(block_reasons), - 'metadata': {} + 'metadata': { + 'message_id': assistant_message_id, + 'thread_info': { + 'thread_id': response_message_context.get('thread_id'), + 'previous_thread_id': response_message_context.get('previous_thread_id'), + 'thread_attempt': assistant_thread_attempt, + }, + } } cosmos_safety_container.upsert_item(safety_item) @@ -8902,24 +9525,36 @@ def publish_live_plugin_thought(thought_payload): ) # Insert safety message - safety_message_id = f"{conversation_id}_safety_{int(time.time())}_{random.randint(1000,9999)}" - safety_doc = { - 'id': safety_message_id, - 'conversation_id': conversation_id, - 'role': 'safety', - 'content': blocked_msg_content.strip(), - 'timestamp': datetime.utcnow().isoformat(), - 'model_deployment_name': None, - 'metadata': {}, - } + safety_doc = _build_safety_message_doc( + conversation_id=conversation_id, + message_id=assistant_message_id, + content=blocked_msg_content.strip(), + response_context=response_message_context, + thread_attempt=assistant_thread_attempt, + ) cosmos_messages_container.upsert_item(safety_doc) conversation_item['last_updated'] = datetime.utcnow().isoformat() cosmos_conversations_container.upsert_item(conversation_item) - # Stream the blocked response and stop - yield f"data: {json.dumps({'content': blocked_msg_content.strip(), 'blocked': True})}\n\n" - yield "data: [DONE]\n\n" + final_data = make_json_serializable({ + 'content': blocked_msg_content.strip(), + 'full_content': blocked_msg_content.strip(), + 'blocked': True, + 'role': 'safety', + 'done': True, + 'conversation_id': conversation_id, + 'conversation_title': conversation_item.get('title'), + 'message_id': assistant_message_id, + 'user_message_id': user_message_id, + 'augmented': False, + 'hybrid_citations': [], + 'web_search_citations': [], + 'agent_citations': [], + 'model_deployment_name': None, + 'thoughts_enabled': thought_tracker.enabled, + }) + yield f"data: {json.dumps(final_data)}\n\n" return except HttpResponseError as e: @@ -9613,6 +10248,26 @@ def publish_live_plugin_thought(thought_payload): agent_citations_list.append( build_history_context_debug_citation(history_debug_info, 'streaming') ) + + fact_memory_enabled = bool(settings.get('enable_fact_memory_plugin', False)) + fact_memory_payload = inject_fact_memory_context( + conversation_history=conversation_history_for_api, + scope_id=scope_id, + scope_type=scope_type, + query_text=user_message, + conversation_id=conversation_id, + agent_id=None, + enabled=fact_memory_enabled, + include_metadata=bool(enable_semantic_kernel and user_enable_agents), + ) + for thought in fact_memory_payload.get('thoughts', []): + yield emit_thought( + thought.get('step_type') or 'fact_memory', + thought.get('content'), + thought.get('detail'), + ) + for citation in fact_memory_payload.get('citations', []): + agent_citations_list.append(citation) # Check if agents are enabled and should be used selected_agent = None @@ -9703,14 +10358,6 @@ def publish_live_plugin_thought(thought_payload): else: debug_print(f"[Streaming] โš ๏ธ No agent selected, falling back to GPT") - inject_fact_memory_context( - conversation_history=conversation_history_for_api, - scope_id=scope_id, - scope_type=scope_type, - conversation_id=conversation_id, - agent_id=getattr(selected_agent, 'id', None), - ) - # Stream the response accumulated_content = "" token_usage_data = None # Will be populated from final stream chunk @@ -10044,20 +10691,9 @@ def publish_live_plugin_thought(thought_payload): yield emit_thought('generation', f"'{gpt_model}' responded ({gpt_stream_total_duration_s}s from initial message)") # Stream complete - save message and send final metadata - # Get user thread info to maintain thread consistency - user_thread_id = None - user_previous_thread_id = None - user_info_for_assistant = None - try: - user_msg = cosmos_messages_container.read_item( - item=user_message_id, - partition_key=conversation_id - ) - user_info_for_assistant = user_msg.get('metadata', {}).get('user_info') - user_thread_id = user_msg.get('metadata', {}).get('thread_info', {}).get('thread_id') - user_previous_thread_id = user_msg.get('metadata', {}).get('thread_info', {}).get('previous_thread_id') - except Exception as e: - debug_print(f"Warning: Could not retrieve thread_id from user message: {e}") + user_info_for_assistant = response_message_context.get('user_info') + user_thread_id = response_message_context.get('thread_id') + user_previous_thread_id = response_message_context.get('previous_thread_id') assistant_timestamp = datetime.utcnow().isoformat() prepared_agent_citations = persist_agent_citation_artifacts( conversation_id=conversation_id, @@ -10088,7 +10724,7 @@ def publish_live_plugin_thought(thought_payload): 'thread_id': user_thread_id, 'previous_thread_id': user_previous_thread_id, 'active_thread': True, - 'thread_attempt': 1 + 'thread_attempt': assistant_thread_attempt }, 'token_usage': token_usage_data if token_usage_data else None # Store token usage from stream } diff --git a/application/single_app/route_backend_conversation_export.py b/application/single_app/route_backend_conversation_export.py index b070c222..086019f1 100644 --- a/application/single_app/route_backend_conversation_export.py +++ b/application/single_app/route_backend_conversation_export.py @@ -11,6 +11,7 @@ from html import escape as _escape_html from typing import Any, Dict, List, Optional +from bs4 import BeautifulSoup, NavigableString, Tag from config import * from flask import jsonify, make_response, request from functions_appinsights import log_event @@ -27,11 +28,14 @@ from functions_thoughts import get_thoughts_for_conversation from swagger_wrapper import swagger_route, get_auth_security from docx import Document as DocxDocument -from docx.shared import Pt +from docx.shared import Inches, Pt TRANSCRIPT_ROLES = {'user', 'assistant'} SUMMARY_SOURCE_CHAR_LIMIT = 60000 +DOCX_MARKDOWN_EXTRAS = ['fenced-code-blocks', 'tables', 'break-on-newline', 'cuddled-lists', 'strike'] +EMAIL_SUBJECT_CHAR_LIMIT = 120 +EMAIL_SUBJECT_SOURCE_CHAR_LIMIT = 12000 def register_route_backend_conversation_export(app): @@ -157,55 +161,11 @@ def api_export_message_word(): return jsonify({'error': 'message_id and conversation_id are required'}), 400 try: - try: - conversation = cosmos_conversations_container.read_item( - item=conversation_id, - partition_key=conversation_id - ) - except Exception: - return jsonify({'error': 'Conversation not found'}), 404 - - if conversation.get('user_id') != user_id: - return jsonify({'error': 'Access denied'}), 403 - - try: - message = cosmos_messages_container.read_item( - item=message_id, - partition_key=conversation_id - ) - except Exception: - message_query = """ - SELECT * FROM c - WHERE c.id = @message_id AND c.conversation_id = @conversation_id - """ - message_results = list(cosmos_messages_container.query_items( - query=message_query, - parameters=[ - {'name': '@message_id', 'value': message_id}, - {'name': '@conversation_id', 'value': conversation_id} - ], - enable_cross_partition_query=True - )) - if not message_results: - return jsonify({'error': 'Message not found'}), 404 - message = message_results[0] - - if message.get('conversation_id') != conversation_id: - return jsonify({'error': 'Message not found'}), 404 - - if isinstance(message.get('agent_citations'), list) and any( - isinstance(citation, dict) and citation.get('artifact_id') - for citation in message.get('agent_citations', []) - ): - conversation_messages = list(cosmos_messages_container.query_items( - query="SELECT * FROM c WHERE c.conversation_id = @conversation_id", - parameters=[{'name': '@conversation_id', 'value': conversation_id}], - partition_key=conversation_id, - )) - artifact_payload_map = build_message_artifact_payload_map(conversation_messages) - hydrated_messages = hydrate_agent_citations_from_artifacts([message], artifact_payload_map) - if hydrated_messages: - message = hydrated_messages[0] + message = _load_export_message_for_user( + user_id=user_id, + conversation_id=conversation_id, + message_id=message_id + ) document_bytes = _message_to_docx_bytes(message) timestamp_str = datetime.utcnow().strftime('%Y%m%d_%H%M%S') @@ -218,11 +178,68 @@ def api_export_message_word(): response.headers['Content-Disposition'] = f'attachment; filename="{filename}"' return response + except LookupError as exc: + return jsonify({'error': str(exc)}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + except Exception as exc: debug_print(f"Message export error: {str(exc)}") log_event(f"Message export failed: {exc}", level="WARNING") return jsonify({'error': 'Export failed due to a server error. Please try again later.'}), 500 + @app.route('/api/message/export-email-draft', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def api_export_message_email_draft(): + """ + Build a mailto-ready email draft for a single message. + + Request body: + message_id (str): ID of the message to export. + conversation_id (str): ID of the conversation the message belongs to. + summary_model_deployment (str): Optional model deployment for subject generation. + """ + user_id = get_current_user_id() + if not user_id: + return jsonify({'error': 'User not authenticated'}), 401 + + data = request.get_json(silent=True) + if not data: + return jsonify({'error': 'Request body is required'}), 400 + + message_id = str(data.get('message_id', '') or '').strip() + conversation_id = str(data.get('conversation_id', '') or '').strip() + summary_model_deployment = str(data.get('summary_model_deployment', '') or '').strip() + + if not message_id or not conversation_id: + return jsonify({'error': 'message_id and conversation_id are required'}), 400 + + try: + settings = get_settings() + message = _load_export_message_for_user( + user_id=user_id, + conversation_id=conversation_id, + message_id=message_id + ) + draft_payload = _message_to_email_draft_payload( + message=message, + settings=settings, + summary_model_deployment=summary_model_deployment + ) + return jsonify(draft_payload), 200 + + except LookupError as exc: + return jsonify({'error': str(exc)}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + + except Exception as exc: + debug_print(f"Message email draft export error: {str(exc)}") + log_event(f"Message email draft export failed: {exc}", level="WARNING") + return jsonify({'error': 'Email draft export failed due to a server error. Please try again later.'}), 500 + def _build_export_entry( conversation: Dict[str, Any], @@ -1265,6 +1282,60 @@ def _safe_filename(title: str) -> str: return safe or 'Untitled' +def _load_export_message_for_user(user_id: str, conversation_id: str, message_id: str) -> Dict[str, Any]: + try: + conversation = cosmos_conversations_container.read_item( + item=conversation_id, + partition_key=conversation_id + ) + except Exception as exc: + raise LookupError('Conversation not found') from exc + + if conversation.get('user_id') != user_id: + raise PermissionError('Access denied') + + try: + message = cosmos_messages_container.read_item( + item=message_id, + partition_key=conversation_id + ) + except Exception: + message_query = """ + SELECT * FROM c + WHERE c.id = @message_id AND c.conversation_id = @conversation_id + """ + message_results = list(cosmos_messages_container.query_items( + query=message_query, + parameters=[ + {'name': '@message_id', 'value': message_id}, + {'name': '@conversation_id', 'value': conversation_id} + ], + enable_cross_partition_query=True + )) + if not message_results: + raise LookupError('Message not found') + message = message_results[0] + + if message.get('conversation_id') != conversation_id: + raise LookupError('Message not found') + + if isinstance(message.get('agent_citations'), list) and any( + isinstance(citation, dict) and citation.get('artifact_id') + for citation in message.get('agent_citations', []) + ): + conversation_messages = list(cosmos_messages_container.query_items( + query="SELECT * FROM c WHERE c.conversation_id = @conversation_id", + parameters=[{'name': '@conversation_id', 'value': conversation_id}], + partition_key=conversation_id, + )) + artifact_payload_map = build_message_artifact_payload_map(conversation_messages) + hydrated_messages = hydrate_agent_citations_from_artifacts([message], artifact_payload_map) + if hydrated_messages: + message = hydrated_messages[0] + + return message + + def _message_to_docx_bytes(message: Dict[str, Any]) -> bytes: doc = DocxDocument() doc.add_heading('Message Export', level=1) @@ -1298,6 +1369,431 @@ def _message_to_docx_bytes(message: Dict[str, Any]) -> bytes: return buffer.read() +def _message_to_email_draft_payload( + message: Dict[str, Any], + settings: Dict[str, Any], + summary_model_deployment: str = '' +) -> Dict[str, Any]: + content = _normalize_content(message.get('content', '')) + subject_payload = _build_message_email_subject( + content=content, + settings=settings, + requested_model=summary_model_deployment + ) + body_content = _strip_explicit_message_email_subject(content) + + body_lines = [] + + if body_content.strip(): + body_lines.extend(_render_markdown_to_email_lines(body_content)) + else: + body_lines.append('No content recorded.') + + citation_labels = _build_message_citation_labels(message) + if citation_labels: + if body_lines and body_lines[-1] != '': + body_lines.append('') + body_lines.append('Citations') + body_lines.append('---------') + for citation_label in citation_labels: + body_lines.append(f'- {citation_label}') + + body = _finalize_email_body_text(body_lines) + return { + 'subject': subject_payload['subject'], + 'subject_source': subject_payload['source'], + 'body': body + } + + +def _render_markdown_to_email_lines(content: str) -> List[str]: + html = markdown2.markdown(content, extras=DOCX_MARKDOWN_EXTRAS) + soup = BeautifulSoup(f'
{html}
', 'html.parser') + root = soup.div if soup.div else soup + lines: List[str] = [] + + for child in root.children: + if isinstance(child, NavigableString): + text = str(child).strip() + if text: + lines.append(text) + lines.append('') + continue + + if isinstance(child, Tag): + _append_html_block_to_email_lines(lines, child) + + return lines + + +def _append_html_block_to_email_lines(lines: List[str], node: Tag, list_level: int = 0): + tag_name = node.name.lower() + + if tag_name in {'h1', 'h2', 'h3', 'h4', 'h5', 'h6'}: + heading_text = _extract_email_inline_text(node).strip() + if heading_text: + underline_char = '=' if tag_name in {'h1', 'h2'} else '-' + lines.append(heading_text) + lines.append(underline_char * min(len(heading_text), 80)) + lines.append('') + return + + if tag_name == 'p': + paragraph_text = _extract_email_inline_text(node).strip() + if paragraph_text: + lines.extend(paragraph_text.splitlines()) + lines.append('') + return + + if tag_name in {'ul', 'ol'}: + _append_html_list_to_email_lines(lines, node, ordered=(tag_name == 'ol'), level=list_level) + lines.append('') + return + + if tag_name == 'pre': + code_text = node.get_text().rstrip('\n') + if code_text: + for code_line in code_text.splitlines(): + lines.append(f' {code_line.rstrip()}') + lines.append('') + return + + if tag_name == 'blockquote': + quote_text = _extract_email_inline_text(node).strip() + if quote_text: + for quote_line in quote_text.splitlines(): + lines.append(f' {quote_line}') + lines.append('') + return + + if tag_name == 'table': + _append_html_table_to_email_lines(lines, node) + lines.append('') + return + + if tag_name == 'hr': + lines.append('-' * 40) + lines.append('') + return + + if tag_name in {'div', 'section', 'article'}: + for child in node.children: + if isinstance(child, NavigableString): + text = str(child).strip() + if text: + lines.append(text) + lines.append('') + continue + + if isinstance(child, Tag): + _append_html_block_to_email_lines(lines, child, list_level=list_level) + return + + fallback_text = _extract_email_inline_text(node).strip() + if fallback_text: + lines.extend(fallback_text.splitlines()) + lines.append('') + + +def _append_html_list_to_email_lines(lines: List[str], list_node: Tag, ordered: bool, level: int = 0): + item_number = 1 + indent = ' ' * level + + for item in list_node.find_all('li', recursive=False): + prefix = f'{item_number}. ' if ordered else '- ' + item_parts = [] + + for child in item.children: + if isinstance(child, Tag) and child.name.lower() in {'ul', 'ol'}: + continue + item_parts.append(_extract_email_inline_text(child)) + + item_text = ''.join(item_parts).strip() + if item_text: + lines.append(f'{indent}{prefix}{item_text}') + else: + lines.append(f'{indent}{prefix}'.rstrip()) + + for nested_list in item.find_all(['ul', 'ol'], recursive=False): + _append_html_list_to_email_lines( + lines, + nested_list, + ordered=(nested_list.name.lower() == 'ol'), + level=level + 1 + ) + + if ordered: + item_number += 1 + + +def _append_html_table_to_email_lines(lines: List[str], table_node: Tag): + rows = table_node.find_all('tr') + if not rows: + return + + parsed_rows = [] + header_present = False + for row_index, row in enumerate(rows): + cells = row.find_all(['th', 'td'], recursive=False) + if not cells: + continue + if row_index == 0 and all(cell.name.lower() == 'th' for cell in cells): + header_present = True + parsed_rows.append([ + re.sub(r'\s+', ' ', _extract_email_inline_text(cell)).strip() + for cell in cells + ]) + + if not parsed_rows: + return + + column_count = max(len(row) for row in parsed_rows) + normalized_rows = [row + [''] * (column_count - len(row)) for row in parsed_rows] + column_widths = [ + max(len(row[column_index]) for row in normalized_rows) + for column_index in range(column_count) + ] + + def format_row(row_values: List[str]) -> str: + padded_cells = [ + row_values[column_index].ljust(column_widths[column_index]) + for column_index in range(column_count) + ] + return ' '.join(padded_cells).rstrip() + + lines.append(format_row(normalized_rows[0])) + if header_present: + separator = ' '.join( + '-' * max(column_widths[column_index], 3) + for column_index in range(column_count) + ) + lines.append(separator) + data_rows = normalized_rows[1:] + else: + data_rows = normalized_rows[1:] + + for row_values in data_rows: + lines.append(format_row(row_values)) + + +def _extract_email_inline_text(node: Any) -> str: + if isinstance(node, NavigableString): + return str(node) + + if not isinstance(node, Tag): + return '' + + tag_name = node.name.lower() + if tag_name == 'br': + return '\n' + if tag_name == 'img': + return f"[{node.get('alt') or 'Image'}]" + if tag_name == 'a': + label = ''.join(_extract_email_inline_text(child) for child in node.children).strip() + href = str(node.get('href') or '').strip() + if href and href != label: + if label: + return f'{label} ({href})' + return href + return label + + return ''.join(_extract_email_inline_text(child) for child in node.children) + + +def _finalize_email_body_text(lines: List[str]) -> str: + normalized_lines: List[str] = [] + + for raw_line in lines: + line = str(raw_line or '').rstrip() + if not line: + if normalized_lines and normalized_lines[-1] != '': + normalized_lines.append('') + continue + normalized_lines.append(line) + + while normalized_lines and normalized_lines[-1] == '': + normalized_lines.pop() + + return '\n'.join(normalized_lines) + + +def _build_message_email_subject( + content: str, + settings: Dict[str, Any], + requested_model: str = '' +) -> Dict[str, str]: + explicit_subject = _extract_message_email_subject(content) + if explicit_subject: + return { + 'subject': explicit_subject, + 'source': 'message' + } + + generated_subject = _generate_message_email_subject_with_model( + content=content, + settings=settings, + requested_model=requested_model + ) + if generated_subject: + return { + 'subject': generated_subject, + 'source': 'model' + } + + return { + 'subject': _fallback_message_email_subject(content), + 'source': 'fallback' + } + + +def _extract_message_email_subject(content: str) -> Optional[str]: + if not content: + return None + + lines = content.splitlines() + explicit_patterns = [ + re.compile(r'^\s*(?:\*\*|__)?(?:email\s+)?subject(?:\*\*|__)?\s*:\s*(.+?)\s*$', re.IGNORECASE), + re.compile(r'^\s*(?:\*\*|__)?title(?:\*\*|__)?\s*:\s*(.+?)\s*$', re.IGNORECASE), + ] + + for line in lines: + stripped_line = line.strip() + if not stripped_line: + continue + for pattern in explicit_patterns: + match = pattern.match(stripped_line) + if not match: + continue + cleaned_subject = _clean_email_subject(match.group(1)) + if cleaned_subject: + return cleaned_subject + + for line in lines: + stripped_line = line.strip() + if not stripped_line: + continue + + heading_match = re.match(r'^#{1,6}\s+(.+)$', stripped_line) + if heading_match: + cleaned_subject = _clean_email_subject(heading_match.group(1)) + if cleaned_subject: + return cleaned_subject + break + + return None + + +def _strip_explicit_message_email_subject(content: str) -> str: + if not content: + return '' + + lines = content.splitlines() + explicit_patterns = [ + re.compile(r'^\s*(?:\*\*|__)?(?:email\s+)?subject(?:\*\*|__)?\s*:\s*(.+?)\s*$', re.IGNORECASE), + re.compile(r'^\s*(?:\*\*|__)?title(?:\*\*|__)?\s*:\s*(.+?)\s*$', re.IGNORECASE), + ] + + first_non_empty_index = None + for index, line in enumerate(lines): + if line.strip(): + first_non_empty_index = index + break + + if first_non_empty_index is None: + return '' + + first_line = lines[first_non_empty_index].strip() + if not any(pattern.match(first_line) for pattern in explicit_patterns): + return content + + remaining_lines = lines[:first_non_empty_index] + lines[first_non_empty_index + 1:] + while remaining_lines and not remaining_lines[0].strip(): + remaining_lines.pop(0) + return '\n'.join(remaining_lines) + + +def _clean_email_subject(subject: str) -> str: + cleaned_subject = re.sub(r'[`*_~]+', '', str(subject or '')) + cleaned_subject = re.sub(r'\s+', ' ', cleaned_subject).strip() + cleaned_subject = cleaned_subject.strip('"\'') + cleaned_subject = cleaned_subject.rstrip(' .:;-') + if len(cleaned_subject) > EMAIL_SUBJECT_CHAR_LIMIT: + cleaned_subject = cleaned_subject[:EMAIL_SUBJECT_CHAR_LIMIT].rstrip(' .:;-') + return cleaned_subject + + +def _generate_message_email_subject_with_model( + content: str, + settings: Dict[str, Any], + requested_model: str = '' +) -> Optional[str]: + subject_source = str(content or '').strip() + if not subject_source: + return None + + truncated_source = subject_source[:EMAIL_SUBJECT_SOURCE_CHAR_LIMIT] + + try: + gpt_client, gpt_model = _initialize_gpt_client(settings, requested_model) + model_lower = gpt_model.lower() + is_reasoning_model = ( + 'o1' in model_lower or 'o3' in model_lower or 'gpt-5' in model_lower + ) + instruction_role = 'developer' if is_reasoning_model else 'system' + subject_prompt = ( + 'You are generating an email subject line for a mailto draft from a single chat message. ' + 'If the message already contains a subject or clear title, reuse it in cleaned form. ' + 'Otherwise, write a concise and specific subject line. ' + 'Return plain text only with no quotes, no markdown, and no more than 10 words.' + ) + + subject_response = gpt_client.chat.completions.create( + model=gpt_model, + messages=[ + { + 'role': instruction_role, + 'content': subject_prompt + }, + { + 'role': 'user', + 'content': truncated_source + } + ] + ) + raw_subject = ( + (subject_response.choices[0].message.content or '').strip() + if subject_response.choices else '' + ) + cleaned_subject = _clean_email_subject(raw_subject) + if cleaned_subject: + return cleaned_subject + except Exception as exc: + debug_print(f'Message email subject generation failed: {exc}') + log_event( + 'Message email subject generation failed', + extra={ + 'requested_model': requested_model or None, + 'content_length': len(subject_source) + }, + level='WARNING' + ) + + return None + + +def _fallback_message_email_subject(content: str) -> str: + extracted_subject = _extract_message_email_subject(content) + if extracted_subject: + return extracted_subject + + for line in str(content or '').splitlines(): + cleaned_subject = _clean_email_subject(line) + if cleaned_subject: + return cleaned_subject + + return 'Shared chat message' + + def _build_message_citation_labels(message: Dict[str, Any]) -> List[str]: normalized_citations = _normalize_citations(_collect_raw_citation_buckets(message)) citation_labels: List[str] = [] @@ -1322,69 +1818,228 @@ def _build_message_citation_labels(message: Dict[str, Any]) -> List[str]: def _add_markdown_content_to_doc(doc: DocxDocument, content: str): - lines = content.split('\n') - index = 0 - - while index < len(lines): - line = lines[index] - - heading_match = re.match(r'^(#{1,6})\s+(.*)', line) - if heading_match: - level = min(len(heading_match.group(1)), 4) - doc.add_heading(heading_match.group(2).strip(), level=level) - index += 1 + html = markdown2.markdown(content, extras=DOCX_MARKDOWN_EXTRAS) + soup = BeautifulSoup(f'
{html}
', 'html.parser') + root = soup.div if soup.div else soup + rendered_blocks = False + + for child in root.children: + if isinstance(child, NavigableString): + text = str(child).strip() + if not text: + continue + paragraph = doc.add_paragraph() + paragraph.add_run(text) + rendered_blocks = True continue - if line.strip().startswith('```'): - code_lines = [] - index += 1 - while index < len(lines) and not lines[index].strip().startswith('```'): - code_lines.append(lines[index]) - index += 1 - index += 1 - code_paragraph = doc.add_paragraph() - code_run = code_paragraph.add_run('\n'.join(code_lines)) - code_run.font.name = 'Consolas' - code_run.font.size = Pt(9) + if not isinstance(child, Tag): continue - unordered_list_match = re.match(r'^(\s*)[*\-+]\s+(.*)', line) - if unordered_list_match: - doc.add_paragraph(unordered_list_match.group(2).strip(), style='List Bullet') - index += 1 - continue + _append_html_block_to_doc(doc, child) + rendered_blocks = True - ordered_list_match = re.match(r'^(\s*)\d+[.)]\s+(.*)', line) - if ordered_list_match: - doc.add_paragraph(ordered_list_match.group(2).strip(), style='List Number') - index += 1 - continue + if not rendered_blocks and content.strip(): + doc.add_paragraph(content.strip()) - if not line.strip(): - index += 1 - continue +def _append_html_block_to_doc(doc: DocxDocument, node: Tag, list_level: int = 0): + tag_name = node.name.lower() + + if tag_name in {'h1', 'h2', 'h3', 'h4', 'h5', 'h6'}: + paragraph = doc.add_heading('', level=min(int(tag_name[1]), 4)) + _append_inline_html_runs(paragraph, node) + return + + if tag_name == 'p': paragraph = doc.add_paragraph() - _add_inline_markdown_runs(paragraph, line) - index += 1 - - -def _add_inline_markdown_runs(paragraph, text: str): - parts = re.compile(r'(\*\*.*?\*\*|\*.*?\*|`[^`]+`)').split(text) - - for part in parts: - if part.startswith('**') and part.endswith('**'): - run = paragraph.add_run(part[2:-2]) - run.bold = True - elif part.startswith('*') and part.endswith('*') and len(part) > 2: - run = paragraph.add_run(part[1:-1]) - run.italic = True - elif part.startswith('`') and part.endswith('`'): - run = paragraph.add_run(part[1:-1]) - run.font.name = 'Consolas' - run.font.size = Pt(9) - elif part: - paragraph.add_run(part) + _append_inline_html_runs(paragraph, node) + return + + if tag_name in {'ul', 'ol'}: + _append_list_items_to_doc(doc, node, ordered=(tag_name == 'ol'), level=list_level) + return + + if tag_name == 'pre': + _add_code_block_to_doc(doc, node) + return + + if tag_name == 'blockquote': + paragraph = doc.add_paragraph() + paragraph.paragraph_format.left_indent = Inches(0.3) + _append_inline_html_runs(paragraph, node, {'italic': True}) + return + + if tag_name == 'table': + _add_html_table_to_doc(doc, node) + return + + if tag_name == 'hr': + doc.add_paragraph('') + return + + if tag_name in {'div', 'section', 'article'}: + for child in node.children: + if isinstance(child, NavigableString): + text = str(child).strip() + if not text: + continue + paragraph = doc.add_paragraph() + paragraph.add_run(text) + continue + + if isinstance(child, Tag): + _append_html_block_to_doc(doc, child, list_level=list_level) + return + + paragraph = doc.add_paragraph() + _append_inline_html_runs(paragraph, node) + + +def _append_list_items_to_doc(doc: DocxDocument, list_node: Tag, ordered: bool, level: int = 0): + style_name = 'List Number' if ordered else 'List Bullet' + + for item in list_node.find_all('li', recursive=False): + paragraph = doc.add_paragraph(style=style_name) + if level: + paragraph.paragraph_format.left_indent = Inches(0.25 * level) + + rendered_inline = False + for child in item.children: + if isinstance(child, Tag) and child.name.lower() in {'ul', 'ol'}: + continue + if isinstance(child, NavigableString) and not str(child).strip(): + continue + + _append_inline_html_runs(paragraph, child) + rendered_inline = True + + if not rendered_inline: + text = item.get_text(' ', strip=True) + if text: + paragraph.add_run(text) + + for nested_list in item.find_all(['ul', 'ol'], recursive=False): + _append_list_items_to_doc( + doc, + nested_list, + ordered=(nested_list.name.lower() == 'ol'), + level=level + 1 + ) + + +def _add_code_block_to_doc(doc: DocxDocument, node: Tag): + code_text = node.get_text().rstrip('\n') + if not code_text: + return + + paragraph = doc.add_paragraph() + paragraph.paragraph_format.left_indent = Inches(0.25) + paragraph.paragraph_format.space_before = Pt(6) + paragraph.paragraph_format.space_after = Pt(6) + run = paragraph.add_run(code_text) + run.font.name = 'Consolas' + run.font.size = Pt(9) + + +def _add_html_table_to_doc(doc: DocxDocument, table_node: Tag): + rows = table_node.find_all('tr') + if not rows: + return + + column_count = max( + len(row.find_all(['th', 'td'], recursive=False)) + for row in rows + ) + if column_count == 0: + return + + table = doc.add_table(rows=len(rows), cols=column_count) + table.style = 'Table Grid' + + for row_index, row in enumerate(rows): + cells = row.find_all(['th', 'td'], recursive=False) + for column_index in range(column_count): + cell = table.cell(row_index, column_index) + cell.text = '' + + if column_index >= len(cells): + continue + + _populate_table_cell( + cell, + cells[column_index], + is_header=(cells[column_index].name.lower() == 'th') + ) + + +def _populate_table_cell(cell, node: Tag, is_header: bool = False): + paragraph = cell.paragraphs[0] + _append_inline_html_runs(paragraph, node, {'bold': is_header}) + + +def _append_inline_html_runs(paragraph, node: Any, formatting: Optional[Dict[str, bool]] = None): + if formatting is None: + formatting = {} + + if isinstance(node, NavigableString): + text = str(node) + if not text: + return + + run = paragraph.add_run(text) + _apply_run_formatting(run, formatting) + return + + if not isinstance(node, Tag): + return + + tag_name = node.name.lower() + if tag_name == 'br': + paragraph.add_run().add_break() + return + + if tag_name == 'img': + alt_text = node.get('alt') or 'Image' + run = paragraph.add_run(f'[{alt_text}]') + _apply_run_formatting(run, formatting) + return + + next_formatting = dict(formatting) + if tag_name in {'strong', 'b'}: + next_formatting['bold'] = True + elif tag_name in {'em', 'i'}: + next_formatting['italic'] = True + elif tag_name in {'s', 'strike', 'del'}: + next_formatting['strike'] = True + elif tag_name == 'code': + next_formatting['code'] = True + elif tag_name == 'a': + next_formatting['underline'] = True + + for child in node.children: + _append_inline_html_runs(paragraph, child, next_formatting) + + if tag_name == 'a': + href = str(node.get('href') or '').strip() + label = node.get_text(' ', strip=True) + if href and href != label: + suffix_run = paragraph.add_run(f' ({href})') + _apply_run_formatting(suffix_run, formatting) + + +def _apply_run_formatting(run, formatting: Dict[str, bool]): + if formatting.get('bold'): + run.bold = True + if formatting.get('italic'): + run.italic = True + if formatting.get('underline'): + run.underline = True + if formatting.get('strike'): + run.font.strike = True + if formatting.get('code'): + run.font.name = 'Consolas' + run.font.size = Pt(9) # --------------------------------------------------------------------------- diff --git a/application/single_app/route_backend_users.py b/application/single_app/route_backend_users.py index 2fdee39f..459aa800 100644 --- a/application/single_app/route_backend_users.py +++ b/application/single_app/route_backend_users.py @@ -155,6 +155,8 @@ def user_settings(): 'microphonePermissionState', # Text-to-speech settings 'ttsEnabled', 'ttsVoice', 'ttsSpeed', 'ttsAutoplay', + # Tutorial visibility settings + 'showTutorialButtons', # Metrics and other settings 'metrics', 'lastUpdated' } # Add others as needed diff --git a/application/single_app/route_frontend_admin_settings.py b/application/single_app/route_frontend_admin_settings.py index eb07b0f2..4656a1a4 100644 --- a/application/single_app/route_frontend_admin_settings.py +++ b/application/single_app/route_frontend_admin_settings.py @@ -67,10 +67,7 @@ def admin_settings(): if 'multi_endpoint_migration_notice' not in settings or not isinstance(settings.get('multi_endpoint_migration_notice'), dict): settings['multi_endpoint_migration_notice'] = { 'enabled': False, - 'message': ( - 'Multi-endpoint has been enabled and your existing AI endpoint was migrated. ' - 'Agents using the default connection may need to be updated to select a new model endpoint.' - ), + 'message': '', 'created_at': None } @@ -638,12 +635,11 @@ def parse_admin_int(raw_value, fallback_value, field_name="unknown", hard_defaul should_migrate_endpoints = enable_multi_model_endpoints and not existing_multi_endpoints_enabled migration_notice = settings.get('multi_endpoint_migration_notice', { 'enabled': False, - 'message': ( - 'Multi-endpoint has been enabled and your existing AI endpoint was migrated. ' - 'Agents using the default connection may need to be updated to select a new model endpoint.' - ), + 'message': '', 'created_at': None }) + migration_notice['enabled'] = False + migration_notice['message'] = '' migrated_at = settings.get('multi_endpoint_migrated_at') if should_migrate_endpoints and not parsed_model_endpoints: @@ -709,7 +705,6 @@ def parse_admin_int(raw_value, fallback_value, field_name="unknown", hard_defaul migrated_at = datetime.now(timezone.utc).isoformat() - migration_notice['enabled'] = True migration_notice['created_at'] = migrated_at parsed_model_endpoints = merge_model_endpoints_with_existing(parsed_model_endpoints, existing_model_endpoints) diff --git a/application/single_app/route_frontend_chats.py b/application/single_app/route_frontend_chats.py index 3436e209..c5590c4b 100644 --- a/application/single_app/route_frontend_chats.py +++ b/application/single_app/route_frontend_chats.py @@ -42,6 +42,90 @@ def _serialize_chat_prompt_option(prompt, *, scope_type, scope_id=None, scope_na } +def _normalize_chat_model_value(value): + return str(value or '').strip() + + +def _build_initial_chat_model_selection(*, chat_model_options, preferred_model_id=None, preferred_model_deployment=None): + scope_order = { + 'global': 0, + 'personal': 1, + 'group': 2, + } + + def serialize_option(option): + if not isinstance(option, dict): + return None + + selection_key = _normalize_chat_model_value(option.get('selection_key')) + model_id = _normalize_chat_model_value(option.get('model_id')) + display_name = _normalize_chat_model_value( + option.get('display_name') or option.get('deployment_name') or option.get('model_id') + ) or 'Select a Model' + deployment_name = _normalize_chat_model_value(option.get('deployment_name')) + scope_type = _normalize_chat_model_value(option.get('scope_type')) + scope_name = _normalize_chat_model_value(option.get('scope_name')) + + search_parts = [ + display_name, + model_id, + deployment_name, + scope_name or scope_type, + ] + return { + 'selection_key': selection_key, + 'model_id': model_id, + 'display_name': display_name, + 'deployment_name': deployment_name, + 'endpoint_id': _normalize_chat_model_value(option.get('endpoint_id')), + 'provider': _normalize_chat_model_value(option.get('provider')), + 'scope_type': scope_type, + 'scope_id': _normalize_chat_model_value(option.get('scope_id')), + 'scope_name': scope_name, + 'option_value': deployment_name or model_id or selection_key, + 'search_text': ' '.join(part for part in search_parts if part), + } + + def sort_key(option): + scope_type = _normalize_chat_model_value(option.get('scope_type')) + display_name = _normalize_chat_model_value( + option.get('display_name') or option.get('deployment_name') or option.get('model_id') + ).lower() + scope_name = _normalize_chat_model_value(option.get('scope_name')).lower() + model_id = _normalize_chat_model_value(option.get('model_id')).lower() + deployment_name = _normalize_chat_model_value(option.get('deployment_name')).lower() + return ( + scope_order.get(scope_type, 99), + scope_name, + display_name, + model_id, + deployment_name, + ) + + valid_options = [option for option in (chat_model_options or []) if isinstance(option, dict)] + if not valid_options: + return None + + sorted_options = sorted(valid_options, key=sort_key) + normalized_preferred_model_id = _normalize_chat_model_value(preferred_model_id) + normalized_preferred_model_deployment = _normalize_chat_model_value(preferred_model_deployment) + + if normalized_preferred_model_id: + for option in sorted_options: + selection_key = _normalize_chat_model_value(option.get('selection_key')) + model_id = _normalize_chat_model_value(option.get('model_id')) + if selection_key == normalized_preferred_model_id or model_id == normalized_preferred_model_id: + return serialize_option(option) + + if normalized_preferred_model_deployment: + for option in sorted_options: + deployment_name = _normalize_chat_model_value(option.get('deployment_name')) + if deployment_name == normalized_preferred_model_deployment: + return serialize_option(option) + + return serialize_option(sorted_options[0]) + + def _build_chat_model_catalog(*, user_id, settings, user_settings_dict, user_groups_raw): if not settings.get('enable_multi_model_endpoints', False): return [] @@ -182,7 +266,6 @@ def chats(): enable_document_classification = public_settings.get("enable_document_classification", False) enable_extract_meta_data = public_settings.get("enable_extract_meta_data", False) enable_multi_model_endpoints = public_settings.get("enable_multi_model_endpoints", False) - multi_endpoint_notice = public_settings.get("multi_endpoint_migration_notice", {}) active_group_id = user_settings_dict.get("activeGroupOid", "") active_group_name = "" if active_group_id: @@ -279,6 +362,12 @@ def chats(): except Exception as e: logger.warning(f"Failed to load chat model options: {e}") + initial_chat_model_selection = _build_initial_chat_model_selection( + chat_model_options=chat_model_options, + preferred_model_id=user_settings_dict.get('preferredModelId'), + preferred_model_deployment=user_settings_dict.get('preferredModelDeployment'), + ) + chat_prompt_options = [] try: chat_prompt_options = _build_chat_prompt_catalog( @@ -302,7 +391,6 @@ def chats(): document_classification_categories=categories_list, enable_extract_meta_data=enable_extract_meta_data, enable_multi_model_endpoints=enable_multi_model_endpoints, - multi_endpoint_notice=multi_endpoint_notice, multi_endpoint_models=multi_endpoint_models, user_id=user_id, user_display_name=user_display_name, @@ -311,6 +399,7 @@ def chats(): chat_prompt_options=chat_prompt_options, chat_agent_options=chat_agent_options, chat_model_options=chat_model_options, + initial_chat_model_selection=initial_chat_model_selection, ) @app.route('/upload', methods=['POST']) diff --git a/application/single_app/route_frontend_profile.py b/application/single_app/route_frontend_profile.py index 96dd996f..e7e1ca20 100644 --- a/application/single_app/route_frontend_profile.py +++ b/application/single_app/route_frontend_profile.py @@ -1,7 +1,11 @@ # route_frontend_profile.py from config import * +from functions_appinsights import log_event from functions_authentication import * +from functions_debug import debug_print +from functions_settings import get_settings, get_user_settings, update_user_settings +from semantic_kernel_fact_memory_store import FactMemoryStore from swagger_wrapper import swagger_route, get_auth_security import traceback @@ -12,6 +16,33 @@ def register_route_frontend_profile(app): def profile(): user = session.get('user') return render_template('profile.html', user=user) + + def serialize_fact_memory_item(fact_item): + return { + 'id': fact_item.get('id'), + 'value': str(fact_item.get('value') or ''), + 'memory_type': fact_item.get('memory_type') or 'fact', + 'agent_id': fact_item.get('agent_id'), + 'conversation_id': fact_item.get('conversation_id'), + 'scope_type': fact_item.get('scope_type'), + 'scope_id': fact_item.get('scope_id'), + 'created_at': fact_item.get('created_at'), + 'updated_at': fact_item.get('updated_at') or fact_item.get('created_at'), + } + + def get_profile_fact_memory_payload(user_id): + settings = get_settings() + fact_store = FactMemoryStore() + facts = fact_store.list_facts(scope_type='user', scope_id=user_id) + instruction_count = sum(1 for fact in facts if fact.get('memory_type') == 'instruction') + fact_count = sum(1 for fact in facts if fact.get('memory_type') != 'instruction') + return { + 'success': True, + 'enabled': bool(settings.get('enable_fact_memory_plugin', False)), + 'instruction_count': instruction_count, + 'fact_count': fact_count, + 'facts': [serialize_fact_memory_item(fact) for fact in facts], + } @app.route('/api/profile/image/refresh', methods=['POST']) @swagger_route(security=get_auth_security()) @@ -290,7 +321,6 @@ def get_user_activity_trends(): tokens_data = [{"date": date, "tokens": tokens_by_date.get(date, 0)} for date in date_range] # Get storage metrics from user settings - from functions_settings import get_user_settings user_settings = get_user_settings(user_id) metrics = user_settings.get('settings', {}).get('metrics', {}) document_metrics = metrics.get('document_metrics', {}) @@ -324,8 +354,6 @@ def get_user_settings_api(): Get current user's settings including cached metrics. """ try: - from functions_settings import get_user_settings - user_id = get_current_user_id() if not user_id: return jsonify({"error": "Unable to identify user"}), 401 @@ -359,4 +387,148 @@ def get_user_settings_api(): debug_print(f"Error fetching user settings: {e}") log_event(f"Error fetching user settings: {str(e)}", level=logging.ERROR) traceback.print_exc() - return jsonify({"error": "Failed to fetch user settings"}), 500 \ No newline at end of file + return jsonify({"error": "Failed to fetch user settings"}), 500 + + @app.route('/api/profile/fact-memory', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def get_profile_fact_memory(): + """Return the current user's fact-memory entries for profile recall.""" + user_id = None + try: + user_id = get_current_user_id() + if not user_id: + return jsonify({'error': 'Unable to identify user'}), 401 + + return jsonify(get_profile_fact_memory_payload(user_id)), 200 + except Exception as exc: + debug_print(f"[ProfileFactMemory] Failed to fetch fact memory for user {user_id}: {exc}") + log_event( + f"[ProfileFactMemory] Failed to fetch fact memory: {exc}", + extra={'user_id': user_id}, + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to fetch fact memory'}), 500 + + @app.route('/api/profile/fact-memory', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def create_profile_fact_memory(): + """Create a user-scoped fact-memory entry from the profile page.""" + user_id = None + try: + user_id = get_current_user_id() + if not user_id: + return jsonify({'error': 'Unable to identify user'}), 401 + + data = request.get_json(silent=True) or {} + value = str(data.get('value') or '').strip() + if not value: + return jsonify({'error': 'Memory value is required'}), 400 + + fact_store = FactMemoryStore() + memory_type = fact_store.normalize_memory_type(data.get('memory_type')) + fact_item = fact_store.set_fact( + scope_type='user', + scope_id=user_id, + value=value, + conversation_id=None, + agent_id=None, + memory_type=memory_type, + ) + log_event( + '[ProfileFactMemory] Created fact memory entry', + extra={'user_id': user_id, 'fact_id': fact_item.get('id'), 'memory_type': memory_type}, + level=logging.INFO, + ) + return jsonify({ + 'success': True, + 'fact': serialize_fact_memory_item(fact_item), + }), 201 + except Exception as exc: + debug_print(f"[ProfileFactMemory] Failed to create fact memory for user {user_id}: {exc}") + log_event( + f"[ProfileFactMemory] Failed to create fact memory: {exc}", + extra={'user_id': user_id}, + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to create fact memory'}), 500 + + @app.route('/api/profile/fact-memory/', methods=['PUT']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def update_profile_fact_memory(fact_id): + """Update an existing user-scoped fact-memory entry.""" + user_id = None + try: + user_id = get_current_user_id() + if not user_id: + return jsonify({'error': 'Unable to identify user'}), 401 + + data = request.get_json(silent=True) or {} + value = str(data.get('value') or '').strip() + if not value: + return jsonify({'error': 'Memory value is required'}), 400 + + fact_store = FactMemoryStore() + memory_type = fact_store.normalize_memory_type(data.get('memory_type')) + updated_fact = fact_store.update_fact(user_id, fact_id, value=value, memory_type=memory_type) + if updated_fact is None: + return jsonify({'error': 'Fact memory entry not found'}), 404 + + log_event( + '[ProfileFactMemory] Updated fact memory entry', + extra={'user_id': user_id, 'fact_id': fact_id, 'memory_type': memory_type}, + level=logging.INFO, + ) + return jsonify({ + 'success': True, + 'fact': serialize_fact_memory_item(updated_fact), + }), 200 + except Exception as exc: + debug_print(f"[ProfileFactMemory] Failed to update fact memory {fact_id} for user {user_id}: {exc}") + log_event( + f"[ProfileFactMemory] Failed to update fact memory: {exc}", + extra={'user_id': user_id, 'fact_id': fact_id}, + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to update fact memory'}), 500 + + @app.route('/api/profile/fact-memory/', methods=['DELETE']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def delete_profile_fact_memory(fact_id): + """Delete an existing user-scoped fact-memory entry.""" + user_id = None + try: + user_id = get_current_user_id() + if not user_id: + return jsonify({'error': 'Unable to identify user'}), 401 + + fact_store = FactMemoryStore() + deleted = fact_store.delete_fact(user_id, fact_id) + if not deleted: + return jsonify({'error': 'Fact memory entry not found'}), 404 + + log_event( + '[ProfileFactMemory] Deleted fact memory entry', + extra={'user_id': user_id, 'fact_id': fact_id}, + level=logging.INFO, + ) + return jsonify({'success': True}), 200 + except Exception as exc: + debug_print(f"[ProfileFactMemory] Failed to delete fact memory {fact_id} for user {user_id}: {exc}") + log_event( + f"[ProfileFactMemory] Failed to delete fact memory: {exc}", + extra={'user_id': user_id, 'fact_id': fact_id}, + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to delete fact memory'}), 500 \ No newline at end of file diff --git a/application/single_app/semantic_kernel_fact_memory_store.py b/application/single_app/semantic_kernel_fact_memory_store.py index 84aca6ac..ee986fee 100644 --- a/application/single_app/semantic_kernel_fact_memory_store.py +++ b/application/single_app/semantic_kernel_fact_memory_store.py @@ -9,6 +9,14 @@ from datetime import datetime, timezone from azure.cosmos import exceptions from config import cosmos_agent_facts_container +from functions_content import generate_embedding + + +MEMORY_TYPE_FACT = 'fact' +MEMORY_TYPE_INSTRUCTION = 'instruction' +MEMORY_TYPE_LEGACY_DESCRIBER = 'describer' +VALID_MEMORY_TYPES = {MEMORY_TYPE_FACT, MEMORY_TYPE_INSTRUCTION, MEMORY_TYPE_LEGACY_DESCRIBER} +UNSET = object() class FactMemoryStore: def __init__(self, container=cosmos_agent_facts_container): @@ -17,33 +25,97 @@ def __init__(self, container=cosmos_agent_facts_container): def get_partition_key(self, scope_id): return f"{scope_id}" - def set_fact(self, scope_type, scope_id, value, conversation_id=None, agent_id=None): + def normalize_memory_type(self, memory_type): + normalized = str(memory_type or '').strip().lower() + if normalized == MEMORY_TYPE_LEGACY_DESCRIBER: + return MEMORY_TYPE_FACT + if normalized in VALID_MEMORY_TYPES: + return normalized + return MEMORY_TYPE_FACT + + def normalize_fact_item(self, item): + normalized_item = dict(item or {}) + normalized_item['memory_type'] = self.normalize_memory_type(normalized_item.get('memory_type')) + return normalized_item + + def _build_embedding_fields(self, value, memory_type): + if self.normalize_memory_type(memory_type) != MEMORY_TYPE_FACT: + return { + 'value_embedding': None, + 'embedding_model': None, + 'embedding_updated_at': None, + } + + try: + embedding_result = generate_embedding(str(value or '').strip()) + except Exception: + return { + 'value_embedding': None, + 'embedding_model': None, + 'embedding_updated_at': None, + } + + if not embedding_result: + return { + 'value_embedding': None, + 'embedding_model': None, + 'embedding_updated_at': None, + } + + if isinstance(embedding_result, tuple): + embedding_vector, token_usage = embedding_result + else: + embedding_vector = embedding_result + token_usage = None + + if not embedding_vector: + return { + 'value_embedding': None, + 'embedding_model': None, + 'embedding_updated_at': None, + } + + return { + 'value_embedding': embedding_vector, + 'embedding_model': (token_usage or {}).get('model_deployment_name') if isinstance(token_usage, dict) else None, + 'embedding_updated_at': datetime.now(timezone.utc).isoformat(), + } + + def get_fact_item(self, scope_id, fact_id): + partition_key = self.get_partition_key(scope_id) + try: + return self.normalize_fact_item(self.container.read_item(item=fact_id, partition_key=partition_key)) + except exceptions.CosmosResourceNotFoundError: + return None + + def set_fact(self, scope_type, scope_id, value, conversation_id=None, agent_id=None, memory_type=MEMORY_TYPE_FACT): now = datetime.now(timezone.utc).isoformat() doc_id = str(uuid.uuid4()) + normalized_memory_type = self.normalize_memory_type(memory_type) item = { "id": doc_id, "agent_id": agent_id, "scope_type": scope_type, "scope_id": scope_id, "conversation_id": conversation_id, + "memory_type": normalized_memory_type, "value": value, "created_at": now, "updated_at": now } + item.update(self._build_embedding_fields(value, normalized_memory_type)) self.container.upsert_item(item) - return item + return self.normalize_fact_item(item) def get_fact(self, scope_id, fact_id): - partition_key = self.get_partition_key(scope_id) - try: - item = self.container.read_item(item=fact_id, scope_id=partition_key) - return item.get("value") - except exceptions.CosmosResourceNotFoundError: + item = self.get_fact_item(scope_id, fact_id) + if item is None: return None + return item.get("value") - def get_facts(self, scope_type, scope_id, conversation_id=None, agent_id=None): + def get_facts(self, scope_type, scope_id, conversation_id=None, agent_id=None, memory_type=None): partition_key = self.get_partition_key(scope_id) query = "SELECT * FROM c WHERE c.scope_id=@scope_id AND c.scope_type=@scope_type" params = [ @@ -57,8 +129,68 @@ def get_facts(self, scope_type, scope_id, conversation_id=None, agent_id=None): if useOptionalFilters and conversation_id is not None: query += " AND c.conversation_id=@conversation_id" params.append({"name": "@conversation_id", "value": conversation_id}) - items = list(self.container.query_items(query=query, parameters=params, partition_key=partition_key)) - return items + items = [ + self.normalize_fact_item(item) + for item in self.container.query_items(query=query, parameters=params, partition_key=partition_key) + ] + + normalized_memory_type = None + if memory_type is not None: + normalized_memory_type = self.normalize_memory_type(memory_type) + + filtered_items = [] + for item in items: + if normalized_memory_type and item.get('memory_type') != normalized_memory_type: + continue + filtered_items.append(item) + + return filtered_items + + def list_facts(self, scope_type, scope_id, conversation_id=None, agent_id=None, memory_type=None): + items = self.get_facts( + scope_type=scope_type, + scope_id=scope_id, + conversation_id=conversation_id, + agent_id=agent_id, + memory_type=memory_type, + ) + return sorted( + items, + key=lambda item: item.get("updated_at") or item.get("created_at") or "", + reverse=True, + ) + + def update_fact(self, scope_id, fact_id, value=UNSET, memory_type=UNSET): + item = self.get_fact_item(scope_id, fact_id) + if item is None: + return None + + value_changed = value is not UNSET and item.get('value') != value + memory_type_changed = memory_type is not UNSET and self.normalize_memory_type(memory_type) != item.get('memory_type') + + if value is not UNSET: + item["value"] = value + if memory_type is not UNSET: + item['memory_type'] = self.normalize_memory_type(memory_type) + + if value_changed or memory_type_changed: + item.update(self._build_embedding_fields(item.get('value'), item.get('memory_type'))) + + item["updated_at"] = datetime.now(timezone.utc).isoformat() + self.container.upsert_item(item) + return self.normalize_fact_item(item) + + def update_fact_embedding(self, scope_id, fact_id, value_embedding, embedding_model=None): + item = self.get_fact_item(scope_id, fact_id) + if item is None: + return None + + item['value_embedding'] = value_embedding + item['embedding_model'] = embedding_model + item['embedding_updated_at'] = datetime.now(timezone.utc).isoformat() + item['updated_at'] = datetime.now(timezone.utc).isoformat() + self.container.upsert_item(item) + return self.normalize_fact_item(item) def delete_fact(self, scope_id, fact_id): partition_key = self.get_partition_key(scope_id) diff --git a/application/single_app/semantic_kernel_loader.py b/application/single_app/semantic_kernel_loader.py index 777e4492..3a2ca4b5 100644 --- a/application/single_app/semantic_kernel_loader.py +++ b/application/single_app/semantic_kernel_loader.py @@ -44,6 +44,7 @@ from functions_group import assert_group_role, get_group_model_endpoints, require_active_group from functions_personal_actions import get_personal_actions, ensure_migration_complete as ensure_actions_migration_complete from functions_personal_agents import get_personal_agents, ensure_migration_complete as ensure_agents_migration_complete +from functions_agent_payload import can_agent_use_default_multi_endpoint_model from semantic_kernel_plugins.plugin_loader import discover_plugins from semantic_kernel_plugins.openapi_plugin_factory import OpenApiPluginFactory from functions_agent_scope import find_agent_by_scope @@ -348,12 +349,7 @@ def resolve_global_gpt_token_provider(global_key): ) return None - def resolve_multi_endpoint_agent_config(): - endpoint_id = (agent.get("model_endpoint_id") or "").strip() - model_id = (agent.get("model_id") or "").strip() - if not endpoint_id or not model_id: - return None - + def get_agent_model_endpoint_candidates(): endpoints = [] if is_group_agent: if allow_custom_agent_endpoints: @@ -369,7 +365,13 @@ def resolve_multi_endpoint_agent_config(): ]) endpoints.extend([{**endpoint, "_endpoint_scope": "global"} for endpoint in (settings.get("model_endpoints", []) or [])]) - endpoint_cfg = next((e for e in endpoints if e.get("id") == endpoint_id), None) + return endpoints + + def resolve_multi_endpoint_agent_binding(endpoint_candidates, endpoint_id, model_id): + if not endpoint_id or not model_id: + return None + + endpoint_cfg = next((e for e in endpoint_candidates if e.get("id") == endpoint_id), None) if not endpoint_cfg or not endpoint_cfg.get("enabled", True): return None @@ -403,6 +405,52 @@ def resolve_multi_endpoint_agent_config(): "model": model_cfg, } + def resolve_multi_endpoint_agent_config(): + endpoint_candidates = get_agent_model_endpoint_candidates() + endpoint_id = (agent.get("model_endpoint_id") or "").strip() + model_id = (agent.get("model_id") or "").strip() + + if endpoint_id and model_id: + bound_config = resolve_multi_endpoint_agent_binding( + endpoint_candidates, + endpoint_id, + model_id, + ) + if bound_config: + return bound_config + debug_print( + f"[SK Loader] Saved multi-endpoint binding is unavailable for agent '{agent.get('name')}'. Falling back to default model selection." + ) + elif endpoint_id or model_id: + debug_print( + f"[SK Loader] Incomplete multi-endpoint binding for agent '{agent.get('name')}'. Falling back to default model selection." + ) + + if not can_agent_use_default_multi_endpoint_model(agent): + return None + + default_selection = settings.get("default_model_selection", {}) or {} + default_endpoint_id = str(default_selection.get("endpoint_id") or "").strip() + default_model_id = str(default_selection.get("model_id") or "").strip() + if not default_endpoint_id or not default_model_id: + return None + + default_config = resolve_multi_endpoint_agent_binding( + endpoint_candidates, + default_endpoint_id, + default_model_id, + ) + if default_config: + debug_print( + f"[SK Loader] Using saved admin default multi-endpoint model for agent '{agent.get('name')}'." + ) + return default_config + + debug_print( + f"[SK Loader] Saved admin default multi-endpoint model could not be resolved for agent '{agent.get('name')}'." + ) + return None + def resolve_foundry_endpoint_config(): foundry_settings_key = "new_foundry" if agent_type == "new_foundry" else "azure_ai_foundry" allowed_providers = {"aifoundry"} diff --git a/application/single_app/semantic_kernel_plugins/fact_memory_plugin.py b/application/single_app/semantic_kernel_plugins/fact_memory_plugin.py index 3086a08d..188d16fa 100644 --- a/application/single_app/semantic_kernel_plugins/fact_memory_plugin.py +++ b/application/single_app/semantic_kernel_plugins/fact_memory_plugin.py @@ -1,3 +1,4 @@ +# fact_memory_plugin.py """ FactMemoryPlugin for Semantic Kernel: provides write/update/delete operations for fact memory. - Uses FactMemoryStore for persistence. @@ -27,13 +28,14 @@ def __init__(self, store: Optional[FactMemoryStore] = None): value (str): The value to be stored in memory. conversation_id (str): The id of the conversation. agent_id (str): The id of the agent, as specified in the agent's manifest. + memory_type (str): Either 'instruction' or 'fact'. Use 'instruction' for durable response rules or user preferences that should be applied to every future prompt. Use 'fact' for profile/context details that should only be recalled when relevant to the current request. Facts are persistent values that provide important context, background knowledge, or user preferences to the AI agent. - Use facts to remember things that should always be available as context for this agent. + Let the model decide the memory_type when saving a new memory. """, name="set_fact" ) - def set_fact(self, scope_type: str, scope_id: str, value: str, conversation_id: str, agent_id: str) -> dict: + def set_fact(self, scope_type: str, scope_id: str, value: str, conversation_id: str, agent_id: str, memory_type: str = 'fact') -> dict: """ Store a fact for the given agent, scope, and conversation. """ @@ -42,9 +44,31 @@ def set_fact(self, scope_type: str, scope_id: str, value: str, conversation_id: scope_id=scope_id, value=value, conversation_id=conversation_id, - agent_id=agent_id + agent_id=agent_id, + memory_type=memory_type, ) + @kernel_function( + description="Update an existing fact by its unique id. Provide memory_type only when you want to change it between 'instruction' and 'fact'.", + name="update_fact" + ) + def update_fact(self, scope_id: str, fact_id: str, value: str, memory_type: str = '') -> dict: + """ + Update a fact value by its unique id and scope_id partition key. + """ + update_kwargs = { + 'scope_id': scope_id, + 'fact_id': fact_id, + 'value': value, + } + if str(memory_type or '').strip(): + update_kwargs['memory_type'] = memory_type + + updated_fact = self.store.update_fact( + **update_kwargs, + ) + return updated_fact or {} + @kernel_function( description="Delete a fact by its unique id.", name="delete_fact" diff --git a/application/single_app/static/css/chats.css b/application/single_app/static/css/chats.css index d4b038ef..1ab7b282 100644 --- a/application/single_app/static/css/chats.css +++ b/application/single_app/static/css/chats.css @@ -1334,6 +1334,7 @@ a.citation-link:hover { background-color: #f1f0f0; /* Lighter grey for AI */ color: black; border-bottom-left-radius: 0; + min-width: min(320px, 90%); } /* File message bubble styling */ diff --git a/application/single_app/static/images/features/agent_default_model_review_action.png b/application/single_app/static/images/features/agent_default_model_review_action.png new file mode 100644 index 00000000..7e1262ba Binary files /dev/null and b/application/single_app/static/images/features/agent_default_model_review_action.png differ diff --git a/application/single_app/static/images/features/agent_default_model_review_summary.png b/application/single_app/static/images/features/agent_default_model_review_summary.png new file mode 100644 index 00000000..548b6a6e Binary files /dev/null and b/application/single_app/static/images/features/agent_default_model_review_summary.png differ diff --git a/application/single_app/static/images/features/fact_memory_management.png b/application/single_app/static/images/features/fact_memory_management.png new file mode 100644 index 00000000..5b62798c Binary files /dev/null and b/application/single_app/static/images/features/fact_memory_management.png differ diff --git a/application/single_app/static/images/features/facts_citation_and_thoughts.png b/application/single_app/static/images/features/facts_citation_and_thoughts.png new file mode 100644 index 00000000..436f55b4 Binary files /dev/null and b/application/single_app/static/images/features/facts_citation_and_thoughts.png differ diff --git a/application/single_app/static/images/features/facts_memory_view_profile.png b/application/single_app/static/images/features/facts_memory_view_profile.png new file mode 100644 index 00000000..acebb6e9 Binary files /dev/null and b/application/single_app/static/images/features/facts_memory_view_profile.png differ diff --git a/application/single_app/static/js/admin/admin_model_endpoints.js b/application/single_app/static/js/admin/admin_model_endpoints.js index 83be14db..2943795d 100644 --- a/application/single_app/static/js/admin/admin_model_endpoints.js +++ b/application/single_app/static/js/admin/admin_model_endpoints.js @@ -3,7 +3,6 @@ import { showToast } from "../chat/chat-toast.js"; const enableMultiEndpointToggle = document.getElementById("enable_multi_model_endpoints"); -const migrationWarning = document.getElementById("multi-endpoint-warning"); const endpointsWrapper = document.getElementById("model-endpoints-wrapper"); const endpointsTbody = document.getElementById("model-endpoints-tbody"); const addEndpointBtn = document.getElementById("add-model-endpoint-btn"); @@ -11,6 +10,27 @@ const endpointsInput = document.getElementById("model_endpoints_json"); const defaultModelSelect = document.getElementById("default-model-selection"); const defaultModelInput = document.getElementById("default_model_selection_json"); const defaultModelWrapper = document.getElementById("default-model-selection-wrapper"); +const migrationPanel = document.getElementById("agent-default-model-migration-panel"); +const migrationStatus = document.getElementById("agent-default-model-migration-status"); +const migrationCallout = document.getElementById("agent-default-model-migration-callout"); +const migrationResults = document.getElementById("agent-default-model-migration-results"); +const previewMigrationBtn = document.getElementById("preview-agent-default-model-migration-btn"); +const runMigrationBtn = document.getElementById("run-agent-default-model-migration-btn"); +const migrationReadyCount = document.getElementById("agent-default-model-ready-count"); +const migrationNeedsDefaultCount = document.getElementById("agent-default-model-needs-default-count"); +const migrationManualCount = document.getElementById("agent-default-model-manual-count"); +const migrationMigratedCount = document.getElementById("agent-default-model-migrated-count"); +const migrationCurrentLabel = document.getElementById("agent-default-model-current-label"); +const migrationSelectionSummary = document.getElementById("agent-default-model-migration-selection-summary"); +const migrationTableBody = document.getElementById("agent-default-model-migration-tbody"); +const migrationSearchInput = document.getElementById("agent-default-model-migration-search"); +const migrationFilterSelect = document.getElementById("agent-default-model-migration-filter"); +const selectReadyMigrationBtn = document.getElementById("select-ready-agent-default-model-migration-btn"); +const selectManualMigrationBtn = document.getElementById("select-manual-agent-default-model-migration-btn"); +const clearMigrationSelectionBtn = document.getElementById("clear-agent-default-model-migration-selection-btn"); + +const migrationModalEl = document.getElementById("agentDefaultModelMigrationModal"); +const migrationModal = migrationModalEl && window.bootstrap ? bootstrap.Modal.getOrCreateInstance(migrationModalEl) : null; const endpointModalEl = document.getElementById("modelEndpointModal"); const endpointModal = endpointModalEl && window.bootstrap ? bootstrap.Modal.getOrCreateInstance(endpointModalEl) : null; @@ -64,6 +84,8 @@ let pendingDeleteTimeout = null; let defaultModelSelection = window.defaultModelSelection && typeof window.defaultModelSelection === "object" ? { ...window.defaultModelSelection } : {}; +let migrationPreviewState = null; +let migrationSelectedKeys = new Set(); const DEFAULT_AOAI_OPENAI_API_VERSION = "2024-05-01-preview"; @@ -110,6 +132,80 @@ function updateDefaultModelInput() { defaultModelInput.value = JSON.stringify(defaultModelSelection || {}); } +function isAdminSettingsFormModified() { + return typeof window.isAdminSettingsFormModified === "function" && window.isAdminSettingsFormModified(); +} + +function setMigrationStatus(message, tone = "muted") { + if (!migrationStatus) { + return; + } + + migrationStatus.textContent = message || ""; + migrationStatus.classList.remove("text-muted", "text-success", "text-warning", "text-danger"); + const className = { + success: "text-success", + warning: "text-warning", + danger: "text-danger" + }[tone] || "text-muted"; + migrationStatus.classList.add(className); +} + +function setMigrationCallout(message = "", tone = "info") { + if (!migrationCallout) { + return; + } + + migrationCallout.classList.remove("alert-info", "alert-warning", "alert-success", "alert-danger"); + if (!message) { + migrationCallout.textContent = ""; + migrationCallout.classList.add("d-none", "alert-info"); + return; + } + + migrationCallout.textContent = message; + migrationCallout.classList.remove("d-none"); + migrationCallout.classList.add(`alert-${tone}`); +} + +function updateMigrationButtonAvailability() { + const selectedCount = migrationSelectedKeys.size; + const hasValidDefault = Boolean(migrationPreviewState?.default_model?.valid); + const formModified = isAdminSettingsFormModified(); + const multiEndpointEnabled = enableMultiEndpointToggle ? enableMultiEndpointToggle.checked : true; + + if (previewMigrationBtn) { + previewMigrationBtn.disabled = !multiEndpointEnabled; + } + if (runMigrationBtn) { + runMigrationBtn.disabled = !multiEndpointEnabled || formModified || !hasValidDefault || selectedCount === 0; + runMigrationBtn.textContent = selectedCount > 0 + ? `Apply Saved Default To Selected (${selectedCount})` + : "Apply Saved Default To Selected"; + } +} + +function handleMigrationConfigurationChange() { + migrationPreviewState = null; + migrationSelectedKeys = new Set(); + setElementVisibility(migrationResults, false); + if (migrationPanel && !migrationPanel.classList.contains("d-none")) { + const multiEndpointEnabled = enableMultiEndpointToggle ? enableMultiEndpointToggle.checked : true; + if (!multiEndpointEnabled) { + setMigrationStatus("Enable multi-endpoint model management to review and rebind agents to a saved default model.", "warning"); + setMigrationCallout("Once multi-endpoint is enabled and saved, admins can use this review workflow to move inherited agents to the saved default model and intentionally override selected explicit agent model choices.", "info"); + } else { + setMigrationStatus("Save your AI model settings before reviewing or migrating agents.", "warning"); + setMigrationCallout("Migration preview uses the saved model endpoints and saved default model. Save settings first.", "warning"); + } + } else { + setMigrationCallout(""); + } + renderMigrationTable(); + updateMigrationSelectionSummary(); + updateMigrationButtonAvailability(); +} + function markModified() { if (typeof window.markFormAsModified === "function") { window.markFormAsModified(); @@ -314,6 +410,7 @@ function handleDefaultModelChange() { } updateDefaultModelInput(); markModified(); + handleMigrationConfigurationChange(); } function updateAuthVisibility() { @@ -713,6 +810,7 @@ function saveEndpoint() { renderEndpoints(); markModified(); + handleMigrationConfigurationChange(); endpointModal?.hide(); showToast("Please save your settings to persist changes.", "warning"); } catch (error) { @@ -779,6 +877,7 @@ function handleTableClick(event) { endpoint.enabled = !endpoint.enabled; renderEndpoints(); markModified(); + handleMigrationConfigurationChange(); return; } @@ -787,6 +886,7 @@ function handleTableClick(event) { modelEndpoints = modelEndpoints.filter((item) => item.id !== endpointId); renderEndpoints(); markModified(); + handleMigrationConfigurationChange(); pendingDeleteEndpointId = null; if (pendingDeleteTimeout) { clearTimeout(pendingDeleteTimeout); @@ -811,12 +911,11 @@ function handleToggleChange() { const enabled = !!enableMultiEndpointToggle?.checked; // setElementVisibility(endpointsWrapper, enabled); setElementVisibility(defaultModelWrapper, enabled); - - if (enabled) { - setElementVisibility(migrationWarning, true); - showToast("Multi-endpoint enabled. Existing AI endpoint will be migrated when you save settings.", "warning"); + if (!enabled) { + setElementVisibility(migrationResults, false); } markModified(); + handleMigrationConfigurationChange(); } function escapeHtml(value) { @@ -825,6 +924,363 @@ function escapeHtml(value) { return div.innerHTML; } +function formatMigrationStatus(status) { + if (status === "ready_to_migrate") { + return { label: "Ready", className: "text-bg-primary" }; + } + if (status === "needs_default_model") { + return { label: "Needs Default", className: "text-bg-warning" }; + } + if (status === "manual_review") { + return { label: "Review", className: "text-bg-secondary" }; + } + return { label: "On Default", className: "text-bg-success" }; +} + +function getMigrationAgents() { + return Array.isArray(migrationPreviewState?.agents) ? migrationPreviewState.agents : []; +} + +function resetMigrationSelection(preview) { + const nextSelection = new Set(); + const agents = Array.isArray(preview?.agents) ? preview.agents : []; + agents.forEach((agent) => { + if (agent?.selected_by_default && agent?.selection_key) { + nextSelection.add(agent.selection_key); + } + }); + migrationSelectedKeys = nextSelection; +} + +function getFilteredMigrationAgents() { + const query = (migrationSearchInput?.value || "").trim().toLowerCase(); + const filterValue = migrationFilterSelect?.value || "all"; + + return getMigrationAgents().filter((agent) => { + if (filterValue === "selected" && !migrationSelectedKeys.has(agent.selection_key)) { + return false; + } + if (filterValue !== "all" && filterValue !== "selected" && agent.migration_status !== filterValue) { + return false; + } + if (!query) { + return true; + } + + const haystack = [ + agent.scope, + agent.scope_label, + agent.agent_display_name, + agent.agent_name, + agent.current_binding_label, + agent.reason + ].join(" ").toLowerCase(); + return haystack.includes(query); + }); +} + +function updateMigrationSelectionSummary() { + if (!migrationSelectionSummary) { + return; + } + + const selectedAgents = getMigrationAgents().filter((agent) => migrationSelectedKeys.has(agent.selection_key)); + const selectedCount = selectedAgents.length; + const recommendedCount = selectedAgents.filter((agent) => agent.migration_status === "ready_to_migrate").length; + const overrideCount = selectedAgents.filter((agent) => agent.can_force_migrate).length; + + if (!selectedCount) { + migrationSelectionSummary.textContent = "No agents selected. Recommended rows are preselected after each review refresh."; + return; + } + + migrationSelectionSummary.textContent = `Selected ${selectedCount} agents: ${recommendedCount} recommended and ${overrideCount} explicit overrides.`; +} + +function renderMigrationTable() { + if (!migrationTableBody) { + return; + } + + if (!migrationPreviewState) { + migrationTableBody.innerHTML = ` + + Review agents to load migration candidates. + + `; + return; + } + + const agents = getFilteredMigrationAgents(); + if (!agents.length) { + migrationTableBody.innerHTML = ` + + No agents match the current filter. + + `; + return; + } + + migrationTableBody.innerHTML = agents.map((agent) => { + const status = formatMigrationStatus(agent.migration_status); + const agentLabel = agent.agent_display_name || agent.agent_name || "Unnamed agent"; + const secondaryLabel = agent.agent_display_name && agent.agent_name && agent.agent_display_name !== agent.agent_name + ? `
${escapeHtml(agent.agent_name)}
` + : ""; + const scopeSuffix = agent.scope !== "global" && agent.scope_label + ? `
${escapeHtml(agent.scope_label)}
` + : ""; + const isSelected = migrationSelectedKeys.has(agent.selection_key); + const selectControl = agent.can_select + ? ` +
+ +
+
${agent.selected_by_default ? "Recommended" : "Override"}
+ ` + : 'Locked'; + + return ` + + ${selectControl} + +
${escapeHtml(agent.scope)}
+ ${scopeSuffix} + + +
${escapeHtml(agentLabel)}
+ ${secondaryLabel} + + ${status.label} + ${escapeHtml(agent.current_binding_label || "Not set")} + ${escapeHtml(agent.reason || "")} + + `; + }).join(""); +} + +function selectRecommendedMigrationAgents() { + const nextSelection = new Set(); + getMigrationAgents().forEach((agent) => { + if (agent?.selected_by_default && agent?.selection_key) { + nextSelection.add(agent.selection_key); + } + }); + migrationSelectedKeys = nextSelection; + renderMigrationTable(); + updateMigrationSelectionSummary(); + updateMigrationButtonAvailability(); +} + +function addManualOverrideMigrationAgents() { + getMigrationAgents().forEach((agent) => { + if (agent?.can_force_migrate && agent?.selection_key) { + migrationSelectedKeys.add(agent.selection_key); + } + }); + renderMigrationTable(); + updateMigrationSelectionSummary(); + updateMigrationButtonAvailability(); +} + +function clearMigrationSelection() { + migrationSelectedKeys = new Set(); + renderMigrationTable(); + updateMigrationSelectionSummary(); + updateMigrationButtonAvailability(); +} + +function handleMigrationTableSelectionChange(event) { + const checkbox = event.target.closest('input[data-selection-key]'); + if (!checkbox) { + return; + } + + const selectionKey = checkbox.dataset.selectionKey || ""; + if (!selectionKey) { + return; + } + + if (checkbox.checked) { + migrationSelectedKeys.add(selectionKey); + } else { + migrationSelectedKeys.delete(selectionKey); + } + + updateMigrationSelectionSummary(); + updateMigrationButtonAvailability(); +} + +async function openMigrationReviewModal() { + await loadAgentMigrationPreview({ openModal: true, showToastOnSuccess: false }); +} + +function renderMigrationPreview(preview) { + migrationPreviewState = preview || null; + + if (!preview || !migrationResults || !migrationTableBody) { + migrationSelectedKeys = new Set(); + renderMigrationTable(); + updateMigrationSelectionSummary(); + updateMigrationButtonAvailability(); + return; + } + + resetMigrationSelection(preview); + + const summary = preview.summary || {}; + const defaultModel = preview.default_model || {}; + + if (migrationReadyCount) { + migrationReadyCount.textContent = `Ready: ${summary.ready_to_migrate || 0}`; + } + if (migrationNeedsDefaultCount) { + migrationNeedsDefaultCount.textContent = `Needs Default: ${summary.needs_default_model || 0}`; + } + if (migrationManualCount) { + migrationManualCount.textContent = `Manual Review: ${summary.manual_review || 0}`; + } + if (migrationMigratedCount) { + migrationMigratedCount.textContent = `On Default: ${summary.already_migrated || 0}`; + } + if (migrationCurrentLabel) { + migrationCurrentLabel.textContent = defaultModel.valid + ? `Saved default model: ${defaultModel.label}` + : "Saved default model: none selected or no longer available"; + } + + if (!defaultModel.valid && (summary.needs_default_model || 0) > 0) { + setMigrationStatus("Save a valid default model before migrating inherited agents.", "warning"); + setMigrationCallout("Select and save a valid default model first, then rerun the review or migration.", "warning"); + } else if ((summary.ready_to_migrate || 0) > 0 || (summary.selectable_override || 0) > 0) { + setMigrationStatus( + `Found ${summary.ready_to_migrate || 0} recommended agents and ${summary.selectable_override || 0} explicit override candidates for the saved default model.`, + "warning" + ); + setMigrationCallout("Use the review modal to confirm the recommended agents and add only the explicit overrides you intentionally want to rebind to the saved default model.", "info"); + } else if ((summary.manual_review || 0) > 0) { + setMigrationStatus("Only manual-review agents remain, and some may require separate handling.", "warning"); + setMigrationCallout("Foundry-managed agents stay locked here. Other manual-review rows can be selected only when a saved default model is available.", "info"); + } else { + setMigrationStatus("All reviewable agents are already aligned with the saved default model.", "success"); + setMigrationCallout("Use this review workflow again whenever you want to evaluate agents against a new saved default model for cost or lifecycle changes.", "success"); + } + + renderMigrationTable(); + updateMigrationSelectionSummary(); + setElementVisibility(migrationResults, true); + updateMigrationButtonAvailability(); +} + +async function loadAgentMigrationPreview({ showToastOnSuccess = false, openModal = false } = {}) { + if (isAdminSettingsFormModified()) { + setMigrationStatus("Save your AI model settings before reviewing agents.", "warning"); + setMigrationCallout("The migration preview uses the saved default model and saved endpoint catalog. Save settings first.", "warning"); + showToast("Save your AI model settings before reviewing agents.", "warning"); + updateMigrationButtonAvailability(); + return null; + } + + if (previewMigrationBtn) { + previewMigrationBtn.disabled = true; + } + + try { + const response = await fetch("/api/admin/agents/default-model-migration/preview", { + headers: { "Content-Type": "application/json" } + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(data.error || "Failed to review agent migration state."); + } + + renderMigrationPreview(data); + if (openModal && migrationModal) { + migrationModal.show(); + } + if (showToastOnSuccess) { + showToast("Agent migration review updated.", "success"); + } + return data; + } catch (error) { + console.error("Agent migration preview failed", error); + setMigrationStatus("Unable to load the agent migration review.", "danger"); + setMigrationCallout(error.message || "Failed to load the agent migration review.", "danger"); + showToast(error.message || "Failed to load the agent migration review.", "danger"); + return null; + } finally { + if (previewMigrationBtn) { + previewMigrationBtn.disabled = false; + } + updateMigrationButtonAvailability(); + } +} + +async function runDefaultModelAgentMigration() { + if (isAdminSettingsFormModified()) { + setMigrationStatus("Save your AI model settings before running migration.", "warning"); + setMigrationCallout("The migration run uses the saved default model and saved endpoint catalog. Save settings first.", "warning"); + showToast("Save your AI model settings before running migration.", "warning"); + updateMigrationButtonAvailability(); + return; + } + + if (!migrationPreviewState) { + const preview = await loadAgentMigrationPreview(); + if (!preview) { + return; + } + } + + if (!migrationPreviewState?.default_model?.valid) { + setMigrationStatus("A valid saved default model is required before migration.", "warning"); + setMigrationCallout("Select and save a valid default model before migrating inherited agents.", "warning"); + showToast("Select and save a valid default model before migrating agents.", "warning"); + updateMigrationButtonAvailability(); + return; + } + + if (!migrationSelectedKeys.size) { + showToast("Select at least one agent in the review modal before migrating.", "warning"); + updateMigrationButtonAvailability(); + return; + } + + if (runMigrationBtn) { + runMigrationBtn.disabled = true; + } + + try { + const response = await fetch("/api/admin/agents/default-model-migration/run", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + selected_agent_keys: Array.from(migrationSelectedKeys) + }) + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(data.error || "Failed to migrate agents to the saved default model."); + } + + renderMigrationPreview(data.preview || null); + + if (Array.isArray(data.failed) && data.failed.length) { + setMigrationCallout(`Migrated ${data.migrated_count || 0} agents, but ${data.failed.length} updates failed. Review server logs for details.`, "warning"); + showToast(`Migrated ${data.migrated_count || 0} agents with ${data.failed.length} failures.`, "warning"); + } else { + showToast(`Applied the saved default model to ${data.migrated_count || 0} selected agents.`, "success"); + } + } catch (error) { + console.error("Agent migration failed", error); + setMigrationStatus("Agent migration failed.", "danger"); + setMigrationCallout(error.message || "Failed to migrate agents to the saved default model.", "danger"); + showToast(error.message || "Failed to migrate agents to the saved default model.", "danger"); + } finally { + updateMigrationButtonAvailability(); + } +} + function init() { const isMultiEndpointEnabled = enableMultiEndpointToggle ? enableMultiEndpointToggle.checked : true; @@ -883,8 +1339,52 @@ function init() { defaultModelSelect.addEventListener("change", handleDefaultModelChange); } + if (previewMigrationBtn) { + previewMigrationBtn.addEventListener("click", () => { + openMigrationReviewModal(); + }); + } + + if (runMigrationBtn) { + runMigrationBtn.addEventListener("click", () => { + runDefaultModelAgentMigration(); + }); + } + + if (migrationTableBody) { + migrationTableBody.addEventListener("change", handleMigrationTableSelectionChange); + } + + if (migrationSearchInput) { + migrationSearchInput.addEventListener("input", renderMigrationTable); + } + + if (migrationFilterSelect) { + migrationFilterSelect.addEventListener("change", renderMigrationTable); + } + + if (selectReadyMigrationBtn) { + selectReadyMigrationBtn.addEventListener("click", selectRecommendedMigrationAgents); + } + + if (selectManualMigrationBtn) { + selectManualMigrationBtn.addEventListener("click", addManualOverrideMigrationAgents); + } + + if (clearMigrationSelectionBtn) { + clearMigrationSelectionBtn.addEventListener("click", clearMigrationSelection); + } + updateHiddenInput(); buildDefaultModelOptions(); + updateMigrationButtonAvailability(); + + if (migrationPanel && isMultiEndpointEnabled) { + setMigrationStatus("Review inherited agents and migrate them to the saved default model when ready.", "muted"); + loadAgentMigrationPreview(); + } else if (migrationPanel) { + handleMigrationConfigurationChange(); + } } if (document.readyState === "loading") { diff --git a/application/single_app/static/js/admin/admin_settings.js b/application/single_app/static/js/admin/admin_settings.js index b007b6b0..896bf6b3 100644 --- a/application/single_app/static/js/admin/admin_settings.js +++ b/application/single_app/static/js/admin/admin_settings.js @@ -115,6 +115,16 @@ document.addEventListener('DOMContentLoaded', () => { }); }); + document.addEventListener('click', function (event) { + const trigger = event.target.closest('[data-open-admin-tab]'); + if (!trigger) { + return; + } + + event.preventDefault(); + openAdminSettingsTab(trigger.getAttribute('data-open-admin-tab')); + }); + window.addEventListener("popstate", activateTabFromHash); // --- NEW: Classification Setup --- @@ -4815,6 +4825,7 @@ function markFormAsModified() { } window.markFormAsModified = markFormAsModified; +window.isAdminSettingsFormModified = () => formModified; /** * Update the save button appearance based on form state @@ -4873,4 +4884,16 @@ function setupLatestFeatureImageModal() { modalImage.src = ''; modalImage.alt = 'Latest feature preview'; }); -} \ No newline at end of file +} + +function openAdminSettingsTab(targetHash) { + if (!targetHash) { + return; + } + + const normalizedHash = targetHash.startsWith('#') ? targetHash : `#${targetHash}`; + history.pushState(null, null, normalizedHash); + activateTabFromHash(); +} + +window.openAdminSettingsTab = openAdminSettingsTab; \ No newline at end of file diff --git a/application/single_app/static/js/chat/chat-message-export.js b/application/single_app/static/js/chat/chat-message-export.js index 8211c097..ffdb9144 100644 --- a/application/single_app/static/js/chat/chat-message-export.js +++ b/application/single_app/static/js/chat/chat-message-export.js @@ -155,17 +155,42 @@ export function copyAsPrompt(messageDiv, messageId, role) { * Open the user's default email client with the message content * pre-filled in the email body via a mailto: link. */ -export function openInEmail(messageDiv, messageId, role) { +export async function openInEmail(messageDiv, messageId, role) { + const conversationId = window.currentConversationId; + if (!conversationId || !messageId) { + showToast('Cannot email โ€” no active conversation or message.', 'warning'); + return; + } + const content = getMessageMarkdown(messageDiv, role); if (!content) { showToast('No message content to email.', 'warning'); return; } - const { sender } = getMessageMeta(messageDiv, role); - const subject = `Chat message from ${sender}`; + try { + const response = await fetch('/api/message/export-email-draft', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + message_id: messageId, + conversation_id: conversationId + }) + }); + + const data = await response.json().catch(() => null); + if (!response.ok) { + const errorMsg = data?.error || `Email export failed (${response.status})`; + showToast(errorMsg, 'danger'); + return; + } - // mailto: uses the body parameter for content - const mailtoUrl = `mailto:?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(content)}`; - window.open(mailtoUrl, '_blank'); + const subject = data?.subject || 'Shared chat message'; + const body = data?.body || content; + const mailtoUrl = `mailto:?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`; + window.location.href = mailtoUrl; + } catch (err) { + console.error('Error exporting message to email:', err); + showToast('Failed to open email draft.', 'danger'); + } } diff --git a/application/single_app/static/js/chat/chat-onload.js b/application/single_app/static/js/chat/chat-onload.js index 0f6d7935..d4ed6777 100644 --- a/application/single_app/static/js/chat/chat-onload.js +++ b/application/single_app/static/js/chat/chat-onload.js @@ -85,20 +85,6 @@ window.addEventListener('DOMContentLoaded', async () => { const preferredModelId = userSettings?.preferredModelId; const preferredModelDeployment = userSettings?.preferredModelDeployment; - // Multi-endpoint migration notice - const notice = window.multiEndpointNotice || {}; - const noticeEl = document.getElementById("multi-endpoint-notice"); - const dismissBtn = document.getElementById("dismiss-multi-endpoint-notice"); - if (notice?.enabled && noticeEl && !userSettings?.dismissedMultiEndpointNotice) { - noticeEl.classList.remove("d-none"); - if (dismissBtn) { - dismissBtn.addEventListener("click", async () => { - noticeEl.classList.add("d-none"); - await saveUserSetting({ dismissedMultiEndpointNotice: true }); - }); - } - } - initializeModelSelector(); await populateModelDropdown({ preferredModelId, diff --git a/application/single_app/static/js/chat/chat-streaming.js b/application/single_app/static/js/chat/chat-streaming.js index 4778a65a..2fe1b845 100644 --- a/application/single_app/static/js/chat/chat-streaming.js +++ b/application/single_app/static/js/chat/chat-streaming.js @@ -542,10 +542,12 @@ function finalizeStreamingMessage(messageId, userMessageId, finalData) { } return; } + + const sender = finalData.role === 'safety' || finalData.blocked ? 'safety' : 'AI'; // Create proper message with all metadata using appendMessage appendMessage( - 'AI', + sender, finalData.full_content || '', finalData.model_deployment_name, finalData.message_id, diff --git a/application/single_app/static/js/chat/chat-thoughts.js b/application/single_app/static/js/chat/chat-thoughts.js index a405d918..597edc90 100644 --- a/application/single_app/static/js/chat/chat-thoughts.js +++ b/application/single_app/static/js/chat/chat-thoughts.js @@ -15,6 +15,7 @@ let activeStreamingServerMessageId = null; function getThoughtIcon(stepType) { const iconMap = { 'history_context': 'bi-diagram-3', + 'fact_memory': 'bi-journal-bookmark', 'search': 'bi-search', 'tabular_analysis': 'bi-table', 'web_search': 'bi-globe', diff --git a/application/single_app/static/js/workspace/group_agents.js b/application/single_app/static/js/workspace/group_agents.js index 69165b74..03a52f7c 100644 --- a/application/single_app/static/js/workspace/group_agents.js +++ b/application/single_app/static/js/workspace/group_agents.js @@ -403,6 +403,7 @@ async function chatWithGroupAgent(agentName) { const payloadData = { selected_agent: { + id: agent.id || null, name: agentName, display_name: agent.display_name || agent.displayName || agentName, is_global: !!agent.is_global, diff --git a/application/single_app/static/js/workspace/workspace_agents.js b/application/single_app/static/js/workspace/workspace_agents.js index 822ca331..88404b86 100644 --- a/application/single_app/static/js/workspace/workspace_agents.js +++ b/application/single_app/static/js/workspace/workspace_agents.js @@ -207,8 +207,13 @@ async function chatWithAgent(agentName) { const payloadData = { selected_agent: { - name: agentName, - is_global: !!agent.is_global + id: agent.id || null, + name: agentName, + display_name: agent.display_name || agent.displayName || agentName, + is_global: !!agent.is_global, + is_group: false, + group_id: null, + group_name: null } }; diff --git a/application/single_app/support_menu_config.py b/application/single_app/support_menu_config.py index f7fca0d2..7fd469d0 100644 --- a/application/single_app/support_menu_config.py +++ b/application/single_app/support_menu_config.py @@ -9,12 +9,13 @@ 'id': 'guided_tutorials', 'title': 'Guided Tutorials', 'icon': 'bi-signpost-split', - 'summary': 'Step-by-step walkthroughs help users discover core chat, workspace, and onboarding flows faster.', - 'details': 'Guided Tutorials add in-product walkthroughs so you can learn the interface in context instead of hunting through menus first.', - 'why': 'This matters because the fastest way to learn a new workflow is usually inside the workflow itself, with the right controls highlighted as you go.', + 'summary': 'Step-by-step walkthroughs help users discover core chat, workspace, and onboarding flows faster, and each user can now hide the launchers when they no longer need them.', + 'details': 'Guided Tutorials add in-product walkthroughs so you can learn the interface in context instead of hunting through menus first. Tutorial launchers are shown by default and can be hidden or restored later from your profile page.', + 'why': 'This matters because the fastest way to learn a new workflow is usually inside the workflow itself, with the right controls highlighted as you go, while still letting each user hide the launcher once they are comfortable with the app.', 'guidance': [ 'Start with the Chat Tutorial to learn message tools, uploads, prompts, and follow-up workflows.', 'If Personal Workspace is enabled for your environment, open the Workspace Tutorial to learn uploads, filters, tags, prompts, agents, and actions.', + 'Tutorial buttons are visible by default. If you prefer a cleaner interface, open your profile page and hide them for your own account.', ], 'actions': [ { @@ -32,6 +33,13 @@ 'icon': 'bi-folder2-open', 'requires_settings': ['enable_user_workspace'], }, + { + 'label': 'Manage Tutorial Visibility', + 'description': 'Open your profile page to show or hide the tutorial launch buttons for your account.', + 'endpoint': 'profile', + 'fragment': 'tutorial-preferences', + 'icon': 'bi-person-gear', + }, ], 'image': 'images/features/guided_tutorials_chat.png', 'image_alt': 'Guided tutorials feature screenshot', @@ -383,6 +391,61 @@ }, ], }, + { + 'id': 'fact_memory', + 'title': 'Fact Memory', + 'icon': 'bi-journal-bookmark', + 'summary': 'Profile-based memory now distinguishes always-on Instructions from recall-only Facts so the assistant can carry durable preferences and relevant personal context forward more cleanly.', + 'details': 'Fact Memory gives each user a compact profile experience for saving Instructions and Facts. Instructions act like durable response preferences, while Facts are recalled only when they are relevant to the current request.', + 'why': 'This matters because you no longer need to restate the same preferences or personal context in every conversation, and the chat experience now shows when saved instructions and facts were actually used.', + 'guidance': [ + 'Open your profile page and use Fact Memory when you want to save a lasting preference or a detail about yourself.', + 'Choose Instruction for durable preferences like tone, brevity, formatting, or things the assistant should always keep in mind.', + 'Choose Fact for details that should only be recalled when relevant, such as who you are, what you prefer, or other personal context.', + 'Try a chat prompt like "tell me all about myself" when you want to confirm which saved facts the assistant can recall.', + ], + 'actions': [ + { + 'label': 'Manage Fact Memory', + 'description': 'Open your profile page and jump straight to the Fact Memory section to add, edit, or remove saved instructions and facts.', + 'endpoint': 'profile', + 'fragment': 'fact-memory-settings', + 'icon': 'bi-person-gear', + }, + { + 'label': 'Try It in Chat', + 'description': 'Open Chat and ask a personal or preference-aware question to see instruction memory and fact recall in action.', + 'endpoint': 'chats', + 'fragment': 'chatbox', + 'icon': 'bi-chat-dots', + }, + ], + 'image': 'images/features/fact_memory_management.png', + 'image_alt': 'Fact memory management modal screenshot', + 'images': [ + { + 'path': 'images/features/facts_memory_view_profile.png', + 'alt': 'Profile fact memory section screenshot', + 'title': 'Fact Memory on Profile', + 'caption': 'Profile page section for adding saved instructions and facts and opening the manager modal.', + 'label': 'Profile Entry Point', + }, + { + 'path': 'images/features/fact_memory_management.png', + 'alt': 'Fact memory management modal screenshot', + 'title': 'Manage Fact Memories', + 'caption': 'Compact popup manager showing saved instructions and facts with search, paging, edit, and type controls.', + 'label': 'Memory Manager', + }, + { + 'path': 'images/features/facts_citation_and_thoughts.png', + 'alt': 'Chat fact memory thoughts and citations screenshot', + 'title': 'Instruction Memory and Fact Recall in Chat', + 'caption': 'Chat response showing instruction memory and fact recall surfaced as dedicated thoughts and citations.', + 'label': 'Chat Recall', + }, + ], + }, { 'id': 'deployment', 'title': 'Deployment', @@ -550,7 +613,10 @@ def get_support_latest_feature_catalog(): def get_default_support_latest_features_visibility(): """Return default visibility for each user-facing latest feature.""" - return {item['id']: True for item in _SUPPORT_LATEST_FEATURE_CATALOG} + defaults = {item['id']: True for item in _SUPPORT_LATEST_FEATURE_CATALOG} + defaults['deployment'] = False + defaults['redis_key_vault'] = False + return defaults def normalize_support_latest_features_visibility(raw_visibility): diff --git a/application/single_app/templates/_sidebar_nav.html b/application/single_app/templates/_sidebar_nav.html index 5e5a8b77..d53586cf 100644 --- a/application/single_app/templates/_sidebar_nav.html +++ b/application/single_app/templates/_sidebar_nav.html @@ -275,6 +275,11 @@ Latest FeaturesNew