Add robust comparison whiteboard template

#1
board_model_prompt.py CHANGED
@@ -28,7 +28,7 @@ Ops:
28
  {"op":"highlight","at":[x,y],"w":W}
29
  Colors: ink, blue, red, green, orange, purple, gray. Use ^ for exponents in text.
30
 
31
- Draw the step's REAL content (actual names, values, shapes) with 2-5 compact ops, unless it is only a check-in. The board should teach visually about as much as the voice does: if the narration names a part, value, result, or relationship, add a mark for it. Use compact boxes/ellipses, usually under 18 wide and 10 high; main diagrams should leave room for notes, formulas, and callouts. Use callout to circle important labels like c and explain them with a short leader line. Keep related items adjacent instead of drawing long arrows. Every unlabeled arrow must connect two visible items; do not point into empty space. Build on what is already on the board — never redraw it, never collide with it."""
32
 
33
 
34
  def board_context(prior_ops):
 
28
  {"op":"highlight","at":[x,y],"w":W}
29
  Colors: ink, blue, red, green, orange, purple, gray. Use ^ for exponents in text.
30
 
31
+ Draw the step's REAL content (actual names, values, shapes) with 2-5 compact ops, unless it is only a check-in. The board should teach visually about as much as the voice does: if the narration names a part, value, result, or relationship, add a mark for it. For "X vs Y", "compare X and Y", or "difference between X and Y", use a two-column comparison layout with one side for each named thing; never stack the two options vertically with unrelated arrows. Use compact boxes/ellipses, usually under 18 wide and 10 high; main diagrams should leave room for notes, formulas, and callouts. Use callout to circle important labels like c and explain them with a short leader line. Keep related items adjacent instead of drawing long arrows. Every unlabeled arrow must connect two visible items; do not point into empty space. Build on what is already on the board — never redraw it, never collide with it."""
32
 
33
 
34
  def board_context(prior_ops):
board_quality.py CHANGED
@@ -17,6 +17,8 @@ def _blob(*parts):
17
 
18
  def diagram_family(question, say=""):
19
  text = _blob(question, say)
 
 
20
  if ("rocket" in text or "spacecraft" in text or "launch" in text) and (
21
  "orbit" in text or "gravity turn" in text or "orbital" in text
22
  ):
@@ -64,14 +66,15 @@ def improve_step_board(question, step_index, say, board):
64
  board = [dict(op) for op in (board or []) if isinstance(op, dict)]
65
  family = diagram_family(question, say)
66
  if family:
67
- templated = _template(family, step_index, question)
68
  if templated is not None:
69
  return templated
70
  return _repair_generic(question, step_index, say, board)
71
 
72
 
73
- def _template(family, step_index, question=""):
74
  templates = {
 
75
  "rocket_orbit": _rocket_orbit,
76
  "model_access_directive": _model_access_directive,
77
  "no_code_platform": _no_code_platform,
@@ -87,9 +90,98 @@ def _template(family, step_index, question=""):
87
  }
88
  if family == "no_code_platform":
89
  return templates[family](step_index, question)
 
 
90
  return templates[family](step_index)
91
 
92
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  def _topic_title(question, fallback="How It Works"):
94
  text = re.sub(r"[?!.\"]", "", str(question or "")).strip()
95
  text = re.sub(r"^(explain|teach me|show me|tell me about)\s+", "", text, flags=re.I)
 
17
 
18
  def diagram_family(question, say=""):
19
  text = _blob(question, say)
20
+ if _comparison_terms(question, say):
21
+ return "comparison"
22
  if ("rocket" in text or "spacecraft" in text or "launch" in text) and (
23
  "orbit" in text or "gravity turn" in text or "orbital" in text
24
  ):
 
66
  board = [dict(op) for op in (board or []) if isinstance(op, dict)]
67
  family = diagram_family(question, say)
68
  if family:
69
+ templated = _template(family, step_index, question, say)
70
  if templated is not None:
71
  return templated
72
  return _repair_generic(question, step_index, say, board)
73
 
74
 
75
+ def _template(family, step_index, question="", say=""):
76
  templates = {
77
+ "comparison": _comparison,
78
  "rocket_orbit": _rocket_orbit,
79
  "model_access_directive": _model_access_directive,
80
  "no_code_platform": _no_code_platform,
 
90
  }
91
  if family == "no_code_platform":
92
  return templates[family](step_index, question)
93
+ if family == "comparison":
94
+ return templates[family](step_index, question, say)
95
  return templates[family](step_index)
96
 
97
 
98
+ def _clean_term(text):
99
+ text = re.sub(r"[?!.\"]", "", str(text or "")).strip(" :,-")
100
+ text = re.sub(
101
+ r"^(explain|teach me|show me|tell me about|what is|what's|whats|how is|how are|compare)\s+",
102
+ "", text, flags=re.I,
103
+ )
104
+ text = re.split(
105
+ r"\s+(?:for|if|when|because|in order to|so that)\s+",
106
+ text, maxsplit=1, flags=re.I,
107
+ )[0]
108
+ words = text.split()[:4]
109
+ text = " ".join(words).strip(" :,-")
110
+ known = {"linkedin": "LinkedIn", "linkme": "Linkme"}
111
+ return known.get(text.lower(), text[:24] or "Option")
112
+
113
+
114
+ def _comparison_terms(question, say=""):
115
+ src = str(question or "") or str(say or "")
116
+ patterns = [
117
+ r"(.+?)\s+(?:vs\.?|versus)\s+(.+?)(?:[?.,]|$)",
118
+ r"(?:difference between|compare)\s+(.+?)\s+(?:and|with|to)\s+(.+?)(?:[?.,]|$)",
119
+ ]
120
+ for pat in patterns:
121
+ m = re.search(pat, src, flags=re.I)
122
+ if not m:
123
+ continue
124
+ left, right = _clean_term(m.group(1)), _clean_term(m.group(2))
125
+ if left and right and left.lower() != right.lower():
126
+ return left, right
127
+ return None
128
+
129
+
130
+ def _comparison_lines(name, question, say):
131
+ low = name.lower()
132
+ if low == "linkme":
133
+ return ["one shareable URL", "bio / portfolio links", "send people outward"]
134
+ if low == "linkedin":
135
+ return ["career profile", "jobs + recruiters", "professional network"]
136
+
137
+ text = str(say or question or "")
138
+ for sent in re.split(r"[.;]\s*", text):
139
+ if low not in sent.lower():
140
+ continue
141
+ lines = _note_lines(sent)
142
+ if lines:
143
+ return lines[:3]
144
+ return ["main purpose", "best-fit use", "when to choose it"]
145
+
146
+
147
+ def _comparison(i, question="", say=""):
148
+ left, right = _comparison_terms(question, say) or ("Option A", "Option B")
149
+ left_lines = _comparison_lines(left, question, say)
150
+ right_lines = _comparison_lines(right, question, say)
151
+ if {left.lower(), right.lower()} == {"linkme", "linkedin"}:
152
+ linkme = left if left.lower() == "linkme" else right
153
+ linkedin = left if left.lower() == "linkedin" else right
154
+ decision = f"{linkme}: links; {linkedin}: jobs"
155
+ else:
156
+ decision = "Choose by goal, not name"
157
+ steps = [
158
+ [
159
+ {"op": "title", "text": f"{left} vs {right}"},
160
+ {"op": "box", "at": [8, 25], "w": 30, "h": 10,
161
+ "label": left, "color": "blue"},
162
+ {"op": "box", "at": [62, 25], "w": 30, "h": 10,
163
+ "label": right, "color": "purple"},
164
+ {"op": "line", "from": [50, 22], "to": [50, 68],
165
+ "color": "gray", "dash": True},
166
+ ],
167
+ [
168
+ {"op": "notes", "title": f"{left} fits",
169
+ "lines": left_lines, "at": [8, 42], "color": "blue",
170
+ "compact": True},
171
+ ],
172
+ [
173
+ {"op": "notes", "title": f"{right} fits",
174
+ "lines": right_lines, "at": [62, 42], "color": "purple",
175
+ "compact": True},
176
+ ],
177
+ [
178
+ {"op": "text", "text": decision, "at": [35, 19],
179
+ "size": "s", "color": "green", "fit_w": 42},
180
+ ],
181
+ ]
182
+ return steps[i] if i < len(steps) else None
183
+
184
+
185
  def _topic_title(question, fallback="How It Works"):
186
  text = re.sub(r"[?!.\"]", "", str(question or "")).strip()
187
  text = re.sub(r"^(explain|teach me|show me|tell me about)\s+", "", text, flags=re.I)
engine.py CHANGED
@@ -233,6 +233,10 @@ Rules for "steps":
233
  - For "how X works" lessons, do not stop at two boxes. Draw the working system:
234
  3 to 5 topic-specific parts, causal arrows with short labels, and one compact
235
  note block that explains what the diagram means.
 
 
 
 
236
  - Values like "a = 3" are lines inside the notes block, NEVER standalone text ops.
237
  - Each step draws ONLY NEW marks. Never redraw the title, the diagram, or anything
238
  already on the board — duplicates are discarded.
 
233
  - For "how X works" lessons, do not stop at two boxes. Draw the working system:
234
  3 to 5 topic-specific parts, causal arrows with short labels, and one compact
235
  note block that explains what the diagram means.
236
+ - For comparison lessons ("X vs Y", "compare X and Y", "difference between X and Y"),
237
+ use a two-column comparison board with one side for each named thing, short feature
238
+ notes under each side, and a compact takeaway. Do not stack the two options
239
+ vertically or connect them with arrows unless there is a real causal relationship.
240
  - Values like "a = 3" are lines inside the notes block, NEVER standalone text ops.
241
  - Each step draws ONLY NEW marks. Never redraw the title, the diagram, or anything
242
  already on the board — duplicates are discarded.
mock_engine.py CHANGED
@@ -44,14 +44,23 @@ def _step(say, board):
44
 
45
 
46
  def _templated_lesson(topic):
47
- if diagram_family(topic) != "pythagorean":
 
48
  return None
49
- says = [
50
- "Let's identify the right triangle first: the two legs make the L shape, and c is the hypotenuse across from it.",
51
- "The theorem says the square on c equals the two leg squares added together.",
52
- "For a concrete example, set a to three and b to four, then substitute those values into the formula.",
53
- "That gives twenty five, so c is five. Notice five is the longest side, which matches the drawing.",
54
- ]
 
 
 
 
 
 
 
 
55
  out = []
56
  for i, say in enumerate(says):
57
  board = improve_step_board(topic, i, say, [])
 
44
 
45
 
46
  def _templated_lesson(topic):
47
+ family = diagram_family(topic)
48
+ if family not in {"pythagorean", "comparison"}:
49
  return None
50
+ if family == "pythagorean":
51
+ says = [
52
+ "Let's identify the right triangle first: the two legs make the L shape, and c is the hypotenuse across from it.",
53
+ "The theorem says the square on c equals the two leg squares added together.",
54
+ "For a concrete example, set a to three and b to four, then substitute those values into the formula.",
55
+ "That gives twenty five, so c is five. Notice five is the longest side, which matches the drawing.",
56
+ ]
57
+ else:
58
+ says = [
59
+ f"Let's compare {topic} by purpose instead of drawing a generic flowchart.",
60
+ "The left side is for the first option's best-fit job and the context where it makes sense.",
61
+ "The right side is for the second option's job, so the differences stay parallel and easy to scan.",
62
+ "The takeaway is to choose based on what you are trying to accomplish, not which name sounds broader.",
63
+ ]
64
  out = []
65
  for i, say in enumerate(says):
66
  board = improve_step_board(topic, i, say, [])
test_board_quality.py CHANGED
@@ -80,6 +80,27 @@ class BoardQualityTests(unittest.TestCase):
80
  xs = [p[0] for p in tri["points"]]
81
  self.assertLessEqual(max(xs) - min(xs), 40)
82
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  def test_fable_mythos_access_template_matches_directive(self):
84
  self.assertEqual(
85
  diagram_family("Anthropic Fable 5 Mythos 5 government ban access"),
@@ -104,6 +125,7 @@ class BoardQualityTests(unittest.TestCase):
104
  self.assertEqual(diagram_family("How do neural networks learn?"), "neural_network")
105
  self.assertEqual(diagram_family("Explain supply and demand"), "supply_demand")
106
  self.assertEqual(diagram_family("How does Unqork work?"), "no_code_platform")
 
107
  self.assertEqual(diagram_family("What happened in space exploration this month?"), None)
108
 
109
  def test_checkin_steps_stay_empty_when_model_drew_nothing(self):
@@ -124,6 +146,8 @@ class BoardQualityTests(unittest.TestCase):
124
  "How does photosynthesis work?",
125
  "How do rainbows form?",
126
  "Explain the water cycle",
 
 
127
  ]
128
  for question in cases:
129
  with self.subTest(question=question):
 
80
  xs = [p[0] for p in tri["points"]]
81
  self.assertLessEqual(max(xs) - min(xs), 40)
82
 
83
+ def test_unique_comparisons_get_structured_two_column_board(self):
84
+ self.assertEqual(diagram_family("Linkme vs LinkedIn"), "comparison")
85
+ ns = load_engine_ns()
86
+ question = "Linkme vs LinkedIn"
87
+ violations, ops = simulate_lesson(ns, _template_steps(question))
88
+ self.assertEqual(violations, [])
89
+ parts = []
90
+ for op in ops:
91
+ parts.extend(str(op.get(k, "")) for k in ("text", "label", "title"))
92
+ parts.extend(str(line) for line in op.get("lines", []) or [])
93
+ text = " ".join(parts)
94
+ self.assertIn("Linkme", text)
95
+ self.assertIn("LinkedIn", text)
96
+ self.assertIn("one shareable URL", text)
97
+ self.assertIn("jobs + recruiters", text)
98
+ boxes = [op for op in ops if op.get("op") == "box"]
99
+ self.assertEqual(len(boxes), 2)
100
+ self.assertLess(boxes[0]["at"][0], 40)
101
+ self.assertGreater(boxes[1]["at"][0], 55)
102
+ self.assertFalse(any(op.get("op") == "arrow" for op in ops))
103
+
104
  def test_fable_mythos_access_template_matches_directive(self):
105
  self.assertEqual(
106
  diagram_family("Anthropic Fable 5 Mythos 5 government ban access"),
 
125
  self.assertEqual(diagram_family("How do neural networks learn?"), "neural_network")
126
  self.assertEqual(diagram_family("Explain supply and demand"), "supply_demand")
127
  self.assertEqual(diagram_family("How does Unqork work?"), "no_code_platform")
128
+ self.assertEqual(diagram_family("Compare Notion and Airtable"), "comparison")
129
  self.assertEqual(diagram_family("What happened in space exploration this month?"), None)
130
 
131
  def test_checkin_steps_stay_empty_when_model_drew_nothing(self):
 
146
  "How does photosynthesis work?",
147
  "How do rainbows form?",
148
  "Explain the water cycle",
149
+ "Linkme vs LinkedIn",
150
+ "Compare Notion and Airtable",
151
  ]
152
  for question in cases:
153
  with self.subTest(question=question):