Skip to content

Commit a3728d5

Browse files
committed
Require editable inputs in writable activity sections
Validate that activity_open_ended_answer, activity_fill_in_the_blank, and activity_fill_in_a_table sections contain at least one <textarea>, writable <input>, or [[blank:item-N]] marker — fail loudly when the LLM emits only static underlines so the renderer retries. Permit the LLM to strip textbook blank placeholders (___ or ...) from label text when it emits a separate editable element, and skip text replacement on data-id elements containing nested editables so they aren't destroyed. Update prompts to add a label-plus-separate-input pattern for form-style labels, require one textarea per response field for open-ended answers, drop the hardcoded Spanish placeholder text, and add a writable-page precedence rule to page sectioning.
1 parent f52543e commit a3728d5

5 files changed

Lines changed: 540 additions & 52 deletions

File tree

packages/pipeline/src/__tests__/validate-html.test.ts

Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1066,3 +1066,291 @@ describe("textSimilarity", () => {
10661066
expect(textSimilarity("abc", "xyz")).toBe(0.0)
10671067
})
10681068
})
1069+
1070+
describe("writable section types require editable inputs", () => {
1071+
it("fails activity_open_ended_answer with no textarea/input/blank marker", () => {
1072+
const html = `
1073+
<section data-section-type="activity_open_ended_answer" data-section-id="pg001_section">
1074+
<p data-id="pg001_gp001">Write your answer below:</p>
1075+
<hr class="border-b border-gray-400" />
1076+
<hr class="border-b border-gray-400" />
1077+
</section>
1078+
`
1079+
const result = validateSectionHtml(html, ["pg001_gp001"], [])
1080+
expect(result.valid).toBe(false)
1081+
expect(result.errors).toContainEqual(
1082+
expect.stringContaining(
1083+
'Section type "activity_open_ended_answer" requires at least one editable element'
1084+
)
1085+
)
1086+
})
1087+
1088+
it("passes activity_open_ended_answer when a <textarea> is present", () => {
1089+
const html = `
1090+
<section data-section-type="activity_open_ended_answer" data-section-id="pg001_section">
1091+
<p data-id="pg001_gp001">Write your answer below:</p>
1092+
<textarea class="w-full" aria-label="answer"></textarea>
1093+
</section>
1094+
`
1095+
const result = validateSectionHtml(html, ["pg001_gp001"], [])
1096+
expect(result.valid).toBe(true)
1097+
})
1098+
1099+
it("passes activity_fill_in_the_blank when a [[blank:item-N]] marker is present", () => {
1100+
const html = `
1101+
<section data-section-type="activity_fill_in_the_blank" data-section-id="pg001_section">
1102+
<p class="fitb-sentence" data-id="pg001_gp001">The capital of France is [[blank:item-1]].</p>
1103+
</section>
1104+
`
1105+
const result = validateSectionHtml(
1106+
html,
1107+
["pg001_gp001"],
1108+
[],
1109+
undefined,
1110+
{
1111+
expectedTexts: new Map([
1112+
["pg001_gp001", "The capital of France is ___."],
1113+
]),
1114+
}
1115+
)
1116+
expect(result.valid).toBe(true)
1117+
})
1118+
1119+
it("fails activity_fill_in_a_table when no <input> elements are present", () => {
1120+
const html = `
1121+
<section data-section-type="activity_fill_in_a_table" data-section-id="pg001_section">
1122+
<table>
1123+
<tr>
1124+
<td data-id="pg001_gp001">Name</td>
1125+
<td><div class="border-b"></div></td>
1126+
</tr>
1127+
</table>
1128+
</section>
1129+
`
1130+
const result = validateSectionHtml(html, ["pg001_gp001"], [])
1131+
expect(result.valid).toBe(false)
1132+
expect(result.errors).toContainEqual(
1133+
expect.stringContaining(
1134+
'Section type "activity_fill_in_a_table" requires at least one editable element'
1135+
)
1136+
)
1137+
})
1138+
1139+
it("does not require editable elements for non-writable section types", () => {
1140+
const html = `
1141+
<section data-section-type="text_only" data-section-id="pg001_section">
1142+
<p data-id="pg001_gp001">Just some prose.</p>
1143+
</section>
1144+
`
1145+
const result = validateSectionHtml(html, ["pg001_gp001"], [])
1146+
expect(result.valid).toBe(true)
1147+
})
1148+
})
1149+
1150+
describe("textbook blank placeholders may be omitted when editable element is provided", () => {
1151+
it("accepts label text with underscores stripped when a <textarea> is added next to it", () => {
1152+
const html = `
1153+
<section data-section-type="activity_open_ended_answer" data-section-id="pg010_section">
1154+
<div>
1155+
<p data-id="pg010_gp001_tx001">Nombre:</p>
1156+
<textarea aria-label="nombre"></textarea>
1157+
</div>
1158+
</section>
1159+
`
1160+
const result = validateSectionHtml(
1161+
html,
1162+
["pg010_gp001_tx001"],
1163+
[],
1164+
undefined,
1165+
{
1166+
expectedTexts: new Map([["pg010_gp001_tx001", "Nombre: ___"]]),
1167+
}
1168+
)
1169+
expect(result.valid).toBe(true)
1170+
expect(result.errors).toEqual([])
1171+
})
1172+
1173+
it("accepts label with multiple stripped underscore runs (e.g. dates)", () => {
1174+
const html = `
1175+
<section data-section-type="activity_open_ended_answer" data-section-id="pg011_section">
1176+
<div>
1177+
<p data-id="pg011_gp002_tx003">Fecha de nacimiento:</p>
1178+
<textarea aria-label="fecha"></textarea>
1179+
</div>
1180+
</section>
1181+
`
1182+
const result = validateSectionHtml(
1183+
html,
1184+
["pg011_gp002_tx003"],
1185+
[],
1186+
undefined,
1187+
{
1188+
expectedTexts: new Map([
1189+
["pg011_gp002_tx003", "Fecha de nacimiento: ___ / ___ / ___"],
1190+
]),
1191+
}
1192+
)
1193+
expect(result.valid).toBe(true)
1194+
expect(result.errors).toEqual([])
1195+
})
1196+
1197+
it("preserves the rendered (stripped) text when underscores were dropped", () => {
1198+
const html = `
1199+
<section data-section-type="activity_open_ended_answer" data-section-id="pg010_section">
1200+
<p data-id="pg010_gp009_tx003">¡Soy !</p>
1201+
<textarea aria-label="soy"></textarea>
1202+
</section>
1203+
`
1204+
const result = validateSectionHtml(
1205+
html,
1206+
["pg010_gp009_tx003"],
1207+
[],
1208+
undefined,
1209+
{
1210+
expectedTexts: new Map([["pg010_gp009_tx003", "¡Soy ___ !"]]),
1211+
}
1212+
)
1213+
expect(result.valid).toBe(true)
1214+
// Rendered HTML should keep the stripped version, not put the underscores back.
1215+
expect(result.sectionHtml).not.toContain("___")
1216+
})
1217+
1218+
it("still rejects truly different text even with underscore stripping", () => {
1219+
const html = `
1220+
<section data-section-type="activity_open_ended_answer" data-section-id="pg010_section">
1221+
<p data-id="pg010_gp001_tx001">Completely unrelated text here</p>
1222+
<textarea aria-label="x"></textarea>
1223+
</section>
1224+
`
1225+
const result = validateSectionHtml(
1226+
html,
1227+
["pg010_gp001_tx001"],
1228+
[],
1229+
undefined,
1230+
{
1231+
expectedTexts: new Map([["pg010_gp001_tx001", "Nombre: ___"]]),
1232+
}
1233+
)
1234+
expect(result.valid).toBe(false)
1235+
expect(result.errors).toContainEqual(
1236+
expect.stringContaining('Text mismatch for data-id "pg010_gp001_tx001"')
1237+
)
1238+
})
1239+
})
1240+
1241+
describe("hasEditableElement — input type filtering", () => {
1242+
it("does not count <input type=\"radio\"> as a writable input", () => {
1243+
const html = `
1244+
<section data-section-type="activity_open_ended_answer" data-section-id="pg001_section">
1245+
<p data-id="pg001_gp001">Question?</p>
1246+
<input type="radio" value="a" />
1247+
<input type="radio" value="b" />
1248+
</section>
1249+
`
1250+
const result = validateSectionHtml(html, ["pg001_gp001"], [])
1251+
expect(result.valid).toBe(false)
1252+
expect(result.errors).toContainEqual(
1253+
expect.stringContaining(
1254+
'Section type "activity_open_ended_answer" requires at least one editable element'
1255+
)
1256+
)
1257+
})
1258+
1259+
it("does not count <input type=\"checkbox\">, \"submit\", \"hidden\" as writable", () => {
1260+
const html = `
1261+
<section data-section-type="activity_fill_in_the_blank" data-section-id="pg001_section">
1262+
<p data-id="pg001_gp001">Label</p>
1263+
<input type="checkbox" />
1264+
<input type="submit" value="Go" />
1265+
<input type="hidden" name="x" value="1" />
1266+
</section>
1267+
`
1268+
const result = validateSectionHtml(html, ["pg001_gp001"], [])
1269+
expect(result.valid).toBe(false)
1270+
expect(result.errors).toContainEqual(
1271+
expect.stringContaining(
1272+
'Section type "activity_fill_in_the_blank" requires at least one editable element'
1273+
)
1274+
)
1275+
})
1276+
1277+
it("counts <input> with no type attribute as writable (defaults to text)", () => {
1278+
const html = `
1279+
<section data-section-type="activity_fill_in_the_blank" data-section-id="pg001_section">
1280+
<p data-id="pg001_gp001">Name:</p>
1281+
<input data-activity-item="item-1" aria-label="name" />
1282+
</section>
1283+
`
1284+
const result = validateSectionHtml(html, ["pg001_gp001"], [])
1285+
expect(result.valid).toBe(true)
1286+
})
1287+
1288+
it("counts <input type=\"text\"> as writable", () => {
1289+
const html = `
1290+
<section data-section-type="activity_fill_in_the_blank" data-section-id="pg001_section">
1291+
<p data-id="pg001_gp001">Name:</p>
1292+
<input type="text" data-activity-item="item-1" aria-label="name" />
1293+
</section>
1294+
`
1295+
const result = validateSectionHtml(html, ["pg001_gp001"], [])
1296+
expect(result.valid).toBe(true)
1297+
})
1298+
1299+
it("ignores [[blank:item-N]] markers inside <style> blocks", () => {
1300+
const html = `
1301+
<section data-section-type="activity_fill_in_the_blank" data-section-id="pg001_section">
1302+
<style>/* [[blank:item-1]] */</style>
1303+
<p data-id="pg001_gp001">Label</p>
1304+
</section>
1305+
`
1306+
const result = validateSectionHtml(html, ["pg001_gp001"], [])
1307+
expect(result.valid).toBe(false)
1308+
expect(result.errors).toContainEqual(
1309+
expect.stringContaining(
1310+
'Section type "activity_fill_in_the_blank" requires at least one editable element'
1311+
)
1312+
)
1313+
})
1314+
})
1315+
1316+
describe("text replacement preserves nested editables", () => {
1317+
it("does not destroy a <textarea> nested inside a data-id element", () => {
1318+
const html = `
1319+
<section data-section-type="activity_open_ended_answer" data-section-id="pg001_section">
1320+
<p data-id="pg001_gp001">La capital es <textarea aria-label="capital"></textarea></p>
1321+
</section>
1322+
`
1323+
const result = validateSectionHtml(
1324+
html,
1325+
["pg001_gp001"],
1326+
[],
1327+
undefined,
1328+
{
1329+
expectedTexts: new Map([["pg001_gp001", "La capital es ___"]]),
1330+
}
1331+
)
1332+
expect(result.valid).toBe(true)
1333+
// The nested textarea must survive — otherwise the page renders as
1334+
// static text and the learner can no longer write an answer.
1335+
expect(result.sectionHtml).toContain("<textarea")
1336+
})
1337+
1338+
it("does not destroy a nested <input> writable element", () => {
1339+
const html = `
1340+
<section data-section-type="activity_fill_in_the_blank" data-section-id="pg001_section">
1341+
<p data-id="pg001_gp001">Nombre: <input type="text" data-activity-item="item-1" aria-label="nombre" /></p>
1342+
</section>
1343+
`
1344+
const result = validateSectionHtml(
1345+
html,
1346+
["pg001_gp001"],
1347+
[],
1348+
undefined,
1349+
{
1350+
expectedTexts: new Map([["pg001_gp001", "Nombre: ___"]]),
1351+
}
1352+
)
1353+
expect(result.valid).toBe(true)
1354+
expect(result.sectionHtml).toContain("<input")
1355+
})
1356+
})

0 commit comments

Comments
 (0)