Skip to content

Commit c14cb30

Browse files
committed
Merge remote-tracking branch 'origin/main' into prod
2 parents b55e943 + 1b3a47f commit c14cb30

3 files changed

Lines changed: 136 additions & 47 deletions

File tree

backend/agent.py

Lines changed: 63 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -973,7 +973,7 @@ def assess_context_or_answer_simple(
973973
include_reasoning=False,
974974
)
975975
_record_generation_usage(usage_tracker, followup)
976-
return False, followup.get("content") or ""
976+
return False, _sanitize_clarification_response(followup.get("content") or "", session=session)
977977

978978
# Check if context is sufficient.
979979
content_upper = content.upper().strip()
@@ -985,7 +985,7 @@ def assess_context_or_answer_simple(
985985
if depth == "Short":
986986
return True, ""
987987

988-
return False, content
988+
return False, _sanitize_clarification_response(content, session=session)
989989

990990
def execute_tool(name, arguments, session=None):
991991
"""Execute a tool by name with the given arguments.
@@ -1209,17 +1209,16 @@ def _build_content_nodes(created_main_block, tool_results=None):
12091209
return []
12101210

12111211
content = str(created_main_block.get("content") or "").strip()
1212-
if not content:
1213-
return []
1214-
12151212
nodes = []
12161213

12171214
# --- 1. Split by ## headings into markdown sections -----------------------
12181215
import re as _re
1219-
sections = _re.split(r"\n(?=##\s|###\s)", content)
1220-
sections = [s.strip() for s in sections if s.strip()]
1221-
if not sections:
1222-
sections = [content]
1216+
sections = []
1217+
if content:
1218+
sections = _re.split(r"\n(?=##\s|###\s)", content)
1219+
sections = [s.strip() for s in sections if s.strip()]
1220+
if not sections:
1221+
sections = [content]
12231222

12241223
# --- 2. Collect media from tool_results (deduplicated by URL) ---------------
12251224
seen_urls = set()
@@ -1275,44 +1274,49 @@ def _build_content_nodes(created_main_block, tool_results=None):
12751274
re.IGNORECASE,
12761275
)
12771276

1278-
# Split sections into those eligible for images and those that aren't.
1279-
eligible_indices = []
1280-
for idx, section in enumerate(sections):
1281-
first_line = section.split("\n", 1)[0].strip()
1282-
if _IMAGE_ONLY_HEADING.match(first_line):
1283-
continue # drop image-only sections entirely
1284-
if _CONCLUSION_HEADING.match(first_line):
1285-
sections[idx] = section # keep but don't attach images
1286-
else:
1287-
eligible_indices.append(idx)
1288-
1289-
# Remove image-only sections from the list.
1290-
sections = [s for s in sections if not _IMAGE_ONLY_HEADING.match(s.split("\n", 1)[0].strip())]
1291-
# Recompute eligible indices after removal.
1292-
eligible_indices = []
1293-
for idx, section in enumerate(sections):
1294-
first_line = section.split("\n", 1)[0].strip()
1295-
if not _CONCLUSION_HEADING.match(first_line):
1296-
eligible_indices.append(idx)
1297-
1298-
max_media = max(len(eligible_indices) * 2, 1) # up to 2 images per section
1299-
media_items = media_items[:max_media]
1300-
1301-
# --- 3. Interleave markdown sections + media (up to 2 per eligible section)
1302-
# Distribute media across eligible sections, skipping conclusion/preamble.
1303-
media_assignment: dict[int, list] = {} # section_index -> [media_items]
1304-
for i, media in enumerate(media_items):
1305-
# Round-robin: fill each eligible section with 1, then loop back for 2nd
1306-
slot = i % len(eligible_indices) if eligible_indices else 0
1307-
sec_idx = eligible_indices[slot] if slot < len(eligible_indices) else 0
1308-
media_assignment.setdefault(sec_idx, [])
1309-
if len(media_assignment[sec_idx]) < 2:
1310-
media_assignment[sec_idx].append(media)
1311-
1312-
for idx, section in enumerate(sections):
1313-
nodes.append({"type": "markdown", "content": section})
1314-
for media in media_assignment.get(idx, []):
1315-
nodes.append(media)
1277+
if sections:
1278+
# Split sections into those eligible for images and those that aren't.
1279+
eligible_indices = []
1280+
for idx, section in enumerate(sections):
1281+
first_line = section.split("\n", 1)[0].strip()
1282+
if _IMAGE_ONLY_HEADING.match(first_line):
1283+
continue # drop image-only sections entirely
1284+
if _CONCLUSION_HEADING.match(first_line):
1285+
sections[idx] = section # keep but don't attach images
1286+
else:
1287+
eligible_indices.append(idx)
1288+
1289+
# Remove image-only sections from the list.
1290+
sections = [s for s in sections if not _IMAGE_ONLY_HEADING.match(s.split("\n", 1)[0].strip())]
1291+
# Recompute eligible indices after removal.
1292+
eligible_indices = []
1293+
for idx, section in enumerate(sections):
1294+
first_line = section.split("\n", 1)[0].strip()
1295+
if not _CONCLUSION_HEADING.match(first_line):
1296+
eligible_indices.append(idx)
1297+
1298+
max_media = max(len(eligible_indices) * 2, 1) # up to 2 images per section
1299+
media_items = media_items[:max_media]
1300+
1301+
# --- 3. Interleave markdown sections + media (up to 2 per eligible section)
1302+
# Distribute media across eligible sections, skipping conclusion/preamble.
1303+
media_assignment: dict[int, list] = {} # section_index -> [media_items]
1304+
for i, media in enumerate(media_items):
1305+
# Round-robin: fill each eligible section with 1, then loop back for 2nd
1306+
slot = i % len(eligible_indices) if eligible_indices else 0
1307+
sec_idx = eligible_indices[slot] if slot < len(eligible_indices) else 0
1308+
media_assignment.setdefault(sec_idx, [])
1309+
if len(media_assignment[sec_idx]) < 2:
1310+
media_assignment[sec_idx].append(media)
1311+
1312+
for idx, section in enumerate(sections):
1313+
nodes.append({"type": "markdown", "content": section})
1314+
for media in media_assignment.get(idx, []):
1315+
nodes.append(media)
1316+
else:
1317+
# Media-only / structured-only results are still renderable and should
1318+
# not collapse into an empty frontend block.
1319+
nodes.extend(media_items)
13161320

13171321
# --- 4. Append structured nodes -------------------------------------------
13181322
objectives = created_main_block.get("learning_objectives")
@@ -2131,6 +2135,12 @@ def _exec_sub(section):
21312135
)
21322136
if content_nodes:
21332137
response_block.metadata["content_nodes"] = content_nodes
2138+
elif not (response_block.content or "").strip():
2139+
response_block.content = (
2140+
"I couldn't assemble a usable response for that request. "
2141+
"Please try again."
2142+
)
2143+
response_block.metadata["empty_generation_fallback"] = True
21342144

21352145
response_block.metadata["response_kind"] = "answer"
21362146
depth_val = (session.user_profile or {}).get("explanation_depth", "Medium")
@@ -2227,6 +2237,12 @@ def _exec_sub(section):
22272237
)
22282238
if content_nodes:
22292239
response_block.metadata["content_nodes"] = content_nodes
2240+
elif not (response_block.content or "").strip():
2241+
response_block.content = (
2242+
"I couldn't assemble a usable response for that request. "
2243+
"Please try again."
2244+
)
2245+
response_block.metadata["empty_generation_fallback"] = True
22302246
response_block.metadata["response_kind"] = "answer"
22312247
if is_main_block:
22322248
depth_val = (session.user_profile or {}).get("explanation_depth", "Medium")

backend/tests/test_agent_tool_choice.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import sys
2+
from unittest.mock import patch
23

34
sys.path.insert(0, ".")
45

56
from agent import (
7+
assess_context_or_answer_simple,
68
_context_missing_fields,
79
_merge_other_context_notes,
810
_requests_text_only,
@@ -91,3 +93,18 @@ def test_sanitize_clarification_keeps_short_question():
9193
session = DummySession({})
9294
short_question = "What is your background with this topic, and what is your goal?"
9395
assert _sanitize_clarification_response(short_question, session=session) == short_question
96+
97+
98+
def test_assess_context_or_answer_simple_rewrites_empty_clarification():
99+
session = DummySession({"__context_slots__": "familiarity"})
100+
101+
with patch("agent.llm_chat") as mock_chat:
102+
mock_chat.return_value = {"content": "", "tool_calls": None}
103+
is_ready, prompt = assess_context_or_answer_simple(
104+
session=session,
105+
user_message="Explain ASMR history",
106+
model="gpt-5.4",
107+
)
108+
109+
assert is_ready is False
110+
assert "What are you learning this for" in prompt

backend/tests/test_block_generation.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1097,6 +1097,34 @@ def test_build_content_nodes_assembles_all_node_types():
10971097
assert quiz_node["questions"][0]["id"] == "q1"
10981098

10991099

1100+
def test_build_content_nodes_preserves_media_without_markdown_content():
1101+
"""Media-only responses should still produce renderable content nodes."""
1102+
from agent import _build_content_nodes
1103+
1104+
created_main_block = {
1105+
"title": "Visuals Only",
1106+
"summary": "Only media is available",
1107+
"content": "",
1108+
}
1109+
tool_results = [
1110+
{"images": [{"url": "https://img.example.com/asmr.jpg", "title": "ASMR setup"}]},
1111+
]
1112+
1113+
nodes = _build_content_nodes(created_main_block, tool_results)
1114+
1115+
assert nodes == [
1116+
{
1117+
"type": "media",
1118+
"item": {
1119+
"id": "img-0",
1120+
"type": "image",
1121+
"source": "https://img.example.com/asmr.jpg",
1122+
"label": "ASMR setup",
1123+
},
1124+
}
1125+
]
1126+
1127+
11001128
def test_create_main_block_with_structured_fields_stores_content_nodes():
11011129
"""create_main_block with structured fields stores content_nodes in block metadata."""
11021130
from agent import run_agent_with_session
@@ -1150,3 +1178,31 @@ def test_create_main_block_with_structured_fields_stores_content_nodes():
11501178
assert "learningObjectives" in types
11511179
assert "keyTerms" in types
11521180
assert "quiz" in types
1181+
1182+
1183+
def test_run_agent_with_session_uses_nonempty_fallback_when_orchestrator_returns_nothing():
1184+
"""Main-block orchestration should never persist an entirely empty assistant block."""
1185+
from agent import run_agent_with_session
1186+
from models import Session
1187+
1188+
session = Session(system_prompt="You are a tutor.", user_profile={"__context_slots__": "familiarity,goal"})
1189+
1190+
with patch("agent.assess_context_or_answer_simple") as mock_preflight, \
1191+
patch("agent.run_orchestrator") as mock_orch, \
1192+
patch("agent.llm_chat") as mock_chat:
1193+
mock_preflight.return_value = (True, "")
1194+
mock_orch.return_value = [{"title": "Main", "instructions": "Answer directly"}]
1195+
mock_chat.side_effect = [
1196+
{"content": "", "tool_calls": None},
1197+
]
1198+
block = run_agent_with_session(
1199+
session=session,
1200+
user_message="Explain the history of ASMR.",
1201+
model="test-model",
1202+
max_turns=1,
1203+
verbose=False,
1204+
persist_block=True,
1205+
)
1206+
1207+
assert block.content == "I couldn't assemble a usable response for that request. Please try again."
1208+
assert block.metadata.get("empty_generation_fallback") is True

0 commit comments

Comments
 (0)