khattab273 commited on
Commit
fedc777
·
verified ·
1 Parent(s): d3a7312

Intake: never re-ask for deal-breakers once given

Browse files
Files changed (2) hide show
  1. core/intake.py +39 -12
  2. tests/test_intake.py +79 -4
core/intake.py CHANGED
@@ -148,8 +148,9 @@ terms.other as a short phrase (e.g. "MRR of at least $30k", "must be profitable"
148
  - Warm, natural and short: at most two sentences, then ONE question. Use the member's language (English or \
149
  Arabic); if they write in Arabic, reply in Modern Standard Arabic.
150
  - After a document arrives, show you read it by mentioning one specific detail.
151
- - The missing list was made BEFORE the member's latest message. Ask about the first item still unknown after you \
152
- apply that message. Phrase it conversationally, never like a form.
 
153
  - Clarify when it matters: if an answer is vague in a way that changes who you'd recommend, ask one short \
154
  clarifying question before moving on. Examples: "early stage" -> pre-seed, seed, or both? "software" -> what kind \
155
  (B2B SaaS, fintech, AI, marketplaces)? "in Qatar" -> based in Qatar, or selling into Qatar? Never ask about details that \
@@ -424,6 +425,20 @@ def dealbreaker_lines(s: Session) -> list[str]:
424
  return lines + add
425
 
426
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
427
  def _all_private(t):
428
  """A member's own numbers are never explained to anyone else: mark every bound private.
429
 
@@ -469,6 +484,11 @@ def _messages(s: Session, need: list[str]) -> list[dict]:
469
  if _arabic_share(last) > 0.3:
470
  doc += "\n\nThe member is writing in Arabic: reply in Modern Standard Arabic."
471
  system = SYSTEM
 
 
 
 
 
472
  if delta_mode():
473
  context["terms_so_far"] = s.raw_terms.model_dump(exclude_none=True) if s.raw_terms else {}
474
  context["empty_fields"] = [k for k in ("display_name", "headline", "public_bio", "looking_for", "location",
@@ -566,25 +586,32 @@ def _turn(session_id: str, text: str, file, stream: bool):
566
  s.raw_terms = fix_units(mark_private(_merge_terms(s.raw_terms, turn.terms), s.draft.private_limits), said,
567
  s.draft.location)
568
  s.terms = _all_private(to_terms(s.raw_terms))
 
569
  # A "yes" only confirms the profile if it answers the summary.
570
  s.confirmed = turn.confirmed and s.summary_shown and ready(s)
571
  s.summary_shown = turn.asks == "summary"
572
  if turn.asks in ("dealbreakers", "summary"):
573
  s.dealbreakers_asked = True # asked once ("none" is fine); never ask again after a summary
574
- asks_more = re.search(r"deal.?breaker|rule (?:someone|anyone|people) out|anything else|any other|other requirement",
575
- turn.reply, re.I) and "?" in turn.reply
576
- # Once they've just given deal-breakers and nothing required is missing, the next step is the summary: any other
577
- # question here (more deal-breakers, equity, timeline...) would make their next "yes" ambiguous.
578
- if _rules(s) != rules_before and _has_rules(s) and not _required(s) and (asks_more or turn.asks != "summary"):
579
- # They just gave their deal-breakers; asking "any others?" makes the next "yes" ambiguous (and the
580
- # model's own asks label can't be trusted here). Ask once more with a direct instruction.
581
- note = [{"role": "user", "content": "(Note from Handshake: the member has just given their deal-breakers. "
582
- "Write the summary now and ask only whether it is correct.)"}]
 
 
 
 
 
 
583
  try:
584
  again, _ = llm.chat_json(_messages(s, missing(s)) + note, _Turn, max_tokens=1500, timeout=TURN_TIMEOUT)
585
  if again.reply.strip():
586
  turn = again
587
- s.summary_shown = True
588
  s.dealbreakers_asked = True
589
  except llm.LLMError:
590
  pass
 
148
  - Warm, natural and short: at most two sentences, then ONE question. Use the member's language (English or \
149
  Arabic); if they write in Arabic, reply in Modern Standard Arabic.
150
  - After a document arrives, show you read it by mentioning one specific detail.
151
+ - The missing list was made BEFORE the member's latest message. Of the items still unknown after you apply that \
152
+ message, ask about the one that fits the conversation most naturally (usually the first). Never ask about anything \
153
+ the member already said in ANY earlier message or the document. Phrase it conversationally, never like a form.
154
  - Clarify when it matters: if an answer is vague in a way that changes who you'd recommend, ask one short \
155
  clarifying question before moving on. Examples: "early stage" -> pre-seed, seed, or both? "software" -> what kind \
156
  (B2B SaaS, fintech, AI, marketplaces)? "in Qatar" -> based in Qatar, or selling into Qatar? Never ask about details that \
 
425
  return lines + add
426
 
427
 
428
+ _MUST_WORK = re.compile(r"\b(only|must|have to|need to be|won'?t|will not|can'?t|cannot|not willing|refuse|no)\b[^.]{0,40}"
429
+ r"\b(remote|on.?site|in.?office|hybrid|relocat\w*|office)\b|\b(remote|on.?site|hybrid)\s+only\b"
430
+ r"|non.?negotiable|deal.?breaker", re.I)
431
+
432
+
433
+ def _work_only_if_required(s: Session, said: str) -> None:
434
+ """A work mode filters people out only if the member said it's a requirement. "Hybrid is fine" is a preference:
435
+ it stays in their public terms as text, but it doesn't remove on-site or remote roles."""
436
+ if s.terms and s.terms.work and not _MUST_WORK.search(said):
437
+ s.terms.work = []
438
+ if s.raw_terms:
439
+ s.raw_terms.work = []
440
+
441
+
442
  def _all_private(t):
443
  """A member's own numbers are never explained to anyone else: mark every bound private.
444
 
 
484
  if _arabic_share(last) > 0.3:
485
  doc += "\n\nThe member is writing in Arabic: reply in Modern Standard Arabic."
486
  system = SYSTEM
487
+ if _has_rules(s):
488
+ context["dealbreakers_already_given"] = dealbreaker_lines(s)
489
+ system += ("\nDEAL-BREAKERS ARE ALREADY GIVEN (see dealbreakers_already_given). Do not ask for deal-breakers "
490
+ "again in any form: no 'any other deal-breakers?', no examples of deal-breakers. If the member adds "
491
+ "one on their own, record it.")
492
  if delta_mode():
493
  context["terms_so_far"] = s.raw_terms.model_dump(exclude_none=True) if s.raw_terms else {}
494
  context["empty_fields"] = [k for k in ("display_name", "headline", "public_bio", "looking_for", "location",
 
586
  s.raw_terms = fix_units(mark_private(_merge_terms(s.raw_terms, turn.terms), s.draft.private_limits), said,
587
  s.draft.location)
588
  s.terms = _all_private(to_terms(s.raw_terms))
589
+ _work_only_if_required(s, said)
590
  # A "yes" only confirms the profile if it answers the summary.
591
  s.confirmed = turn.confirmed and s.summary_shown and ready(s)
592
  s.summary_shown = turn.asks == "summary"
593
  if turn.asks in ("dealbreakers", "summary"):
594
  s.dealbreakers_asked = True # asked once ("none" is fine); never ask again after a summary
595
+ asks_more = re.search(r"deal.?breaker|rule (?:someone|anyone|people) out|anything else|any other|other requirement"
596
+ r"|non.?negotiable|must.?have|would make you say no", turn.reply, re.I) and "?" in turn.reply
597
+ todo = _required(s)
598
+ # Deal-breakers are known, so asking for more makes the member repeat themselves (and makes their next "yes"
599
+ # ambiguous). And once they've just given them with nothing else missing, the next step is the summary.
600
+ redo = None
601
+ if _has_rules(s) and asks_more:
602
+ redo = ("Write the summary now and ask only whether it is correct." if not todo else
603
+ f"Do not ask about deal-breakers. Ask only about: {todo[0]}.")
604
+ elif _rules(s) != rules_before and _has_rules(s) and not todo and turn.asks != "summary":
605
+ redo = "Write the summary now and ask only whether it is correct."
606
+ if redo:
607
+ # The model's own asks label can't be trusted here, so code decides and asks once more, directly.
608
+ note = [{"role": "user", "content": "(Note from Handshake: the member has already given their deal-breakers. "
609
+ + redo + ")"}]
610
  try:
611
  again, _ = llm.chat_json(_messages(s, missing(s)) + note, _Turn, max_tokens=1500, timeout=TURN_TIMEOUT)
612
  if again.reply.strip():
613
  turn = again
614
+ s.summary_shown = not todo
615
  s.dealbreakers_asked = True
616
  except llm.LLMError:
617
  pass
tests/test_intake.py CHANGED
@@ -13,11 +13,13 @@ def script(monkeypatch, turns):
13
 
14
  def fake(messages, schema, max_tokens=0, timeout=0):
15
  sent.append(messages)
16
- nxt = queue.pop(0)
 
17
  if isinstance(nxt, Exception):
18
  raise nxt
19
  return schema.model_validate(nxt), "fake"
20
 
 
21
  monkeypatch.setattr(llm, "chat_json", fake)
22
  return sent
23
 
@@ -65,7 +67,7 @@ def test_monthly_qar_salary_becomes_annual_usd(monkeypatch):
65
 
66
 
67
  def test_terms_survive_a_turn_that_omits_them(monkeypatch):
68
- script(monkeypatch, [{"role": "founder", "profile": FOUNDER, "terms": {"valuation": {"low": 9e6}}, "asks": "summary", "reply": "a"},
69
  {"role": "founder", "profile": FOUNDER, "reply": "b"}])
70
  sid = ob.start()["session_id"]
71
  ob.send(sid, "x")
@@ -93,7 +95,7 @@ def test_private_number_is_stripped_from_public_fields(monkeypatch):
93
  leaky = {**FOUNDER, "public_bio": "Won't go below a $9M valuation.",
94
  "public_terms": {"raising": "$2M", "valuation": "at least $9M"},
95
  "private_limits": ["Valuation floor $9M"]}
96
- script(monkeypatch, [{"role": "founder", "profile": leaky, "asks": "summary", "reply": "ok"}])
97
  sid = ob.start()["session_id"]
98
  p = ob.send(sid, "x")["profile"]
99
  assert p["public_bio"] == "" and "valuation" not in p["public_terms"]
@@ -207,7 +209,7 @@ def test_bad_files_get_friendly_errors(tmp_path):
207
 
208
 
209
  def test_dealbreaker_card_includes_numbers_the_model_did_not_word(monkeypatch):
210
- script(monkeypatch, [{"role": "founder", "profile": FOUNDER, "terms": {"valuation": {"low": 5000000}}, "asks": "summary", "reply": "ok"}])
211
  sid = ob.start()["session_id"]
212
  st = ob.send(sid, "floor is $5M")
213
  assert st["profile"]["dealbreakers"] == ["Valuation of at least $5M"]
@@ -225,3 +227,76 @@ def test_vision_outage_is_explained(tmp_path, monkeypatch):
225
  monkeypatch.setattr(llm, "read_image", lambda *a, **k: (_ for _ in ()).throw(llm.LLMError("down")))
226
  with pytest.raises(documents.DocumentError, match="mostly images"):
227
  documents.read(tmp_path / "deck.pdf")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
  def fake(messages, schema, max_tokens=0, timeout=0):
15
  sent.append(messages)
16
+ nxt = queue.pop(0) if len(queue) > 1 or not sent[:-1] else (queue[0] if queue else fake.last)
17
+ fake.last = nxt
18
  if isinstance(nxt, Exception):
19
  raise nxt
20
  return schema.model_validate(nxt), "fake"
21
 
22
+ fake.last = turns[-1] if turns else None
23
  monkeypatch.setattr(llm, "chat_json", fake)
24
  return sent
25
 
 
67
 
68
 
69
  def test_terms_survive_a_turn_that_omits_them(monkeypatch):
70
+ script(monkeypatch, [{"role": "founder", "profile": FOUNDER, "terms": {"valuation": {"low": 9e6}}, "reply": "a"},
71
  {"role": "founder", "profile": FOUNDER, "reply": "b"}])
72
  sid = ob.start()["session_id"]
73
  ob.send(sid, "x")
 
95
  leaky = {**FOUNDER, "public_bio": "Won't go below a $9M valuation.",
96
  "public_terms": {"raising": "$2M", "valuation": "at least $9M"},
97
  "private_limits": ["Valuation floor $9M"]}
98
+ script(monkeypatch, [{"role": "founder", "profile": leaky, "reply": "ok"}])
99
  sid = ob.start()["session_id"]
100
  p = ob.send(sid, "x")["profile"]
101
  assert p["public_bio"] == "" and "valuation" not in p["public_terms"]
 
209
 
210
 
211
  def test_dealbreaker_card_includes_numbers_the_model_did_not_word(monkeypatch):
212
+ script(monkeypatch, [{"role": "founder", "profile": FOUNDER, "terms": {"valuation": {"low": 5000000}}, "reply": "ok"}])
213
  sid = ob.start()["session_id"]
214
  st = ob.send(sid, "floor is $5M")
215
  assert st["profile"]["dealbreakers"] == ["Valuation of at least $5M"]
 
227
  monkeypatch.setattr(llm, "read_image", lambda *a, **k: (_ for _ in ()).throw(llm.LLMError("down")))
228
  with pytest.raises(documents.DocumentError, match="mostly images"):
229
  documents.read(tmp_path / "deck.pdf")
230
+
231
+
232
+ def test_delta_mode_merges_only_what_changed(monkeypatch):
233
+ monkeypatch.setenv("INTAKE_MODE", "delta")
234
+ script(monkeypatch, [{"role": "founder", "profile": FOUNDER, "reply": "a"},
235
+ {"profile": {"public_terms": {"timeline": "Q1 2027"}, "stage": "series a"}, "reply": "b"}])
236
+ sid = ob.start()["session_id"]
237
+ ob.send(sid, "x")
238
+ p = ob.send(sid, "we're series A, closing Q1 2027")["profile"]
239
+ assert p["public_terms"] == {"raising": "$2M", "timeline": "Q1 2027"} # old keys kept, new key added
240
+ assert p["stage"] == "series a" and p["display_name"] == "Mariam" and p["role"] == "founder"
241
+
242
+
243
+ def test_full_mode_is_the_original_prompt(monkeypatch):
244
+ monkeypatch.setenv("INTAKE_MODE", "full")
245
+ sent = script(monkeypatch, [{"role": "founder", "profile": FOUNDER, "reply": "a"}])
246
+ ob.send(ob.start()["session_id"], "x")
247
+ assert "WRITE ONLY CHANGES" not in sent[0][0]["content"] and "terms_so_far" not in sent[0][1]["content"]
248
+
249
+
250
+ def test_looking_for_is_written_from_terms_when_the_model_skips_it(monkeypatch):
251
+ prof = {**FOUNDER, "looking_for": "", "public_terms": {"raising": "$1.5M", "roles": "backend engineer"}}
252
+ script(monkeypatch, [{"role": "founder", "profile": prof, "reply": "ok"}])
253
+ sid = ob.start()["session_id"]
254
+ p = ob.send(sid, "raising 1.5M, hiring a backend engineer")["profile"]
255
+ assert p["looking_for"] == "Looking for a lead or investor for a $1.5M round and to hire: backend engineer."
256
+ assert not any(m.startswith("who or what") for m in ob.missing(ob.get_session(sid)))
257
+
258
+
259
+ def test_raise_and_wanted_roles_come_from_the_members_words(monkeypatch):
260
+ prof = {**FOUNDER, "public_terms": {}, "seeking": [], "looking_for": "A lead investor who knows fintech, and a backend engineer."}
261
+ script(monkeypatch, [{"role": "founder", "profile": prof, "reply": "ok"}])
262
+ sid = ob.start()["session_id"]
263
+ p = ob.send(sid, "im raising 1.5 million dollars and need a lead investor, also hiring a backend engineer")["profile"]
264
+ assert p["public_terms"]["raising"] == "$1.5M"
265
+ assert p["seeking"] == ["investor", "engineer"]
266
+ assert not ob._required(ob.get_session(sid))
267
+
268
+
269
+ def test_rewritten_hire_keeps_its_known_salary(monkeypatch):
270
+ script(monkeypatch, [
271
+ {"role": "founder", "profile": FOUNDER, "terms": {"hires": [{"kind": "engineer", "salary": {"high": 105000}}]},
272
+ "reply": "a"},
273
+ {"profile": {}, "terms": {"hires": [{"kind": "engineer", "title": "Senior backend engineer", "work": ["hybrid"]}]},
274
+ "reply": "b"}])
275
+ sid = ob.start()["session_id"]
276
+ ob.send(sid, "x")
277
+ ob.send(sid, "y")
278
+ h = ob.get_session(sid).terms.hires[0]
279
+ assert h.salary.high == 105000 and h.work == ["hybrid"] and h.title == "Senior backend engineer"
280
+
281
+
282
+ def test_investor_stage_cheque_and_focus_come_from_their_words(monkeypatch):
283
+ script(monkeypatch, [{"role": "investor", "profile": {"display_name": "Khalifa", "location": "Doha, Qatar",
284
+ "tags": ["healthtech", "GCC"]}, "reply": "ok"}])
285
+ sid = ob.start()["session_id"]
286
+ p = ob.send(sid, "Seed stage only. Cheques of 500k to 1 million dollars.")["profile"]
287
+ assert p["public_terms"]["stages"] == "seed" and p["public_terms"]["cheque_size"] == "$500k–$1M"
288
+ assert p["looking_for"] == "Founders at seed stage in healthtech, GCC."
289
+
290
+
291
+ def test_work_preference_is_not_a_dealbreaker(monkeypatch):
292
+ script(monkeypatch, [{"role": "engineer", "profile": {"display_name": "Khalid"}, "terms": {"work": ["hybrid"]}, "reply": "ok"}])
293
+ sid = ob.start()["session_id"]
294
+ ob.send(sid, "hybrid is fine")
295
+ assert ob.get_session(sid).terms.work == []
296
+
297
+
298
+ def test_work_requirement_is_a_dealbreaker(monkeypatch):
299
+ script(monkeypatch, [{"role": "engineer", "profile": {"display_name": "Sara"}, "terms": {"work": ["remote"]}, "reply": "ok"}])
300
+ sid = ob.start()["session_id"]
301
+ ob.send(sid, "remote only, I won't relocate")
302
+ assert ob.get_session(sid).terms.work == ["remote"]