Matrix intake: fuzzy investor matching + conversational in-thread edits (v0.1.0:86)
Close the two locked post-deploy enhancements for the Matrix intake bot.
Fuzzy matching (server-side, ships in the s9pk): new find_intake_candidates in
server.py returns ranked deterministic near-matches (difflib name similarity +
token-set Jaccard, legal-suffix-aware, + email Levenshtein <= 2); GET
/api/intake/match now returns {match, candidates}. The bot surfaces a numbered
shortlist so a near-duplicate (Charlie/Charles, Acme Capital vs Acme Capital LLC,
a one-char email typo) is confirmed by a human instead of silently creating a
second investor. Exact match still auto-attaches; fuzzy candidates are never
auto-attached. The optional LLM-judge re-rank is deferred.
Conversational edits (bot-side, ships on the Spark): any in-thread reply that
isn't yes/no/edit field=value is treated as a natural-language revision and
re-run through local Qwen (parse.revise). Email integrity is preserved -- a
changed address must literally appear in the instruction; the model's email
field is structurally unreachable. No-op revisions re-prompt.
Docs/current-state brought current; 27/27 backend tests green.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
"""Tests for the proposal store + approval state machine (pure logic, no network)."""
|
||||
import copy
|
||||
import os
|
||||
import sys
|
||||
|
||||
@@ -105,6 +106,79 @@ def test_summary_line_new_vs_note():
|
||||
assert "thread" in new_line.lower()
|
||||
|
||||
|
||||
# --- fuzzy-match disambiguation + conversational-revision helpers ---
|
||||
|
||||
DISAMBIG = {"intent": "new_investor", "investor_name": "Charles Brown",
|
||||
"contact_name": "Charles Brown", "contact_email": None, "contact_title": None,
|
||||
"note": "met at conf", "_stage": "disambiguate",
|
||||
"_candidates": [{"id": "rowCharlie", "name": "Charlie Brown", "score": 0.92, "matched_on": "name"},
|
||||
{"id": "rowBeta", "name": "Beta Capital LLC", "score": 0.7, "matched_on": "name"}]}
|
||||
|
||||
|
||||
def test_interpret_disambiguation_pick_number():
|
||||
assert proposals.interpret_disambiguation("1", 2) == ("pick", 0)
|
||||
assert proposals.interpret_disambiguation(" 2 ", 2) == ("pick", 1)
|
||||
assert proposals.interpret_disambiguation("#1", 2) == ("pick", 0)
|
||||
|
||||
|
||||
def test_interpret_disambiguation_out_of_range_is_unknown():
|
||||
assert proposals.interpret_disambiguation("3", 2)[0] == "unknown"
|
||||
assert proposals.interpret_disambiguation("0", 2)[0] == "unknown"
|
||||
|
||||
|
||||
def test_interpret_disambiguation_new_and_no():
|
||||
assert proposals.interpret_disambiguation("new", 2)[0] == "new"
|
||||
assert proposals.interpret_disambiguation("none of these", 2)[0] == "new"
|
||||
assert proposals.interpret_disambiguation("no", 2)[0] == "reject"
|
||||
|
||||
|
||||
def test_interpret_disambiguation_freeform_is_unknown():
|
||||
# a free-form reply in the shortlist stage isn't guessed at — re-prompt instead
|
||||
assert proposals.interpret_disambiguation("the first one", 2)[0] == "unknown"
|
||||
|
||||
|
||||
def test_attach_to_candidate_promotes_to_meeting_note():
|
||||
out = proposals.attach_to_candidate(DISAMBIG, DISAMBIG["_candidates"][0])
|
||||
assert out["_match_id"] == "rowCharlie"
|
||||
assert out["intent"] == "meeting_note"
|
||||
assert out["_stage"] == "approval"
|
||||
assert out["investor_name"] == "Charlie Brown" # canonical existing name shown
|
||||
assert "_candidates" not in out
|
||||
assert "_candidates" in DISAMBIG # original untouched
|
||||
|
||||
|
||||
def test_promote_to_new_clears_shortlist_and_match():
|
||||
out = proposals.promote_to_new(dict(DISAMBIG, _match_id="rowX"))
|
||||
assert out["_stage"] == "approval"
|
||||
assert "_candidates" not in out
|
||||
assert "_match_id" not in out
|
||||
|
||||
|
||||
def test_disambiguation_pick_then_yes_reaches_approval():
|
||||
# Closes the seam between the two state machines: a shortlist pick promotes the proposal to
|
||||
# approval stage carrying the chosen investor's row id, and a following 'yes' classifies as
|
||||
# approve (the normal commit path) — so pick -> yes lands the note on the existing investor.
|
||||
picked = proposals.attach_to_candidate(copy.deepcopy(DISAMBIG), DISAMBIG["_candidates"][0])
|
||||
assert picked["_stage"] == "approval"
|
||||
assert picked["_match_id"] == "rowCharlie"
|
||||
assert picked["intent"] == "meeting_note"
|
||||
assert proposals.interpret_reply("yes") == ("approve", None)
|
||||
|
||||
|
||||
def test_render_disambiguation_lists_numbered_candidates():
|
||||
text = proposals.render_disambiguation(DISAMBIG)
|
||||
assert "Charlie Brown" in text and "Beta Capital LLC" in text
|
||||
assert "1." in text and "2." in text
|
||||
assert "new" in text.lower() and "no" in text.lower()
|
||||
|
||||
|
||||
def test_same_fields_ignores_control_keys():
|
||||
a = dict(SAMPLE)
|
||||
assert proposals.same_fields(a, dict(a))
|
||||
assert not proposals.same_fields(a, dict(a, note="different"))
|
||||
assert proposals.same_fields(a, dict(a, _match_id="r1", _stage="approval"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
|
||||
for fn in fns:
|
||||
|
||||
Reference in New Issue
Block a user