Skip to content

Commit 3eb6eef

Browse files
committed
Add jokes, spelling sub-flows and fix composite question blocking
- Add Huey Jokes sub-flow with randomized precursor/bridge messages using new random_choice() CEL function - Add Huey Spelling sub-flow with 3-round pick-the-correct-spelling game, difficulty matched to reading ability - Add CMS content loaders for jokes (from CSV) and spelling questions - Fix composite sub-flows not blocking on questions when entered via condition auto-chain (questions were dumped as messages instead of pausing for user input) - Add days_since() CEL function for date comparisons - Update Huey Bookbot flow to v3.0.0 with jokes, spelling composites and stale collection warning - Make /school/{id}/bot endpoint public (no auth required) - Fix KeyError on missing experiments key in school info
1 parent d0889bd commit 3eb6eef

File tree

11 files changed

+885
-14
lines changed

11 files changed

+885
-14
lines changed

app/api/schools.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -215,9 +215,9 @@ async def school_exists():
215215
return True
216216

217217

218-
@router.get("/school/{wriveted_identifier}/bot")
218+
@public_router.get("/school/{wriveted_identifier}/bot")
219219
async def get_school_bookbot_type(
220-
school: School = Permission("read", get_school_from_wriveted_id),
220+
school: School = Depends(get_school_from_wriveted_id),
221221
):
222222
"""
223223
Returns the Huey-relevant information for a school, i.e. whether they've opted for Huey's Collection,

app/services/cel_evaluator.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""CEL (Common Expression Language) evaluator service for safe expression evaluation."""
22

3+
import random
4+
from datetime import datetime, timezone
35
from typing import Any, Callable, Dict, List, Union
46

57
from cel import Context, evaluate
@@ -108,6 +110,29 @@ def _cel_top_keys(d: Dict[str, Any], n: int = 5) -> List[str]:
108110
return [k for k, _ in numeric_items[:n]]
109111

110112

113+
def _cel_days_since(iso_date_str: str) -> int:
114+
"""Return number of days between an ISO datetime string and now.
115+
116+
Returns -1 if the input is empty/None or unparseable.
117+
"""
118+
if not iso_date_str or not isinstance(iso_date_str, str):
119+
return -1
120+
try:
121+
dt = datetime.fromisoformat(iso_date_str.replace("Z", "+00:00"))
122+
if dt.tzinfo is None:
123+
dt = dt.replace(tzinfo=timezone.utc)
124+
return (datetime.now(timezone.utc) - dt).days
125+
except (ValueError, TypeError):
126+
return -1
127+
128+
129+
def _cel_random_choice(items: List[Any]) -> Any:
130+
"""Pick a random item from a list."""
131+
if not items:
132+
return None
133+
return random.choice(items)
134+
135+
111136
# Registry of custom functions available in CEL expressions
112137
CUSTOM_CEL_FUNCTIONS: Dict[str, Callable] = {
113138
"sum": _cel_sum,
@@ -122,6 +147,8 @@ def _cel_top_keys(d: Dict[str, Any], n: int = 5) -> List[str]:
122147
"flatten": _cel_flatten,
123148
"collect": _cel_collect,
124149
"top_keys": _cel_top_keys,
150+
"days_since": _cel_days_since,
151+
"random_choice": _cel_random_choice,
125152
}
126153

127154

app/services/chat_runtime.py

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1651,6 +1651,26 @@ async def _try_return_to_parent_flow(
16511651
session = await self._refresh_session(db, session)
16521652

16531653
if return_result:
1654+
# Check if the return node (or its auto-chained successor)
1655+
# produced a question — e.g. condition → composite → question.
1656+
if return_result.get("type") == "question":
1657+
node_id = return_result.get("node_id")
1658+
flow_id = _to_uuid(return_result.get("sub_flow_id"))
1659+
if node_id:
1660+
result["current_node_id"] = node_id
1661+
options = return_result.get("options", [])
1662+
session = await chat_repo.update_session_state(
1663+
db,
1664+
session_id=session.id,
1665+
state_updates={"system": {"_current_options": options}},
1666+
current_node_id=node_id,
1667+
current_flow_id=flow_id,
1668+
expected_revision=session.revision,
1669+
)
1670+
result["input_request"] = self._build_input_request(return_result)
1671+
result["awaiting_input"] = True
1672+
return result
1673+
16541674
# Extract actual messages from the result
16551675
if return_result.get("type") == "messages":
16561676
result["messages"].extend(return_result.get("messages", []))
@@ -1709,8 +1729,40 @@ async def _try_return_to_parent_flow(
17091729
):
17101730
node_result = await self.process_node(db, next_node, session)
17111731
session = await self._refresh_session(db, session)
1732+
self.logger.debug(
1733+
"Return-chain processed node",
1734+
node_id=next_node.node_id,
1735+
node_type=str(next_node.node_type),
1736+
result_type=node_result.get("type")
1737+
if node_result
1738+
else None,
1739+
result_keys=list(node_result.keys()) if node_result else [],
1740+
)
17121741
if node_result:
1713-
if node_result.get("type") == "messages":
1742+
if node_result.get("type") == "question":
1743+
# Composite (or other node) returned a question
1744+
# — treat it like the dict-question branch below
1745+
node_id = node_result.get("node_id")
1746+
flow_id = _to_uuid(node_result.get("sub_flow_id"))
1747+
if node_id:
1748+
result["current_node_id"] = node_id
1749+
options = node_result.get("options", [])
1750+
session = await chat_repo.update_session_state(
1751+
db,
1752+
session_id=session.id,
1753+
state_updates={
1754+
"system": {"_current_options": options}
1755+
},
1756+
current_node_id=node_id,
1757+
current_flow_id=flow_id,
1758+
expected_revision=session.revision,
1759+
)
1760+
result["input_request"] = self._build_input_request(
1761+
node_result
1762+
)
1763+
result["awaiting_input"] = True
1764+
break
1765+
elif node_result.get("type") == "messages":
17141766
result["messages"].extend(
17151767
node_result.get("messages", [])
17161768
)

app/tests/unit/test_cel_aggregation.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,8 @@ def test_context_includes_all_custom_functions(self):
421421
"flatten",
422422
"collect",
423423
"top_keys",
424+
"days_since",
425+
"random_choice",
424426
}
425427
assert set(CUSTOM_CEL_FUNCTIONS.keys()) == expected_functions
426428

scripts/deploy_huey_flow.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@
4242
{"file": "huey-profile-flow.json", "seed_key": "huey-profile", "name": "Huey Profile"},
4343
{"file": "huey-preferences-flow.json", "seed_key": "huey-preferences", "name": "Huey Preferences"},
4444
{"file": "huey-recommendation-flow.json", "seed_key": "huey-recommendation", "name": "Huey Recommendation"},
45+
{"file": "huey-jokes-flow.json", "seed_key": "huey-jokes", "name": "Huey Jokes"},
46+
{"file": "huey-spelling-flow.json", "seed_key": "huey-spelling", "name": "Huey Spelling"},
4547
]
4648

4749

scripts/fixtures/admin-ui-seed.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -544,6 +544,8 @@
544544
{"flow_file": "huey-profile-flow.json"},
545545
{"flow_file": "huey-preferences-flow.json"},
546546
{"flow_file": "huey-recommendation-flow.json"},
547+
{"flow_file": "huey-jokes-flow.json"},
548+
{"flow_file": "huey-spelling-flow.json"},
547549
{
548550
"flow_file": "huey-bookbot-flow.json",
549551
"theme_seed_key": "huey-bookbot-theme"

scripts/fixtures/huey-bookbot-flow.json

Lines changed: 67 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
{
22
"seed_key": "huey-bookbot",
33
"name": "Huey Bookbot",
4-
"description": "Kid-friendly book recommendation chatbot. Asks age, reading ability, and preference questions, then recommends books.",
5-
"version": "2.0.0",
4+
"description": "Kid-friendly book recommendation chatbot. Asks age, reading ability, and preference questions, then recommends books. Optionally offers jokes and a spelling game.",
5+
"version": "3.0.0",
66
"entry_node_id": "welcome",
77
"visibility": "wriveted",
88
"trace_enabled": true,
@@ -56,31 +56,80 @@
5656
],
5757
"variable": "temp.school_confirm"
5858
},
59-
"position": {"x": 700, "y": 150}
59+
"position": {"x": 700, "y": 100}
60+
},
61+
{
62+
"id": "check_stale_collection",
63+
"type": "condition",
64+
"content": {
65+
"conditions": [
66+
{"if": "has(context.collection_updated_at) && days_since(context.collection_updated_at) > 1095", "then": "$0"}
67+
],
68+
"default_path": "default"
69+
},
70+
"position": {"x": 900, "y": 100}
71+
},
72+
{
73+
"id": "stale_collection_msg",
74+
"type": "message",
75+
"content": {
76+
"messages": [
77+
{"type": "text", "text": "Just a heads up \u2014 it looks like your school's book list hasn't been updated in a while, so my recommendations might not match what's on the shelves right now."},
78+
{"type": "text", "text": "Your librarian can upload a new CSV at hueybooks.com to help me find the latest books! \ud83d\udcda"}
79+
]
80+
},
81+
"position": {"x": 1100, "y": 0}
6082
},
6183
{
6284
"id": "profile_composite",
6385
"type": "composite",
6486
"content": {
6587
"composite_flow_seed_key": "huey-profile"
6688
},
67-
"position": {"x": 900, "y": 300}
89+
"position": {"x": 1100, "y": 300}
6890
},
6991
{
7092
"id": "preferences_composite",
7193
"type": "composite",
7294
"content": {
7395
"composite_flow_seed_key": "huey-preferences"
7496
},
75-
"position": {"x": 1100, "y": 300}
97+
"position": {"x": 1300, "y": 300}
7698
},
7799
{
78100
"id": "recommendation_composite",
79101
"type": "composite",
80102
"content": {
81103
"composite_flow_seed_key": "huey-recommendation"
82104
},
83-
"position": {"x": 1300, "y": 300}
105+
"position": {"x": 1500, "y": 300}
106+
},
107+
{
108+
"id": "check_jokes_enabled",
109+
"type": "condition",
110+
"content": {
111+
"conditions": [
112+
{"if": "!(has(context.experiments) && has(context.experiments.no_jokes) && context.experiments.no_jokes == true)", "then": "$0"}
113+
],
114+
"default_path": "default"
115+
},
116+
"position": {"x": 1700, "y": 300}
117+
},
118+
{
119+
"id": "jokes_composite",
120+
"type": "composite",
121+
"content": {
122+
"composite_flow_seed_key": "huey-jokes"
123+
},
124+
"position": {"x": 1900, "y": 200}
125+
},
126+
{
127+
"id": "spelling_composite",
128+
"type": "composite",
129+
"content": {
130+
"composite_flow_seed_key": "huey-spelling"
131+
},
132+
"position": {"x": 2100, "y": 300}
84133
},
85134
{
86135
"id": "end_msg",
@@ -90,7 +139,7 @@
90139
{"type": "text", "text": "Happy reading! Remember, the best book is the one YOU enjoy. \ud83d\ude0a"}
91140
]
92141
},
93-
"position": {"x": 1500, "y": 300}
142+
"position": {"x": 2300, "y": 300}
94143
},
95144
{
96145
"id": "restart_choice",
@@ -104,7 +153,7 @@
104153
],
105154
"variable": "temp.restart_choice"
106155
},
107-
"position": {"x": 1700, "y": 300}
156+
"position": {"x": 2500, "y": 300}
108157
},
109158
{
110159
"id": "goodbye",
@@ -114,18 +163,25 @@
114163
{"type": "text", "text": "Thanks for chatting with me! See you next time! \ud83d\udc4b"}
115164
]
116165
},
117-
"position": {"x": 1900, "y": 450}
166+
"position": {"x": 2700, "y": 450}
118167
}
119168
],
120169
"connections": [
121170
{"source": "welcome", "target": "greeting_response", "type": "default"},
122171
{"source": "greeting_response", "target": "check_school", "type": "default"},
123172
{"source": "check_school", "target": "school_confirm", "type": "$0"},
124173
{"source": "check_school", "target": "profile_composite", "type": "default"},
125-
{"source": "school_confirm", "target": "profile_composite", "type": "default"},
174+
{"source": "school_confirm", "target": "check_stale_collection", "type": "default"},
175+
{"source": "check_stale_collection", "target": "stale_collection_msg", "type": "$0"},
176+
{"source": "check_stale_collection", "target": "profile_composite", "type": "default"},
177+
{"source": "stale_collection_msg", "target": "profile_composite", "type": "default"},
126178
{"source": "profile_composite", "target": "preferences_composite", "type": "default"},
127179
{"source": "preferences_composite", "target": "recommendation_composite", "type": "default"},
128-
{"source": "recommendation_composite", "target": "end_msg", "type": "default"},
180+
{"source": "recommendation_composite", "target": "check_jokes_enabled", "type": "default"},
181+
{"source": "check_jokes_enabled", "target": "jokes_composite", "type": "$0"},
182+
{"source": "check_jokes_enabled", "target": "spelling_composite", "type": "default"},
183+
{"source": "jokes_composite", "target": "spelling_composite", "type": "default"},
184+
{"source": "spelling_composite", "target": "end_msg", "type": "default"},
129185
{"source": "end_msg", "target": "restart_choice", "type": "default"},
130186
{"source": "restart_choice", "target": "welcome", "type": "$0", "conditions": {"if": {"var": "temp.restart_choice", "eq": "restart"}}},
131187
{"source": "restart_choice", "target": "goodbye", "type": "default"}

0 commit comments

Comments
 (0)