Spaces:
Sleeping
Sleeping
Intake: never re-ask for deal-breakers once given
Browse files- core/intake.py +39 -12
- 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.
|
| 152 |
-
|
|
|
|
| 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 |
-
|
| 577 |
-
#
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 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}}, "
|
| 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, "
|
| 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}}, "
|
| 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"]
|