human handoffs
This commit is contained in:
parent
79188e0f59
commit
36a55930f9
35 changed files with 4039 additions and 139 deletions
33
backend/tests/test_handoff_evaluator.py
Normal file
33
backend/tests/test_handoff_evaluator.py
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
from app.services.handoff_evaluator import evaluate_handoff
|
||||
|
||||
|
||||
def test_handoff_requires_all_selected_filters():
|
||||
email = {
|
||||
"subject": "Payment approval required",
|
||||
"snippet": "Please approve this invoice payment today.",
|
||||
"body_text": "We need your approval for payment of $4,500.",
|
||||
}
|
||||
result = evaluate_handoff(
|
||||
email=email,
|
||||
labels=["Important", "Finance"],
|
||||
email_types=["transactional"],
|
||||
instructions="Forward when approval is required.",
|
||||
)
|
||||
assert result["would_handoff"] is True
|
||||
assert result["label_matched"] is True
|
||||
assert result["type_matched"] is True
|
||||
|
||||
|
||||
def test_handoff_does_not_match_unrelated_instruction():
|
||||
email = {
|
||||
"subject": "Team lunch",
|
||||
"snippet": "Where should we eat tomorrow?",
|
||||
"body_text": "Let me know your preference.",
|
||||
}
|
||||
result = evaluate_handoff(
|
||||
email=email,
|
||||
labels=[],
|
||||
email_types=[],
|
||||
instructions="Forward when a refund requires approval.",
|
||||
)
|
||||
assert result["would_handoff"] is False
|
||||
124
backend/tests/test_human_handoff_service.py
Normal file
124
backend/tests/test_human_handoff_service.py
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import pytest
|
||||
from bson import ObjectId
|
||||
|
||||
from app.services.human_handoff_service import (
|
||||
MESSAGE_SCOPE_EXTERNAL,
|
||||
MESSAGE_SCOPE_HANDOFF,
|
||||
resolve_handoff_from_inbound,
|
||||
)
|
||||
|
||||
|
||||
class FakeCollection:
|
||||
def __init__(self, documents=None):
|
||||
self.documents = list(documents or [])
|
||||
|
||||
async def find_one(self, query):
|
||||
for document in self.documents:
|
||||
if all(document.get(key) == value for key, value in query.items()):
|
||||
return document
|
||||
if "$ne" in query.get("status", {}):
|
||||
if document.get("status") == query["status"]["$ne"]:
|
||||
continue
|
||||
for document in self.documents:
|
||||
matches = True
|
||||
for key, value in query.items():
|
||||
if key == "status" and isinstance(value, dict) and "$ne" in value:
|
||||
if document.get("status") == value["$ne"]:
|
||||
matches = False
|
||||
break
|
||||
elif document.get(key) != value:
|
||||
matches = False
|
||||
break
|
||||
if matches:
|
||||
return document
|
||||
return None
|
||||
|
||||
def find(self, query):
|
||||
class Cursor:
|
||||
def __init__(self, items):
|
||||
self.items = items
|
||||
|
||||
async def to_list(self, length):
|
||||
return self.items[:length]
|
||||
|
||||
filtered = [
|
||||
document
|
||||
for document in self.documents
|
||||
if all(document.get(key) == value for key, value in query.items())
|
||||
]
|
||||
return Cursor(filtered)
|
||||
|
||||
|
||||
class FakeDatabase:
|
||||
def __init__(self):
|
||||
self.human_handoff_rules = FakeCollection()
|
||||
self.messages = FakeCollection()
|
||||
self.mailbox_entries = FakeCollection()
|
||||
self.human_handoffs = FakeCollection()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_handoff_requires_owner_and_thread_context():
|
||||
database = FakeDatabase()
|
||||
handoff_thread_id = ObjectId()
|
||||
message_id = ObjectId()
|
||||
database.human_handoff_rules.documents = [
|
||||
{
|
||||
"account_id": "acct-1",
|
||||
"owner_email": "owner@gmail.com",
|
||||
"active": True,
|
||||
}
|
||||
]
|
||||
database.messages.documents = [
|
||||
{
|
||||
"_id": message_id,
|
||||
"message_id": "<handoff-1@gentic.email>",
|
||||
"message_scope": MESSAGE_SCOPE_HANDOFF,
|
||||
}
|
||||
]
|
||||
database.mailbox_entries.documents = [
|
||||
{
|
||||
"account_id": "acct-1",
|
||||
"message_id": message_id,
|
||||
"thread_id": handoff_thread_id,
|
||||
"message_scope": MESSAGE_SCOPE_HANDOFF,
|
||||
}
|
||||
]
|
||||
database.human_handoffs.documents = [
|
||||
{
|
||||
"_id": ObjectId(),
|
||||
"account_id": "acct-1",
|
||||
"handoff_thread_id": handoff_thread_id,
|
||||
"human_email": "owner@gmail.com",
|
||||
"status": "WAITING_FOR_HUMAN",
|
||||
}
|
||||
]
|
||||
|
||||
handoff = await resolve_handoff_from_inbound(
|
||||
database,
|
||||
account_id="acct-1",
|
||||
agent_email="support@gentic.email",
|
||||
sender_address="owner@gmail.com",
|
||||
in_reply_to="<handoff-1@gentic.email>",
|
||||
references=[],
|
||||
)
|
||||
assert handoff is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_handoff_rejects_unknown_sender():
|
||||
database = FakeDatabase()
|
||||
handoff = await resolve_handoff_from_inbound(
|
||||
database,
|
||||
account_id="acct-1",
|
||||
agent_email="support@gentic.email",
|
||||
sender_address="stranger@example.com",
|
||||
in_reply_to="<handoff-1@gentic.email>",
|
||||
references=[],
|
||||
)
|
||||
assert handoff is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_external_scope_constant_isolated_from_handoff():
|
||||
assert MESSAGE_SCOPE_EXTERNAL != MESSAGE_SCOPE_HANDOFF
|
||||
30
backend/tests/test_reply_recipients.py
Normal file
30
backend/tests/test_reply_recipients.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
from app.services.reply_recipients import reply_recipients
|
||||
|
||||
|
||||
def test_reply_prefers_reply_to_header():
|
||||
message = {
|
||||
"sender": {"name": "Sender", "address": "sender@example.com"},
|
||||
"reply_to": [{"name": "Support", "address": "support@example.com"}],
|
||||
"to": [{"name": "Me", "address": "me@gentic.email"}],
|
||||
"cc": [],
|
||||
}
|
||||
to, cc = reply_recipients(message=message, viewer_email="me@gentic.email", reply_all=False)
|
||||
assert to == ["support@example.com"]
|
||||
assert cc == []
|
||||
|
||||
|
||||
def test_reply_all_excludes_viewer_and_deduplicates():
|
||||
message = {
|
||||
"sender": {"name": "Sender", "address": "sender@example.com"},
|
||||
"to": [
|
||||
{"name": "Me", "address": "me@gentic.email"},
|
||||
{"name": "Other", "address": "other@example.com"},
|
||||
],
|
||||
"cc": [
|
||||
{"name": "Sender", "address": "sender@example.com"},
|
||||
{"name": "Third", "address": "third@example.com"},
|
||||
],
|
||||
}
|
||||
to, cc = reply_recipients(message=message, viewer_email="me@gentic.email", reply_all=True)
|
||||
assert to == ["sender@example.com"]
|
||||
assert set(cc) == {"other@example.com", "third@example.com"}
|
||||
129
backend/tests/test_thread_resolver.py
Normal file
129
backend/tests/test_thread_resolver.py
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
from datetime import datetime, timezone
|
||||
from bson import ObjectId
|
||||
|
||||
from app.services.message_ids import build_references_header, normalize_message_id, parse_message_id_list
|
||||
from app.services.subject_normalize import normalize_subject
|
||||
from app.services.thread_resolver import KnownMessage, resolve_thread_from_known
|
||||
|
||||
|
||||
def _known(
|
||||
internet_id: str,
|
||||
thread_id: ObjectId,
|
||||
account_id: str = "acct-1",
|
||||
participants: frozenset[str] | None = None,
|
||||
) -> KnownMessage:
|
||||
now = datetime.now(timezone.utc)
|
||||
return KnownMessage(
|
||||
id=ObjectId(),
|
||||
thread_id=thread_id,
|
||||
internet_message_id=normalize_message_id(internet_id) or "",
|
||||
account_id=account_id,
|
||||
sent_at=now,
|
||||
received_at=now,
|
||||
created_at=now,
|
||||
participants=participants or frozenset({"sender@example.com"}),
|
||||
normalized_subject=normalize_subject("Project update"),
|
||||
)
|
||||
|
||||
|
||||
def test_new_message_creates_new_thread():
|
||||
resolution = resolve_thread_from_known(
|
||||
account_id="acct-1",
|
||||
in_reply_to=None,
|
||||
references=[],
|
||||
subject="Project update",
|
||||
participants={"sender@example.com"},
|
||||
known_by_internet_id={},
|
||||
)
|
||||
assert resolution.thread_id is None
|
||||
assert resolution.thread_resolution_method == "new_thread"
|
||||
assert resolution.thread_relation == "new_thread"
|
||||
|
||||
|
||||
def test_direct_reply_matches_in_reply_to():
|
||||
thread_id = ObjectId()
|
||||
parent = _known("<a@gentic.email>", thread_id)
|
||||
known = {parent.internet_message_id: parent}
|
||||
resolution = resolve_thread_from_known(
|
||||
account_id="acct-1",
|
||||
in_reply_to="<a@gentic.email>",
|
||||
references=["<a@gentic.email>"],
|
||||
subject="Re: Project update",
|
||||
participants={"rahul@example.com"},
|
||||
known_by_internet_id=known,
|
||||
)
|
||||
assert resolution.thread_id == thread_id
|
||||
assert resolution.thread_resolution_method == "in_reply_to"
|
||||
assert resolution.parent_message_id == parent.id
|
||||
|
||||
|
||||
def test_references_fallback_when_parent_missing():
|
||||
thread_id = ObjectId()
|
||||
parent = _known("<a@gentic.email>", thread_id)
|
||||
known = {parent.internet_message_id: parent}
|
||||
resolution = resolve_thread_from_known(
|
||||
account_id="acct-1",
|
||||
in_reply_to="<b@example.com>",
|
||||
references=["<a@gentic.email>", "<b@example.com>"],
|
||||
subject="Re: Project update",
|
||||
participants={"rahul@example.com"},
|
||||
known_by_internet_id=known,
|
||||
)
|
||||
assert resolution.thread_id == thread_id
|
||||
assert resolution.thread_resolution_method == "references"
|
||||
|
||||
|
||||
def test_fake_re_subject_does_not_thread():
|
||||
thread_id = ObjectId()
|
||||
parent = _known("<a@gentic.email>", thread_id)
|
||||
known = {parent.internet_message_id: parent}
|
||||
resolution = resolve_thread_from_known(
|
||||
account_id="acct-1",
|
||||
in_reply_to=None,
|
||||
references=[],
|
||||
subject="Re: Project update",
|
||||
participants={"rahul@example.com"},
|
||||
known_by_internet_id=known,
|
||||
)
|
||||
assert resolution.thread_id is None
|
||||
assert resolution.thread_resolution_method == "new_thread"
|
||||
|
||||
|
||||
def test_changed_subject_with_valid_reply_headers():
|
||||
thread_id = ObjectId()
|
||||
parent = _known("<a@gentic.email>", thread_id)
|
||||
known = {parent.internet_message_id: parent}
|
||||
resolution = resolve_thread_from_known(
|
||||
account_id="acct-1",
|
||||
in_reply_to="<a@gentic.email>",
|
||||
references=[],
|
||||
subject="Completely changed subject",
|
||||
participants={"rahul@example.com"},
|
||||
known_by_internet_id=known,
|
||||
)
|
||||
assert resolution.thread_id == thread_id
|
||||
|
||||
|
||||
def test_cross_tenant_message_is_ignored():
|
||||
thread_id = ObjectId()
|
||||
parent = _known("<a@gentic.email>", thread_id, account_id="other-acct")
|
||||
known = {parent.internet_message_id: parent}
|
||||
resolution = resolve_thread_from_known(
|
||||
account_id="acct-1",
|
||||
in_reply_to="<a@gentic.email>",
|
||||
references=[],
|
||||
subject="Re: Project update",
|
||||
participants={"rahul@example.com"},
|
||||
known_by_internet_id=known,
|
||||
)
|
||||
assert resolution.thread_id is None
|
||||
|
||||
|
||||
def test_build_references_header_appends_parent():
|
||||
header = build_references_header(["<a@gentic.email>"], "<b@example.com>")
|
||||
assert header == "<a@gentic.email> <b@example.com>"
|
||||
|
||||
|
||||
def test_parse_message_id_list_handles_folded_references():
|
||||
values = parse_message_id_list("<a@gentic.email>\r\n <b@example.com>")
|
||||
assert values == ["<a@gentic.email>", "<b@example.com>"]
|
||||
Loading…
Add table
Add a link
Reference in a new issue