Skip to content

Commit 533d9b2

Browse files
authored
Add share_payload message type and book_feedback input type (#594)
* Add share_payload message type and book_feedback input type Share payload: MessageNodeProcessor resolves share_payload_variable from session state, returning structured data the frontend can render as a shareable card. Book feedback: QuestionNodeProcessor supports input_type "book_feedback" with a book_source variable reference. Users can rate books (like/dislike/ read) and the structured feedback is stored in session state for downstream condition routing. Updated recommendation flow to v2.0.0 with feedback collection, share payload generation, and liked-book branching. * Fix book_feedback validation and CEL has() syntax - Add book_feedback to InteractionCreate.input_type regex pattern so clients can submit feedback without getting a 422 - Fix CEL has() macro to use field-path syntax: has(obj.field) not has(obj, 'field') * Relax InteractionCreate.input_type to accept any valid identifier The hardcoded enum of allowed input types required a schema change for every new flow input type. Replace with a simple identifier pattern so flow authors can define custom input types without API changes. * Relax QuestionContentSchema input_type to accept any identifier Same change as InteractionCreate — flow authors can define custom question input types without needing API schema changes. * Add KNOWN_INPUT_TYPES constant and OpenAPI examples for input_type Centralizes the set of known input types in a frozenset so they're discoverable via IDE autocomplete and OpenAPI examples, while keeping input_type as an open string for custom types. * Fix integration test for relaxed input_type validation The test used "invalid_input_type" which now passes the relaxed identifier regex. Use "123-INVALID!" which actually violates the pattern.
1 parent c5af7c4 commit 533d9b2

File tree

6 files changed

+130
-30
lines changed

6 files changed

+130
-30
lines changed

app/schemas/cms.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,27 @@
1616
from app.schemas.pagination import PaginatedResponse
1717
from app.schemas.recommendations import HueKeys
1818

19+
# Known input types for question nodes. Flow authors can define custom types
20+
# without schema changes — unknown types fall back to text input on the frontend.
21+
KNOWN_INPUT_TYPES: frozenset[str] = frozenset(
22+
{
23+
"text",
24+
"number",
25+
"email",
26+
"phone",
27+
"url",
28+
"date",
29+
"choice",
30+
"multiple_choice",
31+
"button",
32+
"slider",
33+
"image_choice",
34+
"carousel",
35+
"book_feedback",
36+
"continue",
37+
}
38+
)
39+
1940

2041
# Content Schemas
2142
class ContentCreate(BaseModel):
@@ -465,7 +486,8 @@ class InteractionCreate(BaseModel):
465486
input: str
466487
input_type: str = Field(
467488
...,
468-
pattern="^(text|button|file|choice|number|email|phone|url|date|slider|image_choice|carousel|multiple_choice|continue)$",
489+
pattern=r"^[a-z][a-z0-9_]{0,49}$",
490+
json_schema_extra={"examples": sorted(KNOWN_INPUT_TYPES)},
469491
)
470492

471493

app/services/chat_runtime.py

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,15 @@ async def process(
136136
message["delay"] = msg_config["delay"]
137137
messages.append(message)
138138

139+
# Handle share_payload_variable: resolve variable and emit share_payload message
140+
share_payload_var = node_content.get("share_payload_variable")
141+
if share_payload_var:
142+
payload = self.runtime.substitute_object(
143+
f"{{{{{share_payload_var}}}}}", session_state
144+
)
145+
if payload and isinstance(payload, dict):
146+
messages.append({"type": "share_payload", "content": payload})
147+
139148
# Fallback: check for direct "text" or "rich_text" in node content
140149
if not messages:
141150
if node_content.get("rich_text"):
@@ -407,7 +416,7 @@ async def process(
407416
state_updates={"system": {"_current_options": options}},
408417
)
409418

410-
return {
419+
result: Dict[str, Any] = {
411420
"type": "question",
412421
"question": question_message,
413422
"content_id": content_id,
@@ -418,6 +427,18 @@ async def process(
418427
"node_id": node.node_id,
419428
}
420429

430+
# For book_feedback questions, resolve the book source and include books
431+
if input_type == "book_feedback":
432+
book_source = node_content.get("book_source", "")
433+
if book_source:
434+
books = self.runtime.substitute_object(
435+
f"{{{{{book_source}}}}}", session_state
436+
)
437+
if books and isinstance(books, list):
438+
result["books"] = books
439+
440+
return result
441+
421442
async def _fetch_random_content(
422443
self,
423444
db: AsyncSession,
@@ -648,7 +669,15 @@ async def process_response(
648669

649670
# For choice-based inputs, try to store the full option object
650671
stored_value: Any = sanitized_input
651-
if input_type in ("choice", "image_choice", "button"):
672+
if input_type == "book_feedback":
673+
# Book feedback arrives as JSON with liked/disliked/read arrays
674+
import json
675+
676+
try:
677+
stored_value = json.loads(user_input)
678+
except (json.JSONDecodeError, TypeError):
679+
stored_value = sanitized_input
680+
elif input_type in ("choice", "image_choice", "button"):
652681
options = (session.state or {}).get("system", {}).get(
653682
"_current_options"
654683
) or []
@@ -1855,12 +1884,16 @@ async def get_initial_node(
18551884
@staticmethod
18561885
def _build_input_request(source: Dict[str, Any]) -> Dict[str, Any]:
18571886
"""Build an input_request dict from a question result or raw node content."""
1858-
return {
1887+
ir: Dict[str, Any] = {
18591888
"input_type": source.get("input_type", "text"),
18601889
"variable": source.get("variable", ""),
18611890
"options": source.get("options", []),
18621891
"question": source.get("question", {}),
18631892
}
1893+
# Include books for book_feedback questions
1894+
if source.get("books"):
1895+
ir["books"] = source["books"]
1896+
return ir
18641897

18651898
async def _resolve_question_node(
18661899
self, db: AsyncSession, question_node: FlowNode, session: ConversationSession

app/services/node_input_validation.py

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from structlog import get_logger
1515

1616
from app.models.cms import NodeType
17+
from app.schemas.cms import KNOWN_INPUT_TYPES
1718

1819
logger = get_logger()
1920

@@ -102,24 +103,17 @@ def validate_messages(cls, v):
102103
class QuestionContentSchema(BaseModel):
103104
"""Validation schema for question node content.
104105
105-
Supported input types:
106-
- text: Free text input
107-
- number: Numeric input with optional min/max
108-
- email: Email address input
109-
- phone: Phone number input
110-
- url: URL input
111-
- date: Date picker
112-
- choice: Single selection from options (buttons/radio)
113-
- multiple_choice: Multiple selection from options (checkboxes)
114-
- slider: Range slider (for age, ratings, scales)
115-
- image_choice: Single selection from image-based options (for visual preference questions)
116-
- carousel: Swipeable carousel for browsing items (e.g., books)
106+
input_type must be a lowercase identifier (e.g. text, choice, book_feedback).
107+
Common built-in types: text, number, email, phone, url, date, choice,
108+
multiple_choice, slider, image_choice, carousel. Flow authors can define
109+
custom input types without schema changes.
117110
"""
118111

119112
question: Dict[str, Any] = Field(...)
120113
input_type: str = Field(
121114
...,
122-
pattern=r"^(text|choice|multiple_choice|number|email|phone|url|date|slider|image_choice|carousel)$",
115+
pattern=r"^[a-z][a-z0-9_]{0,49}$",
116+
json_schema_extra={"examples": sorted(KNOWN_INPUT_TYPES)},
123117
)
124118
options: Optional[List[Dict[str, Any]]] = None
125119
validation: Optional[Dict[str, Any]] = None

app/tests/integration/test_chat_api_error_handling.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,11 +116,11 @@ def test_interact_invalid_input_type(client, test_user_account_headers):
116116
client.cookies.set("csrf_token", csrf_token)
117117
headers = {**test_user_account_headers, "X-CSRF-Token": csrf_token}
118118

119-
# Try to interact with invalid input type
119+
# Try to interact with input_type that violates the identifier pattern
120120
response = client.post(
121121
f"v1/chat/sessions/{session_token}/interact",
122122
json={
123-
"input_type": "invalid_input_type", # Invalid type
123+
"input_type": "123-INVALID!", # Not a valid identifier
124124
"input": "Hello",
125125
},
126126
headers=headers,

app/tests/unit/test_node_input_validation.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ def test_question_node_validation_invalid_input_type(self):
115115
"""Test question node validation fails with invalid input type."""
116116
content = {
117117
"question": {"text": "Enter something"},
118-
"input_type": "invalid_type", # Not in allowed pattern
118+
"input_type": "123-INVALID!", # Not a valid identifier
119119
}
120120

121121
report = self.validator.validate_node(self.node_id, NodeType.QUESTION, content)

scripts/fixtures/huey-recommendation-flow.json

Lines changed: 61 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
{
22
"seed_key": "huey-recommendation",
33
"name": "Huey Recommendation",
4-
"description": "Queries for book recommendations with fallback, displays results.",
5-
"version": "1.0.0",
4+
"description": "Queries for book recommendations with fallback, collects per-book feedback, and displays liked books.",
5+
"version": "2.0.0",
66
"entry_node_id": "good_choices_msg",
77
"visibility": "wriveted",
88
"flow_data": {
@@ -71,14 +71,60 @@
7171
},
7272
{
7373
"id": "show_books",
74+
"type": "question",
75+
"content": {
76+
"question": {"text": "I found {{temp.book_count}} books for you! Swipe through and tell me what you think \ud83d\udcda"},
77+
"input_type": "book_feedback",
78+
"book_source": "temp.book_results",
79+
"variable": "temp.book_feedback"
80+
},
81+
"position": {"x": 900, "y": 150}
82+
},
83+
{
84+
"id": "build_share_payload",
85+
"type": "action",
86+
"content": {
87+
"actions": [
88+
{
89+
"type": "aggregate",
90+
"expression": "size(has(temp.book_feedback.liked) ? temp.book_feedback.liked : [])",
91+
"target": "temp.liked_count"
92+
}
93+
]
94+
},
95+
"position": {"x": 1100, "y": 150}
96+
},
97+
{
98+
"id": "check_liked",
99+
"type": "condition",
100+
"content": {
101+
"conditions": [
102+
{"if": "temp.liked_count > 0", "then": "$0"}
103+
],
104+
"default_path": "default"
105+
},
106+
"position": {"x": 1300, "y": 150}
107+
},
108+
{
109+
"id": "show_liked_msg",
74110
"type": "message",
75111
"content": {
76112
"messages": [
77-
{"type": "text", "text": "I found {{temp.book_count}} books for you! \ud83d\udcda"},
78-
{"type": "book_list", "source": "temp.book_results"}
113+
{"type": "text", "text": "Great taste! Here are the books you liked \ud83c\udf1f"},
114+
{"type": "book_list", "source": "temp.book_feedback.liked"}
79115
]
80116
},
81-
"position": {"x": 900, "y": 150}
117+
"position": {"x": 1500, "y": 50}
118+
},
119+
{
120+
"id": "no_liked_msg",
121+
"type": "message",
122+
"content": {
123+
"messages": [
124+
{"type": "text", "text": "No worries! Maybe next time we'll find something you love \ud83d\ude0a"}
125+
]
126+
},
127+
"position": {"x": 1500, "y": 250}
82128
},
83129
{
84130
"id": "fallback_query",
@@ -118,12 +164,12 @@
118164
},
119165
{
120166
"id": "show_fallback_books",
121-
"type": "message",
167+
"type": "question",
122168
"content": {
123-
"messages": [
124-
{"type": "text", "text": "I found {{temp.fallback_count}} books you might enjoy! \ud83d\udcda"},
125-
{"type": "book_list", "source": "temp.fallback_results"}
126-
]
169+
"question": {"text": "I found {{temp.fallback_count}} books you might enjoy! Swipe through and tell me what you think \ud83d\udcda"},
170+
"input_type": "book_feedback",
171+
"book_source": "temp.fallback_results",
172+
"variable": "temp.book_feedback"
127173
},
128174
"position": {"x": 1300, "y": 350}
129175
},
@@ -144,8 +190,13 @@
144190
{"source": "book_query", "target": "check_results", "type": "default"},
145191
{"source": "check_results", "target": "show_books", "type": "$0"},
146192
{"source": "check_results", "target": "fallback_query", "type": "default"},
193+
{"source": "show_books", "target": "build_share_payload", "type": "default"},
194+
{"source": "build_share_payload", "target": "check_liked", "type": "default"},
195+
{"source": "check_liked", "target": "show_liked_msg", "type": "$0"},
196+
{"source": "check_liked", "target": "no_liked_msg", "type": "default"},
147197
{"source": "fallback_query", "target": "check_fallback", "type": "default"},
148198
{"source": "check_fallback", "target": "show_fallback_books", "type": "$0"},
199+
{"source": "show_fallback_books", "target": "build_share_payload", "type": "default"},
149200
{"source": "check_fallback", "target": "no_books_msg", "type": "default"}
150201
]
151202
}

0 commit comments

Comments
 (0)