human handoffs

This commit is contained in:
debu-2 2026-10-06 01:51:23 +05:30
parent 79188e0f59
commit 36a55930f9
35 changed files with 4039 additions and 139 deletions

View 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

View 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

View 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"}

View 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>"]