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:
@@ -46,30 +46,49 @@ async def main():
|
||||
try:
|
||||
proposal = await asyncio.to_thread(parse.parse_message, text)
|
||||
except Exception as exc: # Spark/Qwen unreachable or bad response
|
||||
await say(room_id, f"⚠️ couldn't reach the local parser: {exc}", root)
|
||||
await say(room_id, f"⚠️ couldn't reach the local parser: {str(exc)[:200]}", root)
|
||||
return
|
||||
if proposal["intent"] == "unclear":
|
||||
await say(room_id, UNCLEAR_HELP, root)
|
||||
return
|
||||
# Confirm new-vs-existing against the CRM matcher (read-only). Degrade gracefully if
|
||||
# the CRM is unreachable — still propose, just without the "looks like existing" hint.
|
||||
hint = ""
|
||||
# Resolve new-vs-existing against the CRM matcher (read-only). Degrade gracefully if the
|
||||
# CRM is unreachable — still propose as new, just without match/candidate hints.
|
||||
match, candidates = None, []
|
||||
try:
|
||||
match = await asyncio.to_thread(crm_client.match, proposal)
|
||||
if match:
|
||||
proposal["intent"] = "meeting_note"
|
||||
proposal["_match_id"] = match["id"]
|
||||
hint = f"\n\n🔎 Looks like an existing investor: **{match['name']}** — this will append a note to them."
|
||||
res = await asyncio.to_thread(crm_client.match, proposal)
|
||||
match = res.get("match")
|
||||
candidates = res.get("candidates") or []
|
||||
except Exception:
|
||||
pass
|
||||
if match:
|
||||
# Confident exact match → auto-attach the note to that investor (no disambiguation).
|
||||
proposal["intent"] = "meeting_note"
|
||||
proposal["_match_id"] = match["id"]
|
||||
proposal["_stage"] = "approval"
|
||||
store.put(root, proposal)
|
||||
hint = (f"\n\n🔎 Looks like an existing investor: **{match['name']}** — "
|
||||
"this will append a note to them.")
|
||||
await say(room_id, proposals.render(proposal) + hint, root)
|
||||
await nudge(room_id, proposals.summary_line(proposal), root)
|
||||
return
|
||||
if candidates:
|
||||
# No exact match but near-misses exist → make the human pick one or confirm "new",
|
||||
# so a typo'd/near-duplicate name can't silently create a second investor.
|
||||
proposal["_stage"] = "disambiguate"
|
||||
proposal["_candidates"] = candidates
|
||||
store.put(root, proposal)
|
||||
await say(room_id, proposals.render_disambiguation(proposal), root)
|
||||
await nudge(room_id, proposals.disambiguation_nudge(proposal), root)
|
||||
return
|
||||
# Genuinely new — straight to the new-investor approval card.
|
||||
proposal["_stage"] = "approval"
|
||||
store.put(root, proposal)
|
||||
await say(room_id, proposals.render(proposal) + hint, root)
|
||||
await say(room_id, proposals.render(proposal), root)
|
||||
# Also drop a brief, un-threaded reply in the main timeline so the proposal isn't
|
||||
# easy to miss inside a thread (the full card + yes/edit/no stay in the thread).
|
||||
await nudge(room_id, proposals.summary_line(proposal), root)
|
||||
|
||||
async def handle_reply(room_id, root, text):
|
||||
action, payload = proposals.interpret_reply(text)
|
||||
# Claim the proposal synchronously — BEFORE any await — so a second reply that
|
||||
# arrives while a commit is in flight can't double-process it. asyncio is
|
||||
# cooperative: nothing else runs between here and the first await below, so the
|
||||
@@ -77,6 +96,11 @@ async def main():
|
||||
proposal = store.pop(root)
|
||||
if proposal is None:
|
||||
return
|
||||
if proposal.get("_stage") == "disambiguate":
|
||||
await handle_disambiguation(room_id, root, text, proposal)
|
||||
return
|
||||
|
||||
action, payload = proposals.interpret_reply(text)
|
||||
if action == "approve":
|
||||
try:
|
||||
summary = await asyncio.to_thread(crm_client.commit, proposal)
|
||||
@@ -92,9 +116,43 @@ async def main():
|
||||
proposal = proposals.apply_edit(proposal, field, value)
|
||||
store.put(root, proposal) # keep it pending (edited) for the next reply
|
||||
await say(room_id, "✏️ Updated:\n\n" + proposals.render(proposal), root)
|
||||
else: # unrecognized reply — leave the proposal pending
|
||||
else:
|
||||
# Not yes/no/edit-grammar → treat it as a natural-language revision instruction and
|
||||
# re-run it through local Qwen (no Claude, no scrub). The human still approves the
|
||||
# revised card, so the draft→approve gate holds.
|
||||
try:
|
||||
revised = await asyncio.to_thread(parse.revise, proposal, text)
|
||||
except Exception as exc:
|
||||
store.put(root, proposal)
|
||||
await say(room_id, f"⚠️ couldn't apply that change ({str(exc)[:200]}).\n\nReply **yes** "
|
||||
"to commit, **no** to discard, **edit field=value**, or rephrase.", root)
|
||||
return
|
||||
if proposals.same_fields(proposal, revised):
|
||||
store.put(root, proposal)
|
||||
await say(room_id, "I didn't catch a change there. Reply **yes** to commit, **no** "
|
||||
"to discard, **edit field=value**, or tell me what to change.", root)
|
||||
return
|
||||
store.put(root, revised)
|
||||
await say(room_id, "✏️ Updated:\n\n" + proposals.render(revised), root)
|
||||
|
||||
async def handle_disambiguation(room_id, root, text, proposal):
|
||||
cands = proposal.get("_candidates") or []
|
||||
action, payload = proposals.interpret_disambiguation(text, len(cands))
|
||||
if action == "pick":
|
||||
updated = proposals.attach_to_candidate(proposal, cands[payload])
|
||||
store.put(root, updated)
|
||||
await say(room_id, "✏️ Will log against the existing investor:\n\n"
|
||||
+ proposals.render(updated), root)
|
||||
elif action == "new":
|
||||
updated = proposals.promote_to_new(proposal)
|
||||
store.put(root, updated)
|
||||
await say(room_id, "➕ OK — adding as a new investor:\n\n"
|
||||
+ proposals.render(updated), root)
|
||||
elif action == "reject":
|
||||
await say(room_id, "🗑️ Discarded — nothing written.", root)
|
||||
else: # unrecognized — re-show the shortlist
|
||||
store.put(root, proposal)
|
||||
await say(room_id, "Reply **yes** to commit, **edit field=value**, or **no**.", root)
|
||||
await say(room_id, "I didn't catch that.\n\n" + proposals.render_disambiguation(proposal), root)
|
||||
|
||||
async def on_message(room: MatrixRoom, event: RoomMessageText):
|
||||
if event.sender == mx["user_id"]:
|
||||
|
||||
Reference in New Issue
Block a user