Skip to content

Commit a286a14

Browse files
committed
Adjust backend content-type guidance
1 parent 98a36fb commit a286a14

13 files changed

Lines changed: 272 additions & 49 deletions

backend/lambda_handler.py

Lines changed: 82 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2998,7 +2998,9 @@ def handle_chat(event):
29982998
auth_user = _get_authenticated_user(event)
29992999

30003000
question = body.get("question")
3001-
if not question:
3001+
requested_content_types = body.get("requested_content_types") or []
3002+
has_files = bool(body.get("files") or body.get("prepared_upload_ids") or body.get("attachments"))
3003+
if not question and not requested_content_types and not has_files:
30023004
return create_response(400, {"error": "Missing 'question' field"})
30033005
if isinstance(question, str) and len(question) > _MAX_QUESTION_CHARS:
30043006
return create_response(400, {"error": f"Question too long (max {_MAX_QUESTION_CHARS} characters)"})
@@ -3206,8 +3208,12 @@ def handle_continue(event):
32063208
question = body.get("question")
32073209
if not question and component_edit.get("instruction"):
32083210
question = component_edit.get("instruction")
3209-
if not question:
3211+
3212+
requested_content_types = body.get("requested_content_types") or []
3213+
has_files = bool(body.get("files") or body.get("prepared_upload_ids") or body.get("attachments"))
3214+
if not question and not requested_content_types and not has_files:
32103215
return create_response(400, {"error": "Missing 'question' field"})
3216+
32113217
if isinstance(question, str) and len(question) > _MAX_QUESTION_CHARS:
32123218
return create_response(400, {"error": f"Question too long (max {_MAX_QUESTION_CHARS} characters)"})
32133219
attachments = _normalize_text_attachments(body.get("attachments"))
@@ -6159,19 +6165,73 @@ def process_async_job(event):
61596165
# user-visible/persisted user_message content).
61606166
content_type_system_message = None
61616167
_CONTENT_TYPE_FORMAT_HINTS = {
6162-
"quiz": "quiz -> use `create_main_block` with `quiz_questions` array (multiple_choice or true_false)",
6163-
"flashcards": "flashcards -> use `create_main_block` with `flashcards` array ({front, back, variant})",
6164-
"images": "images -> call `search_brave_images` or `search_wiki_images` inline",
6165-
"animation": "animation -> call `generate_math_animation` for math/physics/CS concepts",
6166-
"youtube": "youtube embed -> use `create_main_block` with `youtube_urls` array",
6167-
"tables": "tables -> include markdown tables (| col1 | col2 |) in your content",
6168-
"graphs": "graphs/charts -> include a markdown table with quantitative data AND append a chart tag on the next line (<<bar>>, <<pie>>, <<line>>, <<scatter>>, <<area>>, etc.)",
6169-
"code": "code -> include fenced code blocks with language tags (```python, ```javascript, etc.)",
6170-
"equations": "equations -> include LaTeX math: $inline$ or $$display$$ blocks",
6171-
"quote": "quote -> include blockquotes using > prefix",
6172-
"definition": "definition -> use blockquote format: > **Definition: Term** — explanation",
6173-
"translation": "translation -> use `create_main_block` with `translations` array ({original, translated, language})",
6168+
"quiz": "quiz -> when you have enough context to answer, use `create_main_block` with `quiz_questions` array (multiple_choice or true_false)",
6169+
"flashcards": "flashcards -> when you have enough context to answer, use `create_main_block` with `flashcards` array ({front, back, variant})",
6170+
"images": "images -> when the topic is specific enough, call `search_brave_images` or `search_wiki_images` inline",
6171+
"animation": "animation -> when the concept is specific enough, call `generate_math_animation` for math/physics/CS concepts",
6172+
"youtube": "youtube embed -> when you have enough context to answer, use `create_main_block` with `youtube_urls` array",
6173+
"tables": "tables -> when you have enough context to answer, include markdown tables (| col1 | col2 |) in your content",
6174+
"graphs": "graphs/charts -> when you have enough context to answer, include a markdown table with quantitative data AND append a chart tag on the next line (<<bar>>, <<pie>>, <<line>>, <<scatter>>, <<area>>, etc.)",
6175+
"code": "code -> when you have enough context to answer, include fenced code blocks with language tags (```python, ```javascript, etc.)",
6176+
"equations": "equations -> when you have enough context to answer, include LaTeX math: $inline$ or $$display$$ blocks",
6177+
"quote": "quote -> when you have enough context to answer, include blockquotes using > prefix",
6178+
"definition": "definition -> when you have enough context to answer, use blockquote format: > **Definition: Term** — explanation",
6179+
"translation": "translation -> when you have enough context to answer, use `create_main_block` with `translations` array ({original, translated, language})",
6180+
}
6181+
_CONTENT_TYPE_LABELS = {
6182+
"quiz": "Quiz",
6183+
"flashcards": "Flashcards",
6184+
"images": "Images",
6185+
"animation": "Animation Engine",
6186+
"youtube": "YouTube Embed",
6187+
"tables": "Tables",
6188+
"graphs": "Graphs",
6189+
"code": "Code",
6190+
"equations": "Equations",
6191+
"quote": "Quote",
6192+
"definition": "Definition",
6193+
"translation": "Translation",
6194+
"social-embed": "Social Post Embed (X)",
6195+
"diagram": "Diagram",
6196+
"simulation": "2D/3D Simulation",
61746197
}
6198+
6199+
# Auto-prompt generation (moved from frontend)
6200+
if not (user_message or "").strip() and requested_content_types and isinstance(requested_content_types, list):
6201+
labels = []
6202+
for t in requested_content_types:
6203+
t_str = str(t).strip()
6204+
if not t_str:
6205+
continue
6206+
6207+
# Handle parent:variant
6208+
if ":" in t_str:
6209+
parentId = t_str.split(":")[0]
6210+
# Try to use the parent label as a reasonable fallback for variant
6211+
label = _CONTENT_TYPE_LABELS.get(parentId, parentId.capitalize())
6212+
else:
6213+
label = _CONTENT_TYPE_LABELS.get(t_str, t_str.capitalize())
6214+
6215+
if label:
6216+
labels.append(label.lower())
6217+
6218+
if labels:
6219+
# Deduplicate labels (in case multiple variants of same parent are selected)
6220+
unique_labels = []
6221+
for l in labels:
6222+
if l not in unique_labels:
6223+
unique_labels.append(l)
6224+
6225+
if len(unique_labels) == 1:
6226+
joined = unique_labels[0]
6227+
else:
6228+
joined = ", ".join(unique_labels[:-1]) + " and " + unique_labels[-1]
6229+
6230+
if topic_name:
6231+
user_message = f"Generate {joined} about \"{topic_name}\"."
6232+
else:
6233+
user_message = f"Generate {joined} based on what we've covered so far."
6234+
61756235
if requested_content_types and isinstance(requested_content_types, list):
61766236
hints = []
61776237
plain_names = []
@@ -6186,10 +6246,14 @@ def process_async_job(event):
61866246
types_str = ", ".join(plain_names)
61876247
format_block = "\n".join(hints) if hints else ""
61886248
content_type_system_message = (
6189-
"The user has specifically requested the following content types: "
6190-
f"{types_str}.\n"
6191-
"You MUST include these in your answer.\n"
6192-
"Format guidance:\n"
6249+
"The user has specifically requested the following content types as preferred output modes "
6250+
f"for the eventual answer: {types_str}.\n"
6251+
"Treat these as preferences, not as reasons to skip clarification.\n"
6252+
"If the topic is underspecified or context is insufficient, ask one concise clarification question first "
6253+
"and DO NOT force tool calls, images, animation, or structured content yet.\n"
6254+
"Once you have enough context to answer well, satisfy the requested content types that are appropriate "
6255+
"and well-supported for the topic.\n"
6256+
"Format guidance for the eventual answer:\n"
61936257
f"{format_block}"
61946258
)
61956259

backend/tests/test_block_generation.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -705,6 +705,49 @@ def fake_run_agent_with_session(**kwargs):
705705
assert "cache coherency" in str(saved_block.metadata.get("parent_context_summary") or "")
706706

707707

708+
def test_process_async_job_content_type_guidance_defers_until_context_is_sufficient():
709+
from lambda_handler import process_async_job
710+
from models import Block, Session
711+
712+
session = Session()
713+
714+
event = {
715+
"job_id": "job-content-types-1",
716+
"session_id": session.id,
717+
"user_message": "Generate images and animation",
718+
"model": "test-model",
719+
"requested_content_types": ["images", "animation"],
720+
}
721+
722+
def fake_run_agent_with_session(**kwargs):
723+
return Block(content="Please clarify which process you want visualized.")
724+
725+
with patch("lambda_handler.get_jobs_table") as mock_get_table, patch(
726+
"lambda_handler.session_store"
727+
) as mock_store, patch("lambda_handler.run_agent_with_session") as mock_run:
728+
mock_table = MagicMock()
729+
mock_table.get_item.return_value = {
730+
"Item": {
731+
"id": "job-content-types-1",
732+
"status": "PENDING",
733+
"created_at": "2026-02-09T12:00:00Z",
734+
}
735+
}
736+
mock_get_table.return_value = mock_table
737+
mock_store.get.return_value = session
738+
mock_store.save.return_value = None
739+
mock_store.items_table = None
740+
mock_store.table = None
741+
mock_run.side_effect = fake_run_agent_with_session
742+
743+
process_async_job(event)
744+
745+
extra_system_message = mock_run.call_args.kwargs.get("extra_system_message", "")
746+
assert "Treat these as preferences, not as reasons to skip clarification." in extra_system_message
747+
assert "DO NOT force tool calls, images, animation, or structured content yet." in extra_system_message
748+
assert "Once you have enough context to answer well" in extra_system_message
749+
750+
708751
def test_process_async_job_component_edit_appends_edit_prompt_and_metadata():
709752
from lambda_handler import process_async_job
710753
from models import Block, Session

backend/tests/test_lambda_handler.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1334,6 +1334,33 @@ def test_chat_web_search_flag_forwarded_to_async_job():
13341334
assert mock_create_async_job.call_args.kwargs["web_search"] is True
13351335

13361336

1337+
def test_chat_accepts_content_types_without_question():
1338+
event = {
1339+
"httpMethod": "POST",
1340+
"path": "/chat",
1341+
"body": json.dumps({
1342+
"question": "",
1343+
"requested_content_types": ["images", "animation"],
1344+
}),
1345+
}
1346+
1347+
with patch("lambda_handler.session_store") as mock_session_store, patch(
1348+
"lambda_handler.create_async_job"
1349+
) as mock_create_async_job:
1350+
mock_session = MagicMock()
1351+
mock_session.is_hidden = False
1352+
mock_session.id = "session-1"
1353+
mock_session_store.create.return_value = mock_session
1354+
mock_create_async_job.return_value = "job-1"
1355+
1356+
response = handler(event, None)
1357+
1358+
assert response["statusCode"] == 202
1359+
kwargs = mock_create_async_job.call_args.kwargs
1360+
assert kwargs["user_message"] == ""
1361+
assert kwargs["requested_content_types"] == ["images", "animation"]
1362+
1363+
13371364
def test_is_allowed_model_accepts_gemini_models():
13381365
assert lambda_handler._is_allowed_model("gemini-3.1-pro-preview") is True
13391366
assert lambda_handler._is_allowed_model("gemini-3-flash-preview") is True
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import pytest
2+
import os
3+
import tempfile
4+
from math_anim import render_animation, is_safe_manim_code, generate_manim_code
5+
6+
def mock_generate_manim(*args, **kwargs):
7+
return \"\"\"
8+
from manim import *
9+
class SafeScene(Scene):
10+
def construct(self):
11+
text = Text("Hello AST!")
12+
self.play(Write(text))
13+
self.wait(1)
14+
\"\"\"
15+
16+
def mock_generate_manim_unsafe(*args, **kwargs):
17+
return \"\"\"
18+
import os
19+
from manim import *
20+
class UnsafeScene(Scene):
21+
def construct(self):
22+
os.system("echo 'pwned'")
23+
\"\"\"
24+
25+
def test_safe_integration(monkeypatch):
26+
monkeypatch.setattr("math_anim.generate_manim_code", mock_generate_manim)
27+
code = generate_manim_code("test topic")
28+
is_safe, msg = is_safe_manim_code(code)
29+
assert is_safe
30+
31+
def test_unsafe_integration(monkeypatch):
32+
monkeypatch.setattr("math_anim.generate_manim_code", mock_generate_manim_unsafe)
33+
code = generate_manim_code("test topic")
34+
is_safe, msg = is_safe_manim_code(code)
35+
assert not is_safe
36+
assert "not allowed" in msg
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import pytest
2+
from math_anim import is_safe_manim_code
3+
4+
def test_safe_manim_code():
5+
code = \"\"\"
6+
from manim import *
7+
8+
class TestScene(Scene):
9+
def construct(self):
10+
circle = Circle()
11+
self.play(Create(circle))
12+
self.wait(1)
13+
\"\"\"
14+
is_safe, msg = is_safe_manim_code(code)
15+
assert is_safe, f"Valid code should be safe. Failed with: {msg}"
16+
17+
def test_unsafe_import():
18+
code = \"\"\"
19+
import os
20+
import subprocess
21+
from manim import *
22+
23+
class TestScene(Scene):
24+
def construct(self):
25+
os.system('ls')
26+
\"\"\"
27+
is_safe, msg = is_safe_manim_code(code)
28+
assert not is_safe
29+
assert "not allowed" in msg
30+
31+
def test_unsafe_builtin():
32+
code = \"\"\"
33+
from manim import *
34+
open('/etc/passwd', 'r').read()
35+
36+
class TestScene(Scene):
37+
def construct(self):
38+
circle = Circle()
39+
\"\"\"
40+
is_safe, msg = is_safe_manim_code(code)
41+
assert not is_safe
42+
assert "dangerous builtin" in msg
43+
assert "open" in msg
44+
45+
def test_unsafe_eval():
46+
code = \"\"\"
47+
from manim import *
48+
eval('print("hello")')
49+
\"\"\"
50+
is_safe, msg = is_safe_manim_code(code)
51+
assert not is_safe
52+
assert "dangerous builtin" in msg
53+
assert "eval" in msg
54+
55+
def test_unsafe_dunder_attribute():
56+
code = \"\"\"
57+
from manim import *
58+
59+
class TestScene(Scene):
60+
def construct(self):
61+
cls = "".__class__.__mro__[1].__subclasses__()
62+
\"\"\"
63+
is_safe, msg = is_safe_manim_code(code)
64+
assert not is_safe
65+
assert "dangerous attribute" in msg
66+
assert "__class__" in msg or "__mro__" in msg or "__subclasses__" in msg
443 KB
Binary file not shown.
633 KB
Binary file not shown.
618 KB
Binary file not shown.

frontend/src/components/app/ChatInput.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { usePreparedAttachments } from "@/hooks/usePreparedAttachments"
1717
import { useAuth } from "@/context/AuthContext"
1818
import ContentTypePicker, { InlineContentChips, type ContentTypePickerKeyHandler } from "@/components/ui/ContentTypePicker"
1919
import { readStoredModel, writeStoredModel } from "@/lib/models"
20-
import { readStoredContentTypes, writeStoredContentTypes, buildContentTypeAutoPrompt } from "@/lib/contentTypes"
20+
import { readStoredContentTypes, writeStoredContentTypes } from "@/lib/contentTypes"
2121

2222
/* ═══════════════════════════════════════════════════════════
2323
* ChatInput — memoized input component with typewriter,
@@ -150,9 +150,8 @@ const ChatInput = memo(function ChatInput() {
150150
if (!content && attachments.length === 0 && !hasContentTypes) return
151151
if (!canSend) return
152152

153-
// Auto-generate prompt when user selected content types but typed nothing
154-
const messageText = content
155-
|| (hasContentTypes ? buildContentTypeAutoPrompt(inputFeatures.requestedContentTypes!) : "See attached files.")
153+
// Auto-generate prompt when user selected content types but typed nothing (now handled by backend)
154+
const messageText = content || ""
156155
const msgAttachments = attachments.length > 0 ? attachedToMessageFormat(attachments) : undefined
157156
const previousInputValue = inputValue
158157
const previousAttachments = attachments
@@ -164,7 +163,8 @@ const ChatInput = memo(function ChatInput() {
164163
if (activeView.startsWith("chat-")) {
165164
conversationId = activeView.replace("chat-", "")
166165
} else {
167-
const created = addConversation(getConversationTitle(messageText))
166+
const titleText = messageText || (hasContentTypes ? "New Content Request" : "New Session")
167+
const created = addConversation(getConversationTitle(titleText))
168168
conversationId = created.id
169169
createdConversationId = created.id
170170
}

0 commit comments

Comments
 (0)