diff --git a/app/api/chat.py b/app/api/chat.py index 35ef3256..c158bd9a 100644 --- a/app/api/chat.py +++ b/app/api/chat.py @@ -97,6 +97,38 @@ async def start_conversation( initial_state=session_data.initial_state, ) + # Resolve school name from school_wriveted_id if not already set + session_state = conversation_session.state or {} + ctx = session_state.get("context", {}) + school_wriveted_id = ctx.get("school_wriveted_id") + if school_wriveted_id and not ctx.get("school_name"): + try: + from app.repositories.school_repository import school_repository + + school_obj = await school_repository.aget_by_wriveted_id_or_404( + db=session, wriveted_id=school_wriveted_id + ) + ctx["school_name"] = school_obj.name + session_state["context"] = ctx + await chat_repo.update_session_state( + session, + session_id=conversation_session.id, + state_updates=session_state, + expected_revision=conversation_session.revision, + ) + # Refresh session to pick up updated state + refreshed = await chat_repo.get_session_by_id( + session, conversation_session.id + ) + if refreshed: + conversation_session = refreshed + except Exception as e: + logger.warning( + "Could not resolve school name", + school_wriveted_id=school_wriveted_id, + error=str(e), + ) + # Get initial node initial_node = await chat_runtime.get_initial_node( session, session_data.flow_id, conversation_session diff --git a/app/repositories/cms_repository.py b/app/repositories/cms_repository.py index 895e3191..73fd9dca 100644 --- a/app/repositories/cms_repository.py +++ b/app/repositories/cms_repository.py @@ -374,7 +374,7 @@ async def get_random_content( json_filter = json.dumps({key: value}) conditions.append( - text("info @> :json_filter::jsonb").bindparams( + text("info @> cast(:json_filter as jsonb)").bindparams( json_filter=json_filter ) ) diff --git a/app/services/action_processor.py b/app/services/action_processor.py index d0f5ba69..9e7ec95d 100644 --- a/app/services/action_processor.py +++ b/app/services/action_processor.py @@ -9,7 +9,7 @@ - aggregate: Aggregate values from a list using various operations """ -from datetime import datetime +import datetime from typing import Any, Dict from sqlalchemy.ext.asyncio import AsyncSession @@ -29,6 +29,21 @@ logger = get_logger() +def _extract_nested(data: Dict[str, Any], key_path: str) -> Any: + """Extract a value from a nested dict using dot notation.""" + keys = key_path.split(".") + value = data + try: + for key in keys: + if isinstance(value, dict): + value = value.get(key) + else: + return None + return value + except (KeyError, TypeError): + return None + + class ActionNodeProcessor(NodeProcessor): """Processor for ACTION nodes with support for api_call actions.""" @@ -145,6 +160,11 @@ async def _execute_actions_sync( current_state = session.state or {} + # Inject db session and user ID for internal API calls + context = {**context, "db": db} + if session.user_id: + context["session_user_id"] = str(session.user_id) + for i, action in enumerate(actions): action_type = action.get("type") action_id = f"{node_id}_action_{i}" @@ -194,7 +214,7 @@ async def _execute_actions_sync( # Update session state if variables were modified if variables_updated: - current_state.update(variables_updated) + self._deep_merge(current_state, variables_updated) await chat_repo.update_session_state( db, session_id=session.id, @@ -214,7 +234,7 @@ async def _execute_actions_sync( "variables_updated": list(variables_updated.keys()), "success": success, "errors": errors, - "timestamp": datetime.utcnow().isoformat(), + "timestamp": datetime.datetime.utcnow().isoformat(), "processed_async": False, }, ) @@ -233,9 +253,10 @@ async def _handle_set_variable( value = action.get("value") if variable and value is not None: - # Substitute variables in value if it's a string - if isinstance(value, str): - value = self.runtime.substitute_variables(value, state) + # Recursively substitute variables in the value. + # substitute_object handles strings, lists, and dicts, and preserves + # typed values when the entire string is a single {{var}} reference. + value = self.runtime.substitute_object(value, state) self._set_nested_value(updates, variable, value) logger.debug(f"Set variable {variable} = {value}") @@ -285,6 +306,51 @@ async def _handle_api_call( ) -> None: """Handle api_call action.""" api_config_data = action.get("config", {}) + auth_type = api_config_data.get("auth_type", "internal") + + # For internal endpoints, try direct service call (bypasses HTTP + auth) + if auth_type == "internal": + from app.services.internal_api_handlers import INTERNAL_HANDLERS + + endpoint = api_config_data.get("endpoint", "") + db = context.get("db") + + if endpoint in INTERNAL_HANDLERS and db is not None: + resolved_body = self.runtime.substitute_object( + api_config_data.get("body", {}), state + ) + resolved_params = self.runtime.substitute_object( + api_config_data.get("query_params", {}), state + ) + + result_data = await INTERNAL_HANDLERS[endpoint]( + db, resolved_body, resolved_params + ) + + response_mapping = api_config_data.get("response_mapping", {}) + for response_path, variable_name in response_mapping.items(): + value = _extract_nested(result_data, response_path) + if value is not None: + self._set_nested_value(updates, variable_name, value) + + logger.info( + "Internal API call via direct service call", + endpoint=endpoint, + variables_updated=list(response_mapping.values()), + ) + return + + # For authenticated sessions, generate a short-lived JWT + if auth_type == "internal" and context.get("session_user_id"): + from app.services.security import create_access_token + + token = create_access_token( + subject=f"Wriveted:User-Account:{context['session_user_id']}", + expires_delta=datetime.timedelta(minutes=5), + ) + api_config_data = {**api_config_data} + api_config_data["auth_type"] = "bearer" + api_config_data["auth_config"] = {"token": token} # Create API call configuration api_config = ApiCallConfig(**api_config_data) @@ -297,8 +363,9 @@ async def _handle_api_call( result = await api_client.execute_api_call(api_config, state, composite_scopes) if result.success: - # Update variables with API response - updates.update(result.variables_updated) + # Update variables with API response using nested paths + for var_name, var_value in result.variables_updated.items(): + self._set_nested_value(updates, var_name, var_value) logger.info( "API call successful", endpoint=api_config.endpoint, @@ -307,12 +374,16 @@ async def _handle_api_call( else: # Store error information error_var = api_config_data.get("error_variable", "api_error") - updates[error_var] = { - "error": result.error_message, - "status_code": result.status_code, - "timestamp": datetime.utcnow().isoformat(), - "fallback_used": result.fallback_used, - } + self._set_nested_value( + updates, + error_var, + { + "error": result.error_message, + "status_code": result.status_code, + "timestamp": datetime.datetime.utcnow().isoformat(), + "fallback_used": result.fallback_used, + }, + ) logger.error( "API call failed", endpoint=api_config.endpoint, @@ -450,6 +521,14 @@ def _build_cel_expression( else: raise ValueError(f"Unknown aggregate operation: {operation}") + def _deep_merge(self, base: Dict[str, Any], updates: Dict[str, Any]) -> None: + """Recursively merge updates into base dict, preserving existing nested keys.""" + for key, value in updates.items(): + if key in base and isinstance(base[key], dict) and isinstance(value, dict): + self._deep_merge(base[key], value) + else: + base[key] = value + def _get_nested_value(self, data: Dict[str, Any], key_path: str) -> Any: """Get nested value from dictionary using dot notation.""" keys = key_path.split(".") diff --git a/app/services/api_client.py b/app/services/api_client.py index 4eec0df1..282a2411 100644 --- a/app/services/api_client.py +++ b/app/services/api_client.py @@ -5,11 +5,11 @@ with proper authentication, error handling, and response processing. """ -import logging from typing import Any, Dict, Optional import httpx from pydantic import BaseModel +from structlog import get_logger from app.config import get_settings from app.services.circuit_breaker import ( @@ -18,7 +18,7 @@ get_circuit_breaker, ) -logger = logging.getLogger(__name__) +logger = get_logger() class ApiCallConfig(BaseModel): @@ -68,7 +68,11 @@ class InternalApiClient: def __init__(self): self.settings = get_settings() - self.base_url = self.settings.WRIVETED_INTERNAL_API + self.base_url = ( + str(self.settings.WRIVETED_INTERNAL_API) + if self.settings.WRIVETED_INTERNAL_API + else None + ) self.session: Optional[httpx.AsyncClient] = None async def initialize(self) -> None: diff --git a/app/services/cel_evaluator.py b/app/services/cel_evaluator.py index f8c9b071..3eb1fffd 100644 --- a/app/services/cel_evaluator.py +++ b/app/services/cel_evaluator.py @@ -95,6 +95,19 @@ def _cel_collect(items: List[Any]) -> List[Any]: return _cel_flatten(items) +def _cel_top_keys(d: Dict[str, Any], n: int = 5) -> List[str]: + """Return the top N keys from a dict sorted by value descending. + + Useful for converting a hue_profile dict (hue→weight) into a ranked + list of hue keys for the recommendation API. + """ + if not isinstance(d, dict): + return [] + numeric_items = [(k, v) for k, v in d.items() if isinstance(v, (int, float))] + numeric_items.sort(key=lambda pair: pair[1], reverse=True) + return [k for k, _ in numeric_items[:n]] + + # Registry of custom functions available in CEL expressions CUSTOM_CEL_FUNCTIONS: Dict[str, Callable] = { "sum": _cel_sum, @@ -108,6 +121,7 @@ def _cel_collect(items: List[Any]) -> List[Any]: "merge_last": _cel_merge_last, "flatten": _cel_flatten, "collect": _cel_collect, + "top_keys": _cel_top_keys, } @@ -174,6 +188,7 @@ def evaluate_cel_expression( - min(temp.quiz_results.map(x, x.time)) - merge(selections.map(x, x.preferences)) - flatten(items.map(x, x.tags)) + - top_keys(user.hue_profile, 5) """ try: if include_aggregation_functions: diff --git a/app/services/chat_runtime.py b/app/services/chat_runtime.py index f9125674..15a41867 100644 --- a/app/services/chat_runtime.py +++ b/app/services/chat_runtime.py @@ -196,6 +196,21 @@ async def _render_content_message( def _render_inline_message( self, msg_config: Dict[str, Any], session_state: Dict[str, Any] ) -> Optional[Dict[str, Any]]: + message_type = msg_config.get("type", "text") + + # Handle book_list type: resolve source variable to actual book data + if message_type == "book_list": + source_var = msg_config.get("source", "") + books = self.runtime.substitute_object( + f"{{{{{source_var}}}}}", session_state + ) + if books and isinstance(books, list): + return { + "type": "book_list", + "content": {"books": books}, + } + return None + raw_content = None if "content" in msg_config: raw_content = msg_config.get("content") @@ -214,7 +229,6 @@ def _render_inline_message( ) } - message_type = msg_config.get("type", "text") return { "type": message_type, "content": content, @@ -323,6 +337,24 @@ async def process( if question_message is None and node_content.get("text"): question_message = {"text": node_content["text"]} + # Substitute variables in the question text (e.g. {{context.school_name}}) + if question_message and question_message.get("text"): + question_message["text"] = self.runtime.substitute_variables( + question_message["text"], session_state + ) + + # For CMS random content, use question_text from CMS as the prompt + if fetched_content and ( + not question_message or not question_message.get("text") + ): + cms_data = fetched_content.content or {} + cms_question_text = cms_data.get("question_text") + if cms_question_text: + if question_message: + question_message["text"] = cms_question_text + else: + question_message = {"text": cms_question_text} + # Record question in history await chat_repo.add_interaction_history( db, @@ -340,12 +372,40 @@ async def process( # Determine variable for storing response variable = node_content.get("variable") or node_content.get("result_variable") + # Resolve options: from CMS content or inline node content + options = node_content.get("options", []) + if fetched_content and not options: + cms_content_data = fetched_content.content or {} + options = cms_content_data.get("answers", []) or cms_content_data.get( + "options", [] + ) + + # Normalize CMS options: ensure label/value fields exist + if options: + for opt in options: + if "label" not in opt and "text" in opt: + opt["label"] = opt["text"] + if "value" not in opt and "text" in opt: + opt["value"] = opt["text"] + + input_type = node_content.get("input_type", "text") + + # Store resolved options in session for process_response() to match against. + # Both CMS-sourced and inline options need to be stored so _match_option + # can return the full option object (with fields like age_number, hue_map). + if options and input_type in ("choice", "image_choice", "button"): + await chat_repo.update_session_state( + db, + session_id=session.id, + state_updates={"system": {"_current_options": options}}, + ) + return { "type": "question", "question": question_message, "content_id": content_id, - "input_type": node_content.get("input_type", "text"), - "options": node_content.get("options", []), + "input_type": input_type, + "options": options, "validation": node_content.get("validation", {}), "variable": variable, "node_id": node.node_id, @@ -382,9 +442,13 @@ async def _fetch_random_content( and value.startswith("${") and value.endswith("}") ): - # Resolve variable reference like "${user.age}" - resolved_value = self.runtime.substitute_variables(value, session_state) - if resolved_value != value: # Successfully resolved + # Convert ${var} syntax to {{var}} for the variable resolver + var_name = value[2:-1] + var_template = "{{" + var_name + "}}" + resolved_value = self.runtime.substitute_variables( + var_template, session_state + ) + if resolved_value != var_template: # Successfully resolved try: resolved_filters[key] = int(resolved_value) except (ValueError, TypeError): @@ -573,17 +637,22 @@ async def process_response( content_id=content_id if content_id else "no_content_id", ) if variable_name: - # Store sanitized user input as the variable name in state sanitized_input = sanitize_user_input(user_input) + # For choice-based inputs, try to store the full option object + stored_value: Any = sanitized_input + if input_type in ("choice", "image_choice", "button"): + options = (session.state or {}).get("system", {}).get( + "_current_options" + ) or [] + stored_value = self._match_option(sanitized_input, options) + # Check if variable name specifies a scope (e.g., "temp.name" or "user.age") if "." in variable_name: - # Variable name already includes scope, store as-is with nested structure scope, var_key = variable_name.split(".", 1) - state_updates = {scope: {var_key: sanitized_input}} + state_updates = {scope: {var_key: stored_value}} else: - # No scope specified, default to 'temp' scope for question responses - state_updates = {"temp": {variable_name: sanitized_input}} + state_updates = {"temp": {variable_name: stored_value}} # Update session state updated_session = await chat_repo.update_session_state( @@ -625,28 +694,47 @@ async def process_response( # Check if any connections have conditions (indicating dynamic choice routing needed) has_conditional_connections = any(conn.conditions for conn in connections) - if input_type == "button" and has_conditional_connections: - # For button inputs with conditional connections, store the selected option value - choice_updates = {"temp": {"user_choice": user_input}} - updated_session = await chat_repo.update_session_state( - db, - session_id=session.id, - state_updates=choice_updates, - expected_revision=session.revision, - ) - # Use updated session for condition evaluation - session = updated_session + next_connection = None + + # For choice-based inputs, try option-index routing first + if input_type in ("choice", "image_choice", "button"): + options = (session.state or {}).get("system", {}).get( + "_current_options" + ) or node_content.get("options", []) + # Find which option was selected by matching user input + selected_index = None + for i, opt in enumerate(options): + if isinstance(opt, dict): + if user_input in ( + opt.get("value"), + opt.get("label"), + opt.get("text"), + ): + selected_index = i + break + elif str(opt) == user_input: + selected_index = i + break - # Find matching connection using new dynamic logic + # Map option index to connection type + option_connection_types = { + 0: ConnectionType.OPTION_0, + 1: ConnectionType.OPTION_1, + } + if selected_index in option_connection_types: + conn_type = option_connection_types[selected_index] + next_connection = await self.get_next_connection(db, node, conn_type) + + # Condition-based routing as fallback + if not next_connection and has_conditional_connections: next_connection = await self._find_matching_connection(db, node, session) - else: - # For simple text inputs or button inputs without conditions, use first DEFAULT connection - next_connection = None + + # Final fallback to DEFAULT connection + if not next_connection: for connection in connections: if connection.connection_type == ConnectionType.DEFAULT: next_connection = connection break - # Fallback to first connection if no DEFAULT found if not next_connection and connections: next_connection = connections[0] next_node = None @@ -670,7 +758,7 @@ async def _find_matching_connection( session: ConversationSession, ) -> Optional[FlowConnection]: """Find connection that matches current session state conditions.""" - from app.crud.chat import chat_repo + from app.repositories.chat_repository import chat_repo from app.services.variable_resolver import create_session_resolver # Get all outgoing connections from this node @@ -744,6 +832,27 @@ def _evaluate_condition(self, conditions: dict, resolver) -> bool: return False + @staticmethod + def _match_option(user_input: str, options: List[Dict[str, Any]]) -> Any: + """Match user input against options and return the full option object. + + Falls back to the raw text if no match is found. + """ + if not options: + return user_input + + for option in options: + if not isinstance(option, dict): + continue + if user_input in ( + option.get("value"), + option.get("label"), + option.get("text"), + ): + return option + + return user_input + async def _render_question_message( self, content, session_state: Dict[str, Any] ) -> Dict[str, Any]: @@ -1107,18 +1216,37 @@ async def process_interaction( chained_next_node = ( next_result.get("next_node") if next_result else None ) - if response["next_node"].node_type == NodeType.QUESTION or ( + if response["next_node"].node_type == NodeType.QUESTION: + awaiting_input = True + # Use the processed result which has variable substitution + # and normalized options, falling back to raw node content + if next_result and next_result.get("type") == "question": + result["input_request"] = { + "input_type": next_result.get("input_type", "text"), + "variable": next_result.get("variable", ""), + "options": next_result.get("options", []), + "question": next_result.get("question", {}), + } + else: + q_content = response["next_node"].content or {} + result["input_request"] = { + "input_type": q_content.get("input_type", "text"), + "variable": q_content.get("variable", ""), + "options": q_content.get("options", []), + "question": q_content.get("question", {}), + } + elif ( isinstance(next_result, dict) and next_result.get("type") == "question" ): + # Action/condition node auto-chained into a question awaiting_input = True - # Add input_request for direct question nodes - q_content = response["next_node"].content or {} + session_position = next_result.get("node_id", session_position) result["input_request"] = { - "input_type": q_content.get("input_type", "text"), - "variable": q_content.get("variable", ""), - "options": q_content.get("options", []), - "question": q_content.get("question", {}), + "input_type": next_result.get("input_type", "text"), + "variable": next_result.get("variable", ""), + "options": next_result.get("options", []), + "question": next_result.get("question", {}), } if chained_next_node: # Handle FlowNode objects @@ -1127,14 +1255,35 @@ async def process_interaction( session_position = chained_next_node.node_id session_flow_id = chained_next_node.flow_id awaiting_input = True - # Add input_request for chained question nodes q_content = chained_next_node.content or {} - result["input_request"] = { - "input_type": q_content.get("input_type", "text"), - "variable": q_content.get("variable", ""), - "options": q_content.get("options", []), - "question": q_content.get("question", {}), - } + + # CMS random-source questions need process() to + # fetch content and resolve options + if q_content.get("source") == "random": + session = await self._refresh_session(db, session) + q_result = await self.process_node( + db, chained_next_node, session + ) + session = await self._refresh_session(db, session) + result["input_request"] = { + "input_type": q_result.get("input_type", "text"), + "variable": q_result.get("variable", ""), + "options": q_result.get("options", []), + "question": q_result.get("question", {}), + } + else: + # Process inline questions for variable substitution + session = await self._refresh_session(db, session) + q_result = await self.process_node( + db, chained_next_node, session + ) + session = await self._refresh_session(db, session) + result["input_request"] = { + "input_type": q_result.get("input_type", "text"), + "variable": q_result.get("variable", ""), + "options": q_result.get("options", []), + "question": q_result.get("question", {}), + } # Handle dict results (e.g., from composite node sub-flows) elif isinstance(chained_next_node, dict): if chained_next_node.get("type") == "question": @@ -1162,11 +1311,20 @@ async def process_interaction( result["current_node_id"] = session_position + # Persist available options so process_response() can match full objects. + # Always write (even empty) to clear stale options from a previous question. + options_to_store = ( + result.get("input_request", {}).get("options", []) + if awaiting_input + else [] + ) + state_updates = {"system": {"_current_options": options_to_store}} + # Update session's current node position (and flow for sub-flow support) session = await chat_repo.update_session_state( db, session_id=session.id, - state_updates={}, # No state changes, just position update + state_updates=state_updates, current_node_id=session_position, current_flow_id=session_flow_id, expected_revision=session.revision, @@ -1178,26 +1336,45 @@ async def process_interaction( # If next node is a FlowNode if isinstance(chained_next_node, FlowNode): if chained_next_node.node_type == NodeType.QUESTION: - # Stop at question and include its content in response + # Stop at question and include its content session_position = chained_next_node.node_id session_flow_id = chained_next_node.flow_id + q_content = chained_next_node.content or {} + + # CMS random-source questions need process() to + # fetch content and resolve options + if q_content.get("source") == "random": + session = await self._refresh_session(db, session) + q_result = await self.process_node( + db, chained_next_node, session + ) + session = await self._refresh_session(db, session) + q_options = q_result.get("options", []) + result["input_request"] = { + "input_type": q_result.get("input_type", "text"), + "variable": q_result.get("variable", ""), + "options": q_options, + "question": q_result.get("question", {}), + } + else: + q_options = q_content.get("options", []) + result["input_request"] = { + "input_type": q_content.get("input_type", "text"), + "variable": q_content.get("variable", ""), + "options": q_options, + "question": q_content.get("question", {}), + } + + q_state = {"system": {"_current_options": q_options}} session = await self._refresh_session(db, session) session = await chat_repo.update_session_state( db, session_id=session.id, - state_updates={}, + state_updates=q_state, current_node_id=session_position, current_flow_id=session_flow_id, expected_revision=session.revision, ) - # Add question content to input_request for frontend - q_content = chained_next_node.content or {} - result["input_request"] = { - "input_type": q_content.get("input_type", "text"), - "variable": q_content.get("variable", ""), - "options": q_content.get("options", []), - "question": q_content.get("question", {}), - } awaiting_input = True break elif chained_next_node.node_type in ( @@ -1489,10 +1666,13 @@ async def get_initial_node( next_node = result.get("next_node") if next_node and isinstance(next_node, FlowNode): if next_node.node_type == NodeType.QUESTION: + q_content = next_node.content or {} + options = q_content.get("options", []) + state_updates = {"system": {"_current_options": options}} await chat_repo.update_session_state( db, session_id=session.id, - state_updates={}, + state_updates=state_updates, current_node_id=next_node.node_id, ) diff --git a/app/services/internal_api_handlers.py b/app/services/internal_api_handlers.py new file mode 100644 index 00000000..33030587 --- /dev/null +++ b/app/services/internal_api_handlers.py @@ -0,0 +1,67 @@ +"""Registry of internal API endpoint handlers for direct service calls. + +Allows the action processor to bypass HTTP for known internal endpoints, +avoiding authentication requirements and HTTP overhead for anonymous +chatbot sessions. +""" + +from typing import Any, Callable, Coroutine, Dict + +from sqlalchemy.ext.asyncio import AsyncSession +from structlog import get_logger + +logger = get_logger() + +InternalHandler = Callable[ + [AsyncSession, Dict[str, Any], Dict[str, Any]], + Coroutine[Any, Any, Dict[str, Any]], +] + +INTERNAL_HANDLERS: Dict[str, InternalHandler] = {} + + +def internal_handler(endpoint: str): + """Decorator to register an internal API handler.""" + + def decorator(func: InternalHandler) -> InternalHandler: + INTERNAL_HANDLERS[endpoint] = func + return func + + return decorator + + +@internal_handler("/v1/recommend") +async def handle_recommend( + db: AsyncSession, + body: Dict[str, Any], + query_params: Dict[str, Any], +) -> Dict[str, Any]: + """Direct service call for book recommendations.""" + from app.api.recommendations import get_recommendations_with_fallback + from app.repositories.school_repository import school_repository + from app.schemas.recommendations import HueyRecommendationFilter + + data = HueyRecommendationFilter(**body) + school = None + if data.wriveted_identifier: + school = await school_repository.aget_by_wriveted_id_or_404( + db=db, wriveted_id=data.wriveted_identifier + ) + + try: + limit = max(1, min(int(query_params.get("limit", 5)), 50)) + except (ValueError, TypeError): + limit = 5 + recommended_books, query_parameters = await get_recommendations_with_fallback( + asession=db, + account=None, + school=school, + data=data, + background_tasks=None, + limit=limit, + ) + + return { + "count": len(recommended_books), + "books": [book.model_dump(mode="json") for book in recommended_books], + } diff --git a/app/services/variable_resolver.py b/app/services/variable_resolver.py index ff05efe8..eff26510 100644 --- a/app/services/variable_resolver.py +++ b/app/services/variable_resolver.py @@ -240,6 +240,10 @@ def substitute_object(self, obj: Any, preserve_unresolved: bool = True) -> Any: """ Recursively substitute variables in complex objects. + When the entire value is a single variable reference (e.g. "{{user.age}}"), + the raw typed value is returned (int, dict, list, etc.) instead of a string. + Mixed templates like "Hello {{user.name}}" still return strings. + Args: obj: Object to process (dict, list, string, etc.) preserve_unresolved: If True, keep unresolved variables as-is @@ -248,6 +252,25 @@ def substitute_object(self, obj: Any, preserve_unresolved: bool = True) -> Any: Object with variables substituted """ if isinstance(obj, str): + # Check if the entire string is a single variable reference + stripped = obj.strip() + match = self.variable_pattern.fullmatch(stripped) + if match: + variable_str = match.group(1).strip() + try: + variable_ref = self.parse_variable_reference(variable_str) + value = self.resolve_variable(variable_ref) + if value is not None: + return value + elif preserve_unresolved: + return obj + else: + return None + except VariableValidationError: + if preserve_unresolved: + return obj + return None + # Multiple references or mixed text — fall back to string substitution return self.substitute_variables(obj, preserve_unresolved) elif isinstance(obj, dict): return { diff --git a/app/tests/integration/test_variable_resolver.py b/app/tests/integration/test_variable_resolver.py index 143e11d4..a4108cb5 100644 --- a/app/tests/integration/test_variable_resolver.py +++ b/app/tests/integration/test_variable_resolver.py @@ -169,7 +169,7 @@ def test_json_object_substitution(self, variable_resolver): assert result["user_info"]["name"] == "John Doe" assert result["user_info"]["email"] == "john@example.com" - assert result["user_info"]["age"] == "30" + assert result["user_info"]["age"] == 30 # substitute_object preserves types assert result["context"]["locale"] == "en-US" assert result["context"]["device"] == "mobile" @@ -199,8 +199,8 @@ def test_mixed_data_types_substitution(self, variable_resolver): result = variable_resolver.substitute_object(template) assert result["string_field"] == "John Doe" - assert result["numeric_field"] == "30" # Note: substitution returns strings - assert result["boolean_field"] == "True" + assert result["numeric_field"] == 30 # substitute_object preserves types + assert result["boolean_field"] is True assert result["mixed_string"] == "User John Doe is 30 years old" def test_variable_scope_isolation(self): diff --git a/app/tests/unit/test_cel_aggregation.py b/app/tests/unit/test_cel_aggregation.py index c021d781..935e285a 100644 --- a/app/tests/unit/test_cel_aggregation.py +++ b/app/tests/unit/test_cel_aggregation.py @@ -420,6 +420,7 @@ def test_context_includes_all_custom_functions(self): "merge_last", "flatten", "collect", + "top_keys", } assert set(CUSTOM_CEL_FUNCTIONS.keys()) == expected_functions @@ -646,3 +647,76 @@ def test_skill_assessment_peak_scores(self): assert result["math"] == 5 assert result["science"] == 5 assert result["art"] == 5 + + +class TestTopKeys: + """Tests for the top_keys CEL function used in hue profile ranking.""" + + def test_basic_ranking(self): + """Return keys ordered by descending value.""" + context = { + "profile": { + "hue01_dark_suspense": 1.3, + "hue02_beautiful_whimsical": 1.5, + "hue03_dark_beautiful": 1.2, + } + } + result = evaluate_cel_expression("top_keys(profile, 3)", context) + assert result == [ + "hue02_beautiful_whimsical", + "hue01_dark_suspense", + "hue03_dark_beautiful", + ] + + def test_n_limits_results(self): + """Only the top N keys are returned.""" + context = { + "scores": {"a": 10, "b": 30, "c": 20, "d": 5, "e": 25} + } + result = evaluate_cel_expression("top_keys(scores, 2)", context) + assert result == ["b", "e"] + + def test_n_larger_than_dict(self): + """Requesting more keys than exist returns all keys.""" + context = {"scores": {"x": 1, "y": 2}} + result = evaluate_cel_expression("top_keys(scores, 10)", context) + assert result == ["y", "x"] + + def test_non_numeric_values_ignored(self): + """Keys with non-numeric values are excluded from ranking.""" + context = { + "mixed": {"a": 5, "b": "high", "c": 10, "d": None, "e": 3} + } + result = evaluate_cel_expression("top_keys(mixed, 5)", context) + assert result == ["c", "a", "e"] + + def test_empty_dict(self): + """Empty dict returns empty list.""" + context = {"empty": {}} + result = evaluate_cel_expression("top_keys(empty, 3)", context) + assert result == [] + + def test_non_dict_returns_empty(self): + """Non-dict input returns empty list.""" + from app.services.cel_evaluator import CUSTOM_CEL_FUNCTIONS + + assert CUSTOM_CEL_FUNCTIONS["top_keys"]("not a dict") == [] + assert CUSTOM_CEL_FUNCTIONS["top_keys"]([1, 2, 3]) == [] + + def test_huey_hue_profile_pipeline(self): + """End-to-end: merge preference hue_maps then rank with top_keys.""" + context = { + "temp": { + "preference_answers": [ + {"hue_map": {"dark": 1.0, "whimsical": 0.2, "funny": 0.5}}, + {"hue_map": {"dark": 0.3, "whimsical": 0.8, "action": 0.9}}, + {"hue_map": {"whimsical": 0.5, "funny": 0.7}}, + ] + } + } + merged = evaluate_cel_expression( + "merge(temp.preference_answers.map(x, x.hue_map))", context + ) + # whimsical=1.5, dark=1.3, funny=1.2, action=0.9 + result = evaluate_cel_expression("top_keys(profile, 2)", {"profile": merged}) + assert result == ["whimsical", "dark"] diff --git a/docker-compose.yml b/docker-compose.yml index 696ade87..49a6180e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -66,7 +66,7 @@ services: - SQLALCHEMY_WARN_20=true - OPENAI_API_KEY=unused-test-key-for-testing - DEBUG=true - - BACKEND_CORS_ORIGINS=["http://localhost:3000","http://localhost:3005","http://localhost:3006","http://localhost:3007","http://localhost:3008","http://localhost:3009","http://localhost:3010","http://localhost:8080","http://localhost:8000","http://127.0.0.1:8000"] + - BACKEND_CORS_ORIGINS=["http://localhost:3000","http://localhost:3001","http://localhost:3005","http://localhost:3006","http://localhost:3007","http://localhost:3008","http://localhost:3009","http://localhost:3010","http://localhost:8080","http://localhost:8000","http://127.0.0.1:8000"] - DISABLE_EVENT_LISTENER=true - CSRF_SKIP_COOKIE_VALIDATION=true ports: diff --git a/docs/chatbot-system.md b/docs/chatbot-system.md index 24503baa..6b559be8 100644 --- a/docs/chatbot-system.md +++ b/docs/chatbot-system.md @@ -149,7 +149,7 @@ CREATE INDEX idx_conversation_sessions_state ON conversation_sessions USING GIN ### 2. Chat Runtime Implementation -#### Repository Layer (`app/crud/chat_repo.py`) +#### Repository Layer (`app/repositories/chat_repository.py`) **ChatRepository** class provides: - Session CRUD operations with optimistic concurrency control @@ -174,8 +174,8 @@ Key methods: - Integration with CMS content system **Core Node Processors:** -- **MessageNodeProcessor**: Displays messages with CMS content integration -- **QuestionNodeProcessor**: Handles user input and state updates +- **MessageNodeProcessor**: Displays messages with CMS content integration, supports `book_list` type for rendering recommendation results +- **QuestionNodeProcessor**: Handles user input and state updates. For choice-based inputs (`choice`, `image_choice`, `button`), stores the full option object (including custom fields like `age_number`, `hue_map`) rather than just the raw user input string. Options are persisted in `system._current_options` during `process()` and matched during `process_response()`. #### Extended Processors (`app/services/node_processors.py`) @@ -225,7 +225,7 @@ RESTful chat interaction endpoints: | Method | Path | Description | |--------|------|-------------| -| POST | `/v1/chat/start` | Create session, return token + first messages | +| POST | `/v1/chat/start` | Create session, return token + first messages + theme | | POST | `/v1/chat/sessions/{token}/interact` | Process user input, return response | | GET | `/v1/chat/sessions/{token}` | Get current session state | | POST | `/v1/chat/sessions/{token}/end` | End conversation session | @@ -239,6 +239,8 @@ Features: - HTTP 409 for concurrency conflicts - Session token-based authentication - Comprehensive logging and monitoring +- Server-side school name resolution: when `initial_state.context.school_wriveted_id` is provided, the school name is looked up from the database and injected into `context.school_name` +- Theme loading: if the flow has a `theme_id` configured, the full theme config is returned in the start response ## Node Types and Flow Structure @@ -578,11 +580,40 @@ Retrieves comprehensive user context for personalized conversations: These endpoints are designed as "internal API calls" within the Wriveted platform: -- **Authentication**: Uses existing Wriveted authentication system +- **Direct Service Calls**: Known internal endpoints (e.g., `/v1/recommend`) are called directly via the service layer, bypassing HTTP and authentication overhead entirely. This is essential for anonymous chatbot sessions that have no user credentials. +- **Handler Registry**: Internal endpoint handlers are registered in `app/services/internal_api_handlers.py` using the `@internal_handler("/v1/recommend")` decorator pattern. +- **HTTP Fallback**: For endpoints without a registered handler or for authenticated sessions, the action processor falls back to making an HTTP request with JWT authentication. - **Data Sources**: Leverages existing recommendation engine and user data - **Optimization**: Chatbot-specific response formats reduce payload size - **Fallback Handling**: Graceful degradation when services are unavailable +#### Direct Service Call Architecture + +``` +Action Node (api_call) + │ + ├─ Is auth_type "internal" and endpoint registered? + │ YES → Call handler directly with DB session (no HTTP, no auth) + │ NO → Fall through to HTTP path + │ + └─ HTTP path: Generate JWT → HTTP request → Parse response +``` + +To register a new internal handler: + +```python +# app/services/internal_api_handlers.py +@internal_handler("/v1/my-endpoint") +async def handle_my_endpoint( + db: AsyncSession, + body: Dict[str, Any], + query_params: Dict[str, Any], +) -> Dict[str, Any]: + # Call service layer directly + result = await my_service.do_work(db, **body) + return result +``` + ## Variable Scoping & Resolution ### Explicit Input/Output Model diff --git a/docs/chatflow-node-types.md b/docs/chatflow-node-types.md index 2ad461da..04910cb8 100644 --- a/docs/chatflow-node-types.md +++ b/docs/chatflow-node-types.md @@ -145,17 +145,41 @@ Collects user input and stores it in session state. - `choice` - Select from options ### Variable Storage -User responses are stored in `session.state.temp.{variable}`: +User responses are stored in session state under the scope specified by the variable name. If no scope prefix is given, `temp` is used by default: ```json { "state": { "temp": { - "user_name": "Brian" + "user_name": "Brian", + "age_selection": { + "label": "8", + "value": "8", + "age_number": 8 + } } } } ``` +### Choice Option Matching + +For choice-based inputs (`choice`, `image_choice`, `button`), the runtime stores the **full option object** rather than just the raw user input string. This preserves custom fields defined on each option (e.g., `age_number`, `hue_map`, `description`). + +The matching flow: +1. When a question node is processed, its options are stored in `system._current_options` +2. When the user answers, `_match_option()` compares the input against each option's `value`, `label`, and `text` fields +3. If a match is found, the entire option dict is stored as the variable value +4. If no match is found, the raw input string is stored as a fallback + +This enables downstream nodes to access typed fields via dot notation: +```json +{ + "type": "set_variable", + "variable": "user.age_number", + "value": "{{temp.age_selection.age_number}}" +} +``` + ### Fallback Resolution Order 1. `question` (string) → Use as inline text 2. `question.content_id` → Load from CMS @@ -357,6 +381,7 @@ The `aggregate` action type evaluates CEL (Common Expression Language) expressio | `merge_last(list_of_dicts)` | Merge dicts with last value wins | `merge_last(temp.prefs)` | | `flatten(list_of_lists)` | Flatten nested lists | `flatten(temp.tags)` | | `collect(list)` | Alias for flatten | `collect(temp.items)` | +| `top_keys(dict, n)` | Get top N keys by value from a dict | `top_keys(user.hue_profile, 5)` | **Real-World Example: Huey Preference Aggregation** ```json @@ -578,7 +603,7 @@ Variables are organized into scopes: | Scope | Access | Description | |-------|--------|-------------| | `temp` | Read/Write | Temporary session variables (cleared on session end) | -| `user` | Read-only | User profile data | +| `user` | Read/Write | User profile data (writable by action nodes via `set_variable`) | | `context` | Read-only | Custom session context from `session.state.context` (populate via `initial_state`) | | `input` | Read-only | Input data for composite nodes | | `output` | Write | Output data for composite nodes | diff --git a/docs/node-schemas-reference.md b/docs/node-schemas-reference.md index 198c4fa5..8fd20337 100644 --- a/docs/node-schemas-reference.md +++ b/docs/node-schemas-reference.md @@ -228,18 +228,22 @@ Remove a variable from session ``` #### 4. `api_call` -Make internal API calls +Make internal API calls. For known internal endpoints, the call is routed directly to the service layer without HTTP overhead. For other endpoints, a standard HTTP request is made. + ```json { "type": "api_call", "config": { - "endpoint": "/v1/process-outbox-events", + "endpoint": "/v1/recommend", "method": "POST", - "headers": { - "Content-Type": "application/json" - }, + "auth_type": "internal", "body": { - "key": "value" + "wriveted_identifier": "{{context.school_wriveted_id}}", + "age": "{{user.age_number}}" + }, + "response_mapping": { + "books": "temp.book_results", + "count": "temp.book_count" }, "response_variable": "temp.api_result" } @@ -282,6 +286,7 @@ Aggregate values from a list using CEL (Common Expression Language) expressions. | `merge_last(list_of_dicts)` | Merge dicts with last value wins | `merge_last(temp.prefs)` | | `flatten(list_of_lists)` | Flatten nested lists | `flatten(temp.tags)` | | `collect(list)` | Alias for flatten | `collect(temp.items)` | +| `top_keys(dict, n)` | Get top N keys by value from a dict | `top_keys(user.hue_profile, 5)` | **Real-World Example: Huey Preference Aggregation** ```json @@ -307,7 +312,8 @@ Aggregate values from a list using CEL (Common Expression Language) expressions. - **Error Handling**: - Continue on error vs. abort - Error message variable -- **API Call Endpoint**: Relative to `WRIVETED_INTERNAL_API` +- **API Call Endpoint**: Relative to `WRIVETED_INTERNAL_API`. Internal endpoints with registered handlers (see `app/services/internal_api_handlers.py`) are called directly via the service layer. +- **Auth Type** (optional): `"internal"` (default) for service-layer calls, or `"bearer"` for authenticated HTTP requests --- @@ -544,7 +550,7 @@ All node types support variable interpolation using `{{variable.path}}` syntax: ``` ### Variable Scopes -- `user.*`: User profile data (persistent) +- `user.*`: User profile data (persistent, writable by action nodes) - `session.*`: Session metadata (persistent) - `temp.*`: Temporary variables (cleared on session end) - `env.*`: Environment variables (read-only) diff --git a/docs/seed-fixtures.md b/docs/seed-fixtures.md index 00c5cee7..fbf2f593 100644 --- a/docs/seed-fixtures.md +++ b/docs/seed-fixtures.md @@ -32,6 +32,23 @@ Tokens are printed for each seeded user role (school admin, educator, student, e If a flow needs to be present in local UI testing, add it to the fixture JSON under `flows` and use the seeder. This keeps flow JSON in one place and avoids multiple scripts diverging over time. +## External flow files + +For complex flows, the seed config supports a `flow_file` key that loads the flow definition from a separate JSON file in `scripts/fixtures/`: + +```json +{ + "flows": [ + { + "flow_file": "huey-bookbot-flow.json", + "theme_seed_key": "huey-bookbot-theme" + } + ] +} +``` + +The external JSON file should contain the full flow config including `seed_key`, `name`, `entry_node_id`, and `flow_data` with nodes and connections. The seeder merges any additional keys (like `theme_seed_key`) from the parent entry. + ## About JSON fixtures and `.gitignore` `.gitignore` currently ignores `*.json`, so fixture JSON files must be added with: diff --git a/scripts/fixtures/admin-ui-seed.json b/scripts/fixtures/admin-ui-seed.json index e584ae01..78f79977 100644 --- a/scripts/fixtures/admin-ui-seed.json +++ b/scripts/fixtures/admin-ui-seed.json @@ -72,58 +72,186 @@ } ], "hues": [ - { - "key": "hue08_charming_courageous", - "name": "Charming & Courageous" - } + {"key": "hue01_dark_suspense", "name": "Dark Suspense"}, + {"key": "hue02_beautiful_whimsical", "name": "Beautiful & Whimsical"}, + {"key": "hue03_dark_beautiful", "name": "Dark & Beautiful"}, + {"key": "hue04_joyful_charming", "name": "Joyful & Charming"}, + {"key": "hue05_funny_comic", "name": "Funny Comic"}, + {"key": "hue06_dark_gritty", "name": "Dark & Gritty"}, + {"key": "hue07_silly_charming", "name": "Silly & Charming"}, + {"key": "hue08_charming_courageous", "name": "Charming & Courageous"}, + {"key": "hue09_charming_playful", "name": "Charming & Playful"}, + {"key": "hue10_inspiring", "name": "Inspiring"}, + {"key": "hue11_realistic_hope", "name": "Realistic Hope"}, + {"key": "hue12_funny_quirky", "name": "Funny & Quirky"}, + {"key": "hue13_informative", "name": "Informative"} ], "reading_abilities": [ - { - "key": "TREEHOUSE", - "name": "Treehouse" - } + {"key": "SPOT", "name": "Spot"}, + {"key": "CAT_HAT", "name": "Cat in the Hat"}, + {"key": "TREEHOUSE", "name": "Treehouse"}, + {"key": "CHARLIE_CHOCOLATE", "name": "Charlie Chocolate"}, + {"key": "HARRY_POTTER", "name": "Harry Potter"} ], "works": [ { "seed_key": "lost-map", "title": "The Lost Map", "isbn": "9780000000001", - "cover_url": "https://placehold.co/400x600/png?text=The+Lost+Map", + "cover_url": "https://placehold.co/400x600/e8d5b7/333?text=The+Lost+Map", "labelset": { - "min_age": 9, + "min_age": 7, "max_age": 12, + "huey_summary": "An adventurous quest through hidden lands where two friends follow a mysterious old map to find a lost treasure.", "hues": [ - {"key": "hue08_charming_courageous", "ordinal": "primary"} + {"key": "hue08_charming_courageous", "ordinal": "primary"}, + {"key": "hue10_inspiring", "ordinal": "secondary"} ], - "reading_abilities": ["TREEHOUSE"] + "reading_abilities": ["TREEHOUSE", "CHARLIE_CHOCOLATE"] } }, { "seed_key": "space-explorers", "title": "Space Explorers", "isbn": "9780000000002", - "cover_url": "https://placehold.co/400x600/png?text=Space+Explorers", + "cover_url": "https://placehold.co/400x600/1a1a2e/e0e0ff?text=Space+Explorers", "labelset": { - "min_age": 9, - "max_age": 12, + "min_age": 8, + "max_age": 13, + "huey_summary": "A thrilling journey through the solar system with a crew of young astronauts discovering alien life on Europa.", "hues": [ - {"key": "hue08_charming_courageous", "ordinal": "primary"} + {"key": "hue13_informative", "ordinal": "primary"}, + {"key": "hue08_charming_courageous", "ordinal": "secondary"} ], - "reading_abilities": ["TREEHOUSE"] + "reading_abilities": ["TREEHOUSE", "CHARLIE_CHOCOLATE", "HARRY_POTTER"] } }, { "seed_key": "mystery-museum", "title": "Mystery at the Museum", "isbn": "9780000000003", - "cover_url": "https://placehold.co/400x600/png?text=Mystery+at+the+Museum", + "cover_url": "https://placehold.co/400x600/2d2d44/ffd700?text=Mystery+Museum", + "labelset": { + "min_age": 8, + "max_age": 12, + "huey_summary": "A suspenseful mystery set in a grand museum where paintings come alive at night and a stolen artifact must be found.", + "hues": [ + {"key": "hue01_dark_suspense", "ordinal": "primary"}, + {"key": "hue03_dark_beautiful", "ordinal": "secondary"} + ], + "reading_abilities": ["TREEHOUSE", "CHARLIE_CHOCOLATE"] + } + }, + { + "seed_key": "silly-pets-club", + "title": "The Silly Pets Club", + "isbn": "9780000000004", + "cover_url": "https://placehold.co/400x600/fff5cc/333?text=Silly+Pets+Club", + "labelset": { + "min_age": 5, + "max_age": 9, + "huey_summary": "A laugh-out-loud story about a group of quirky pets who start their own secret club and get into hilarious misadventures.", + "hues": [ + {"key": "hue05_funny_comic", "ordinal": "primary"}, + {"key": "hue07_silly_charming", "ordinal": "secondary"} + ], + "reading_abilities": ["SPOT", "CAT_HAT", "TREEHOUSE"] + } + }, + { + "seed_key": "enchanted-garden", + "title": "The Enchanted Garden", + "isbn": "9780000000005", + "cover_url": "https://placehold.co/400x600/d4edda/2d5a27?text=Enchanted+Garden", + "labelset": { + "min_age": 6, + "max_age": 11, + "huey_summary": "A magical garden hidden behind an old stone wall holds secrets, talking flowers, and a gentle fairy who needs help.", + "hues": [ + {"key": "hue02_beautiful_whimsical", "ordinal": "primary"}, + {"key": "hue04_joyful_charming", "ordinal": "secondary"} + ], + "reading_abilities": ["CAT_HAT", "TREEHOUSE", "CHARLIE_CHOCOLATE"] + } + }, + { + "seed_key": "robot-friends", + "title": "Robot Friends Forever", + "isbn": "9780000000006", + "cover_url": "https://placehold.co/400x600/cce5ff/003366?text=Robot+Friends", + "labelset": { + "min_age": 7, + "max_age": 11, + "huey_summary": "In a world where kids build their own robots, a shy inventor creates a robot best friend who teaches everyone about kindness.", + "hues": [ + {"key": "hue09_charming_playful", "ordinal": "primary"}, + {"key": "hue04_joyful_charming", "ordinal": "secondary"} + ], + "reading_abilities": ["TREEHOUSE", "CHARLIE_CHOCOLATE"] + } + }, + { + "seed_key": "dark-tower-secret", + "title": "The Dark Tower's Secret", + "isbn": "9780000000007", + "cover_url": "https://placehold.co/400x600/1a1a1a/cc0000?text=Dark+Tower", + "labelset": { + "min_age": 10, + "max_age": 14, + "huey_summary": "A gripping tale of survival as three teens explore an abandoned tower and uncover a dark secret buried for centuries.", + "hues": [ + {"key": "hue06_dark_gritty", "ordinal": "primary"}, + {"key": "hue01_dark_suspense", "ordinal": "secondary"} + ], + "reading_abilities": ["CHARLIE_CHOCOLATE", "HARRY_POTTER"] + } + }, + { + "seed_key": "ocean-diary", + "title": "My Ocean Diary", + "isbn": "9780000000008", + "cover_url": "https://placehold.co/400x600/e0f0ff/006699?text=Ocean+Diary", "labelset": { - "min_age": 9, + "min_age": 8, + "max_age": 13, + "huey_summary": "A heartfelt diary of a young marine biologist who documents her first summer studying dolphins and protecting the reef.", + "hues": [ + {"key": "hue11_realistic_hope", "ordinal": "primary"}, + {"key": "hue10_inspiring", "ordinal": "secondary"} + ], + "reading_abilities": ["TREEHOUSE", "CHARLIE_CHOCOLATE", "HARRY_POTTER"] + } + }, + { + "seed_key": "joke-quest", + "title": "The Great Joke Quest", + "isbn": "9780000000009", + "cover_url": "https://placehold.co/400x600/fff0f5/cc3366?text=Joke+Quest", + "labelset": { + "min_age": 6, + "max_age": 10, + "huey_summary": "A wacky adventure where a kid must collect the funniest jokes in the world to save their town from a grumpy wizard.", + "hues": [ + {"key": "hue12_funny_quirky", "ordinal": "primary"}, + {"key": "hue05_funny_comic", "ordinal": "secondary"} + ], + "reading_abilities": ["CAT_HAT", "TREEHOUSE"] + } + }, + { + "seed_key": "starlight-dragon", + "title": "The Starlight Dragon", + "isbn": "9780000000010", + "cover_url": "https://placehold.co/400x600/2c003e/ffd700?text=Starlight+Dragon", + "labelset": { + "min_age": 7, "max_age": 12, + "huey_summary": "A beautiful tale of a girl who befriends a dragon made of starlight and together they illuminate the darkest corners of the kingdom.", "hues": [ - {"key": "hue08_charming_courageous", "ordinal": "primary"} + {"key": "hue03_dark_beautiful", "ordinal": "primary"}, + {"key": "hue02_beautiful_whimsical", "ordinal": "secondary"} ], - "reading_abilities": ["TREEHOUSE"] + "reading_abilities": ["TREEHOUSE", "CHARLIE_CHOCOLATE"] } } ], @@ -135,7 +263,14 @@ "items": [ {"isbn": "9780000000001", "copies_total": 5, "copies_available": 3}, {"isbn": "9780000000002", "copies_total": 4, "copies_available": 4}, - {"isbn": "9780000000003", "copies_total": 6, "copies_available": 5} + {"isbn": "9780000000003", "copies_total": 6, "copies_available": 5}, + {"isbn": "9780000000004", "copies_total": 3, "copies_available": 2}, + {"isbn": "9780000000005", "copies_total": 4, "copies_available": 3}, + {"isbn": "9780000000006", "copies_total": 3, "copies_available": 3}, + {"isbn": "9780000000007", "copies_total": 2, "copies_available": 1}, + {"isbn": "9780000000008", "copies_total": 3, "copies_available": 2}, + {"isbn": "9780000000009", "copies_total": 4, "copies_available": 4}, + {"isbn": "9780000000010", "copies_total": 3, "copies_available": 2} ] } ], @@ -149,7 +284,14 @@ "items": [ {"isbn": "9780000000001", "order": 1}, {"isbn": "9780000000002", "order": 2}, - {"isbn": "9780000000003", "order": 3} + {"isbn": "9780000000003", "order": 3}, + {"isbn": "9780000000004", "order": 4}, + {"isbn": "9780000000005", "order": 5}, + {"isbn": "9780000000006", "order": 6}, + {"isbn": "9780000000007", "order": 7}, + {"isbn": "9780000000008", "order": 8}, + {"isbn": "9780000000009", "order": 9}, + {"isbn": "9780000000010", "order": 10} ] } ], @@ -194,6 +336,44 @@ } } ], + "themes": [ + { + "seed_key": "huey-bookbot-theme", + "name": "Huey Bookbot", + "description": "Kid-friendly teal and pink theme matching the production Huey chatbot.", + "avatar_url": "https://storage.googleapis.com/wriveted-huey-media/chat/huey-avatar.png", + "config": { + "colors": { + "primary": "#F22555", + "secondary": "#E02678", + "background": "#D6F3F3", + "backgroundAlt": "#ffffff", + "botBubble": "#ffffff", + "userBubble": "#ADD6D3", + "userBubbleText": "#000000", + "botBubbleText": "#34495E", + "border": "#b8e6e6", + "text": "#2F324A", + "textMuted": "#6b7280", + "header": "#9CC6C8", + "headerText": "#2F324A" + }, + "typography": { + "fontFamily": "Nunito, 'Segoe UI', Roboto, sans-serif" + }, + "bubbles": { + "borderRadius": 14, + "spacing": 12, + "padding": "12px 18px" + }, + "bot": { + "name": "Huey", + "avatar": "https://storage.googleapis.com/wriveted-huey-media/chat/huey-avatar.png", + "typingIndicator": "dots" + } + } + } + ], "flows": [ { "seed_key": "admin-ui-demo-flow", @@ -292,6 +472,10 @@ {"source": "genre_branch", "target": "default_msg", "type": "default"} ] } + }, + { + "flow_file": "huey-bookbot-flow.json", + "theme_seed_key": "huey-bookbot-theme" } ] } diff --git a/scripts/seed_admin_ui_data.py b/scripts/seed_admin_ui_data.py index 9aa461f2..881d0365 100644 --- a/scripts/seed_admin_ui_data.py +++ b/scripts/seed_admin_ui_data.py @@ -2,8 +2,9 @@ import argparse import json +import sys from pathlib import Path -from typing import Dict, Iterable, Optional +from typing import Any, Dict, Iterable, Optional from uuid import UUID from sqlalchemy import and_ @@ -14,6 +15,7 @@ from app.models.booklist_work_association import BookListItem from app.models.class_group import ClassGroup from app.models.cms import ( + ChatTheme, CMSContent, ContentStatus, ContentType, @@ -44,6 +46,7 @@ from app.services.flow_utils import token_to_enum DEFAULT_CONFIG_PATH = Path("scripts/fixtures/admin-ui-seed.json") +DEFAULT_QUESTIONS_CSV = Path("AI-Questions.csv") def _info_with_seed(info: Optional[dict], seed_key: Optional[str]) -> dict: @@ -274,6 +277,7 @@ def _ensure_work_and_edition( work=work, min_age=label_cfg.get("min_age"), max_age=label_cfg.get("max_age"), + huey_summary=label_cfg.get("huey_summary", ""), hue_origin=LabelOrigin.HUMAN, reading_ability_origin=LabelOrigin.HUMAN, ) @@ -284,6 +288,8 @@ def _ensure_work_and_edition( labelset.min_age = label_cfg.get("min_age") if label_cfg.get("max_age") is not None: labelset.max_age = label_cfg.get("max_age") + if label_cfg.get("huey_summary") is not None: + labelset.huey_summary = label_cfg["huey_summary"] reading_keys = label_cfg.get("reading_abilities") or [] labelset.reading_abilities = [reading_abilities[key] for key in reading_keys] @@ -483,6 +489,65 @@ def _ensure_cms_content( return content +def _ensure_theme( + session, + config: dict, + school: Optional[School], + created_by: Optional[User], +) -> ChatTheme: + school_id = school.wriveted_identifier if school else None + q = session.query(ChatTheme).filter(ChatTheme.name == config["name"]) + if school_id is not None: + q = q.filter(ChatTheme.school_id == school_id) + else: + q = q.filter(ChatTheme.school_id.is_(None)) + theme = q.first() + + if theme is None: + theme = ChatTheme( + name=config["name"], + description=config.get("description"), + school_id=school.wriveted_identifier if school else None, + config=config.get("config", {}), + logo_url=config.get("logo_url"), + avatar_url=config.get("avatar_url"), + is_active=config.get("is_active", True), + is_default=config.get("is_default", False), + version=config.get("version", "1.0"), + created_by=created_by.id if created_by else None, + ) + session.add(theme) + session.flush() + else: + theme.description = config.get("description", theme.description) + theme.config = config.get("config", theme.config) + if config.get("logo_url") is not None: + theme.logo_url = config["logo_url"] + if config.get("avatar_url") is not None: + theme.avatar_url = config["avatar_url"] + if "is_active" in config: + theme.is_active = config["is_active"] + return theme + + +def _load_flow_config(config: dict, base_dir: Path) -> Optional[dict]: + """If config has a 'flow_file' key, merge the external JSON into config. + + Returns None when the referenced file is missing so the caller can skip it. + """ + flow_file = config.get("flow_file") + if not flow_file: + return config + path = base_dir / flow_file + if not path.exists(): + logger.warning("Flow file not found, skipping: %s", path) + return None + external = json.loads(path.read_text()) + merged = {**external, **config} + merged.pop("flow_file", None) + return merged + + def _ensure_flow( session, config: dict, @@ -511,6 +576,7 @@ def _ensure_flow( school_id=school.wriveted_identifier if school else None, created_by=created_by.id if created_by else None, trace_enabled=config.get("trace_enabled", False), + is_published=config.get("is_published", True), ) session.add(flow) session.flush() @@ -524,6 +590,8 @@ def _ensure_flow( flow.visibility = ContentVisibility(config["visibility"]) if config.get("info") or seed_key: flow.info = _info_with_seed(config.get("info", flow.info or {}), seed_key) + if "is_published" in config: + flow.is_published = config["is_published"] session.query(FlowConnection).filter(FlowConnection.flow_id == flow.id).delete() session.query(FlowNode).filter(FlowNode.flow_id == flow.id).delete() @@ -561,6 +629,66 @@ def _ensure_flow( return flow +def _seed_airtable_questions(session, csv_path: Path) -> int: + """Seed CMS preference questions from Airtable CSV export. + + Returns the number of questions inserted. + """ + if not csv_path.exists(): + return 0 + + # Import parsing logic from migration script + sys_path_orig = sys.path[:] + try: + from scripts.migrate_airtable_questions import load_questions_from_csv + except ImportError: + # Fallback: add parent dir to path + sys.path.insert(0, str(csv_path.parent.parent)) + try: + from scripts.migrate_airtable_questions import load_questions_from_csv + except ImportError: + print("Warning: Could not import migration script, skipping questions") + return 0 + finally: + sys.path = sys_path_orig + + questions = load_questions_from_csv(csv_path) + if not questions: + return 0 + + # Check for existing airtable questions + existing = ( + session.query(CMSContent) + .filter(CMSContent.info["source"].astext == "airtable") + .count() + ) + if existing > 0: + print(f" Found {existing} existing Airtable questions, replacing...") + session.query(CMSContent).filter( + CMSContent.info["source"].astext == "airtable" + ).delete(synchronize_session="fetch") + session.flush() + + count = 0 + for q in questions: + content = CMSContent( + id=q["id"], + type=q["type"], + content=q["content"], + tags=q["tags"], + is_active=q["is_active"], + status=q["status"], + visibility=q["visibility"], + school_id=q["school_id"], + info=q["info"], + ) + session.add(content) + count += 1 + + session.flush() + return count + + def _parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Seed admin UI demo data") parser.add_argument( @@ -659,9 +787,35 @@ def main() -> None: cms_school = schools.get(cms_cfg.get("school_seed_key")) or school _ensure_cms_content(session, cms_cfg, cms_school, admin_user) + # Seed Airtable preference questions if CSV exists + questions_csv = config_path.parent.parent / "AI-Questions.csv" + if not questions_csv.exists(): + questions_csv = DEFAULT_QUESTIONS_CSV + q_count = _seed_airtable_questions(session, questions_csv) + if q_count: + print(f" Seeded {q_count} preference questions from CSV") + + # Seed themes + themes_by_seed_key: dict[str, ChatTheme] = {} + for theme_cfg in config.get("themes", []): + theme = _ensure_theme(session, theme_cfg, None, admin_user) + if theme_cfg.get("seed_key"): + themes_by_seed_key[theme_cfg["seed_key"]] = theme + + fixtures_dir = config_path.parent for flow_cfg in config.get("flows", []): - flow_school = schools.get(flow_cfg.get("school_seed_key")) or school - _ensure_flow(session, flow_cfg, flow_school, admin_user) + resolved_cfg = _load_flow_config(flow_cfg, fixtures_dir) + if resolved_cfg is None: + continue + # Link theme to flow via flow_data.theme_id if theme_seed_key is specified + theme_seed_key = resolved_cfg.get("theme_seed_key") + if theme_seed_key and theme_seed_key in themes_by_seed_key: + theme = themes_by_seed_key[theme_seed_key] + flow_data = resolved_cfg.get("flow_data", {}) + flow_data["theme_id"] = str(theme.id) + resolved_cfg["flow_data"] = flow_data + flow_school = schools.get(resolved_cfg.get("school_seed_key")) or school + _ensure_flow(session, resolved_cfg, flow_school, admin_user) session.commit()