Spaces:
Sleeping
Sleeping
Phase 4: CRMAPI with 3 drifts + scenarios E1/E2/E3
Browse files- scenarios.py +123 -3
- tests/test_crm.py +142 -0
- tests/test_scenarios.py +65 -0
- tools/base.py +7 -0
- tools/crm.py +212 -3
scenarios.py
CHANGED
|
@@ -1,4 +1,124 @@
|
|
| 1 |
-
"""Scenario definitions (E1-E3 easy
|
|
|
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Scenario definitions (E1-E3 easy). Medium/hard added in Phase 11."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
|
| 4 |
+
from models import DriftEvent
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
SCENARIOS: dict[str, dict] = {
|
| 8 |
+
"E1_onboard_new_hire": {
|
| 9 |
+
"difficulty": "easy",
|
| 10 |
+
"max_steps": 8,
|
| 11 |
+
"token_budget": 4000,
|
| 12 |
+
"task_description": (
|
| 13 |
+
"A new employee named Priya Sharma (priya@company.com) starts on "
|
| 14 |
+
"Monday April 27. Send her a welcome email at priya@company.com "
|
| 15 |
+
"with subject containing 'welcome', then create a calendar event "
|
| 16 |
+
"titled 'New Hire Orientation' on Monday April 27 at 10:00 AM for "
|
| 17 |
+
"1 hour, inviting both Priya and her manager alex@company.com."
|
| 18 |
+
),
|
| 19 |
+
"success_criteria": [
|
| 20 |
+
"Welcome email sent to priya@company.com with 'welcome' in subject",
|
| 21 |
+
"Calendar event created for Monday April 27 10am with both Priya "
|
| 22 |
+
"and Alex attending",
|
| 23 |
+
],
|
| 24 |
+
"seed_data": {
|
| 25 |
+
"mail": {"messages": []},
|
| 26 |
+
"calendar": {"events": []},
|
| 27 |
+
},
|
| 28 |
+
"drift_plan": [
|
| 29 |
+
DriftEvent(
|
| 30 |
+
tool="calendar",
|
| 31 |
+
endpoint="create_event",
|
| 32 |
+
kind="field_rename",
|
| 33 |
+
fires_at_step=3,
|
| 34 |
+
details={"from": "attendees", "to": "participants"},
|
| 35 |
+
),
|
| 36 |
+
],
|
| 37 |
+
"ground_truth_final_state": {
|
| 38 |
+
"mail.sent_count": 1,
|
| 39 |
+
"mail.last_sent_to": "priya@company.com",
|
| 40 |
+
"mail.last_subject_contains_welcome": True,
|
| 41 |
+
"calendar.events_count": 1,
|
| 42 |
+
"calendar.last_event_has_both_attendees": True,
|
| 43 |
+
},
|
| 44 |
+
"required_tools": ["mail", "calendar"],
|
| 45 |
+
},
|
| 46 |
+
|
| 47 |
+
"E2_meeting_invite_blast": {
|
| 48 |
+
"difficulty": "easy",
|
| 49 |
+
"max_steps": 6,
|
| 50 |
+
"token_budget": 4000,
|
| 51 |
+
"task_description": (
|
| 52 |
+
"Send calendar invite emails to three team members for an "
|
| 53 |
+
"all-hands meeting at 3:00 PM Friday. Recipients: "
|
| 54 |
+
"alex@company.com, jordan@company.com, sam@company.com. "
|
| 55 |
+
"Subject: 'All-Hands: Friday 3pm'. "
|
| 56 |
+
"Body should mention the time and agenda."
|
| 57 |
+
),
|
| 58 |
+
"success_criteria": [
|
| 59 |
+
"Three emails sent to the three listed recipients",
|
| 60 |
+
"All three emails have subject containing 'All-Hands'",
|
| 61 |
+
],
|
| 62 |
+
"seed_data": {
|
| 63 |
+
"mail": {"messages": []},
|
| 64 |
+
},
|
| 65 |
+
"drift_plan": [
|
| 66 |
+
DriftEvent(
|
| 67 |
+
tool="mail",
|
| 68 |
+
endpoint="send_message",
|
| 69 |
+
kind="endpoint_deprecation",
|
| 70 |
+
fires_at_step=1,
|
| 71 |
+
details={"replacement": "messages.send"},
|
| 72 |
+
),
|
| 73 |
+
],
|
| 74 |
+
"ground_truth_final_state": {
|
| 75 |
+
"mail.sent_count": 3,
|
| 76 |
+
"mail.sent_to_all_three_recipients": True,
|
| 77 |
+
},
|
| 78 |
+
"required_tools": ["mail"],
|
| 79 |
+
},
|
| 80 |
+
|
| 81 |
+
"E3_customer_lookup": {
|
| 82 |
+
"difficulty": "easy",
|
| 83 |
+
"max_steps": 8,
|
| 84 |
+
"token_budget": 4000,
|
| 85 |
+
"task_description": (
|
| 86 |
+
"A customer emailed support asking about their account: "
|
| 87 |
+
"bob@customer.com. Look them up in the CRM by email, retrieve "
|
| 88 |
+
"their full contact record, and update their status field to "
|
| 89 |
+
"'support_in_progress'. Report back their company name in your "
|
| 90 |
+
"final summary."
|
| 91 |
+
),
|
| 92 |
+
"success_criteria": [
|
| 93 |
+
"Customer bob@customer.com found in CRM",
|
| 94 |
+
"Contact status updated to 'support_in_progress'",
|
| 95 |
+
"Completion summary mentions the customer's company",
|
| 96 |
+
],
|
| 97 |
+
"seed_data": {
|
| 98 |
+
"crm": {
|
| 99 |
+
"contacts": [
|
| 100 |
+
{"contact_id": "c_1", "customer_email": "alice@customer.com",
|
| 101 |
+
"name": "Alice Nguyen", "company": "Acme Corp", "status": "active"},
|
| 102 |
+
{"contact_id": "c_2", "customer_email": "bob@customer.com",
|
| 103 |
+
"name": "Bob Taylor", "company": "Globex Industries", "status": "active"},
|
| 104 |
+
{"contact_id": "c_3", "customer_email": "carol@customer.com",
|
| 105 |
+
"name": "Carol Davis", "company": "Initech", "status": "inactive"},
|
| 106 |
+
],
|
| 107 |
+
},
|
| 108 |
+
},
|
| 109 |
+
"drift_plan": [
|
| 110 |
+
DriftEvent(
|
| 111 |
+
tool="crm",
|
| 112 |
+
endpoint=None,
|
| 113 |
+
kind="field_rename",
|
| 114 |
+
fires_at_step=2,
|
| 115 |
+
details={"from": "customer_email", "to": "email_address"},
|
| 116 |
+
),
|
| 117 |
+
],
|
| 118 |
+
"ground_truth_final_state": {
|
| 119 |
+
"crm.contact_c_2_status": "support_in_progress",
|
| 120 |
+
"complete_summary_mentions_company": True,
|
| 121 |
+
},
|
| 122 |
+
"required_tools": ["crm"],
|
| 123 |
+
},
|
| 124 |
+
}
|
tests/test_crm.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CRMAPI acceptance tests — Phase 4."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import pytest
|
| 5 |
+
|
| 6 |
+
from models import DriftEvent
|
| 7 |
+
from tools.crm import CRMAPI
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def _fresh_crm() -> CRMAPI:
|
| 11 |
+
return CRMAPI(seed_data={
|
| 12 |
+
"contacts": [
|
| 13 |
+
{"contact_id": "c_1", "customer_email": "alice@customer.com",
|
| 14 |
+
"name": "Alice Nguyen", "company": "Acme Corp", "status": "active"},
|
| 15 |
+
{"contact_id": "c_2", "customer_email": "bob@customer.com",
|
| 16 |
+
"name": "Bob Taylor", "company": "Globex Industries", "status": "active"},
|
| 17 |
+
{"contact_id": "c_3", "customer_email": "carol@customer.com",
|
| 18 |
+
"name": "Carol Davis", "company": "Initech", "status": "inactive"},
|
| 19 |
+
]
|
| 20 |
+
})
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def test_baseline_search_contacts() -> None:
|
| 24 |
+
crm = _fresh_crm()
|
| 25 |
+
by_email = crm.call("search_contacts", {"customer_email": "bob@customer.com"})
|
| 26 |
+
assert by_email.ok is True
|
| 27 |
+
assert by_email.status == 200
|
| 28 |
+
assert by_email.body is not None
|
| 29 |
+
assert by_email.body["total"] == 1
|
| 30 |
+
assert by_email.body["contacts"][0]["name"] == "Bob Taylor"
|
| 31 |
+
assert by_email.body["contacts"][0]["customer_email"] == "bob@customer.com"
|
| 32 |
+
|
| 33 |
+
by_name = crm.call("search_contacts", {"name": "Alice"})
|
| 34 |
+
assert by_name.ok is True
|
| 35 |
+
assert by_name.body["total"] == 1
|
| 36 |
+
assert by_name.body["contacts"][0]["contact_id"] == "c_1"
|
| 37 |
+
|
| 38 |
+
all_contacts = crm.call("search_contacts", {})
|
| 39 |
+
assert all_contacts.ok is True
|
| 40 |
+
assert all_contacts.body["total"] == 3
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def test_baseline_create_and_get() -> None:
|
| 44 |
+
crm = _fresh_crm()
|
| 45 |
+
created = crm.call("create_contact", {
|
| 46 |
+
"customer_email": "dan@customer.com",
|
| 47 |
+
"name": "Dan Ellis",
|
| 48 |
+
"company": "DEMO Corp",
|
| 49 |
+
})
|
| 50 |
+
assert created.ok is True
|
| 51 |
+
assert created.status == 200
|
| 52 |
+
cid = created.body["contact_id"]
|
| 53 |
+
assert cid.startswith("c_")
|
| 54 |
+
|
| 55 |
+
got = crm.call("get_contact", {"contact_id": cid})
|
| 56 |
+
assert got.ok is True
|
| 57 |
+
assert got.status == 200
|
| 58 |
+
assert got.body is not None
|
| 59 |
+
assert got.body["name"] == "Dan Ellis"
|
| 60 |
+
assert got.body["customer_email"] == "dan@customer.com"
|
| 61 |
+
assert got.body["company"] == "DEMO Corp"
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def test_drift_field_rename_crm_tool_wide() -> None:
|
| 65 |
+
crm = _fresh_crm()
|
| 66 |
+
event = DriftEvent(
|
| 67 |
+
tool="crm", endpoint=None, kind="field_rename",
|
| 68 |
+
fires_at_step=1, details={},
|
| 69 |
+
)
|
| 70 |
+
crm.apply_drift(event)
|
| 71 |
+
|
| 72 |
+
for ep in ("search_contacts", "get_contact", "create_contact", "update_contact"):
|
| 73 |
+
schema = crm.get_schema(ep)
|
| 74 |
+
assert "customer_email" not in schema["params"]
|
| 75 |
+
assert "customer_email" not in schema["required"]
|
| 76 |
+
assert "customer_email" not in schema["response_shape"]
|
| 77 |
+
|
| 78 |
+
assert "email_address" in crm.get_schema("search_contacts")["params"]
|
| 79 |
+
assert "email_address" in crm.get_schema("create_contact")["required"]
|
| 80 |
+
assert "email_address" in crm.get_schema("get_contact")["response_shape"]
|
| 81 |
+
|
| 82 |
+
bad = crm.call("search_contacts", {"customer_email": "bob@customer.com"})
|
| 83 |
+
assert bad.ok is False
|
| 84 |
+
assert bad.status == 400
|
| 85 |
+
|
| 86 |
+
ok = crm.call("search_contacts", {"email_address": "bob@customer.com"})
|
| 87 |
+
assert ok.ok is True
|
| 88 |
+
assert ok.status == 200
|
| 89 |
+
assert ok.body is not None
|
| 90 |
+
assert ok.body["total"] == 1
|
| 91 |
+
contact = ok.body["contacts"][0]
|
| 92 |
+
assert "email_address" in contact
|
| 93 |
+
assert "customer_email" not in contact
|
| 94 |
+
assert contact["email_address"] == "bob@customer.com"
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def test_drift_rate_limit_tightening() -> None:
|
| 98 |
+
crm = _fresh_crm()
|
| 99 |
+
event = DriftEvent(
|
| 100 |
+
tool="crm", endpoint=None, kind="rate_limit_tightening",
|
| 101 |
+
fires_at_step=1, details={},
|
| 102 |
+
)
|
| 103 |
+
crm.apply_drift(event)
|
| 104 |
+
|
| 105 |
+
r1 = crm.call("search_contacts", {})
|
| 106 |
+
assert r1.ok is True
|
| 107 |
+
|
| 108 |
+
r2 = crm.call("search_contacts", {})
|
| 109 |
+
assert r2.ok is True
|
| 110 |
+
|
| 111 |
+
r3 = crm.call("search_contacts", {})
|
| 112 |
+
assert r3.ok is False
|
| 113 |
+
assert r3.status == 429
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def test_drift_endpoint_deprecation_update_contact() -> None:
|
| 117 |
+
crm = _fresh_crm()
|
| 118 |
+
event = DriftEvent(
|
| 119 |
+
tool="crm", endpoint="update_contact", kind="endpoint_deprecation",
|
| 120 |
+
fires_at_step=1, details={"replacement": "contacts.patch"},
|
| 121 |
+
)
|
| 122 |
+
crm.apply_drift(event)
|
| 123 |
+
|
| 124 |
+
old = crm.call("update_contact", {"contact_id": "c_1", "status": "updated"})
|
| 125 |
+
assert old.ok is False
|
| 126 |
+
assert old.status == 410
|
| 127 |
+
|
| 128 |
+
new = crm.call("contacts.patch", {"contact_id": "c_1", "status": "updated"})
|
| 129 |
+
assert new.ok is True
|
| 130 |
+
assert new.status == 200
|
| 131 |
+
assert new.body is not None
|
| 132 |
+
assert new.body["status"] == "updated"
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def test_crm_unknown_drift_raises() -> None:
|
| 136 |
+
crm = _fresh_crm()
|
| 137 |
+
event = DriftEvent(
|
| 138 |
+
tool="crm", endpoint="search_contacts", kind="tool_removal",
|
| 139 |
+
fires_at_step=1, details={},
|
| 140 |
+
)
|
| 141 |
+
with pytest.raises(ValueError):
|
| 142 |
+
crm.apply_drift(event)
|
tests/test_scenarios.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Scenario structure tests — Phase 4."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from models import DriftEvent
|
| 5 |
+
from scenarios import SCENARIOS
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
REQUIRED_KEYS = {
|
| 9 |
+
"difficulty",
|
| 10 |
+
"max_steps",
|
| 11 |
+
"token_budget",
|
| 12 |
+
"task_description",
|
| 13 |
+
"success_criteria",
|
| 14 |
+
"seed_data",
|
| 15 |
+
"drift_plan",
|
| 16 |
+
"ground_truth_final_state",
|
| 17 |
+
"required_tools",
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def test_all_three_scenarios_present() -> None:
|
| 22 |
+
assert set(SCENARIOS.keys()) == {
|
| 23 |
+
"E1_onboard_new_hire",
|
| 24 |
+
"E2_meeting_invite_blast",
|
| 25 |
+
"E3_customer_lookup",
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def test_each_scenario_has_required_fields() -> None:
|
| 30 |
+
for name, sc in SCENARIOS.items():
|
| 31 |
+
missing = REQUIRED_KEYS - set(sc.keys())
|
| 32 |
+
assert not missing, f"{name} missing keys: {missing}"
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def test_drift_plans_contain_valid_events() -> None:
|
| 36 |
+
valid_tools = {"mail", "calendar", "crm", "chat", "docs"}
|
| 37 |
+
for name, sc in SCENARIOS.items():
|
| 38 |
+
plan = sc["drift_plan"]
|
| 39 |
+
assert isinstance(plan, list)
|
| 40 |
+
assert len(plan) > 0, f"{name}: drift_plan must have at least one event"
|
| 41 |
+
for d in plan:
|
| 42 |
+
assert isinstance(d, DriftEvent), f"{name}: non-DriftEvent in drift_plan"
|
| 43 |
+
assert d.tool in valid_tools
|
| 44 |
+
assert isinstance(d.fires_at_step, int)
|
| 45 |
+
assert d.fires_at_step >= 0
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def test_required_tools_match_scenario_intent() -> None:
|
| 49 |
+
assert SCENARIOS["E1_onboard_new_hire"]["required_tools"] == ["mail", "calendar"]
|
| 50 |
+
assert SCENARIOS["E2_meeting_invite_blast"]["required_tools"] == ["mail"]
|
| 51 |
+
assert SCENARIOS["E3_customer_lookup"]["required_tools"] == ["crm"]
|
| 52 |
+
|
| 53 |
+
for name, sc in SCENARIOS.items():
|
| 54 |
+
seed_keys = set(sc["seed_data"].keys())
|
| 55 |
+
req = set(sc["required_tools"])
|
| 56 |
+
assert seed_keys.issubset(req), (
|
| 57 |
+
f"{name}: seed_data keys {seed_keys} not a subset of required_tools {req}"
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def test_ground_truth_keys_non_empty() -> None:
|
| 62 |
+
for name, sc in SCENARIOS.items():
|
| 63 |
+
gt = sc["ground_truth_final_state"]
|
| 64 |
+
assert isinstance(gt, dict)
|
| 65 |
+
assert len(gt) > 0, f"{name}: ground_truth_final_state is empty"
|
tools/base.py
CHANGED
|
@@ -40,6 +40,13 @@ class BaseTool:
|
|
| 40 |
error=f"Endpoint '{endpoint}' is no longer available on {self.name}.",
|
| 41 |
)
|
| 42 |
schema = self.active_schemas[endpoint]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
missing = [p for p in schema.required if p not in params]
|
| 44 |
if missing:
|
| 45 |
return ToolResponse(
|
|
|
|
| 40 |
error=f"Endpoint '{endpoint}' is no longer available on {self.name}.",
|
| 41 |
)
|
| 42 |
schema = self.active_schemas[endpoint]
|
| 43 |
+
unknown = [p for p in params if p not in schema.params]
|
| 44 |
+
if unknown:
|
| 45 |
+
return ToolResponse(
|
| 46 |
+
ok=False,
|
| 47 |
+
status=400,
|
| 48 |
+
error=f"Unknown params on {endpoint}: {unknown}",
|
| 49 |
+
)
|
| 50 |
missing = [p for p in schema.required if p not in params]
|
| 51 |
if missing:
|
| 52 |
return ToolResponse(
|
tools/crm.py
CHANGED
|
@@ -1,4 +1,213 @@
|
|
| 1 |
-
"""CRMAPI — search_contacts, get_contact, create_contact, update_contact +
|
|
|
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CRMAPI — search_contacts, get_contact, create_contact, update_contact + 3 drift handlers."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
|
| 4 |
+
import copy
|
| 5 |
+
|
| 6 |
+
from models import DriftEvent, ToolResponse
|
| 7 |
+
from tools.base import BaseTool, EndpointSchema
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
_SEARCH_CONTACTS_BASE = EndpointSchema(
|
| 11 |
+
name="search_contacts",
|
| 12 |
+
params={"customer_email": "str", "name": "str", "limit": "int"},
|
| 13 |
+
required=[],
|
| 14 |
+
response_shape={"contacts": "list", "total": "int"},
|
| 15 |
+
error_codes={200: "ok", 400: "bad_request", 429: "rate_limit"},
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
_GET_CONTACT_BASE = EndpointSchema(
|
| 19 |
+
name="get_contact",
|
| 20 |
+
params={"contact_id": "str"},
|
| 21 |
+
required=["contact_id"],
|
| 22 |
+
response_shape={
|
| 23 |
+
"contact_id": "str", "name": "str", "customer_email": "str",
|
| 24 |
+
"company": "str", "status": "str",
|
| 25 |
+
},
|
| 26 |
+
error_codes={200: "ok", 404: "not_found", 429: "rate_limit"},
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
_CREATE_CONTACT_BASE = EndpointSchema(
|
| 30 |
+
name="create_contact",
|
| 31 |
+
params={"customer_email": "str", "name": "str", "company": "str"},
|
| 32 |
+
required=["customer_email", "name"],
|
| 33 |
+
response_shape={
|
| 34 |
+
"contact_id": "str", "name": "str",
|
| 35 |
+
"customer_email": "str", "status": "str",
|
| 36 |
+
},
|
| 37 |
+
error_codes={200: "ok", 400: "bad_request", 422: "validation", 429: "rate_limit"},
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
_UPDATE_CONTACT_BASE = EndpointSchema(
|
| 41 |
+
name="update_contact",
|
| 42 |
+
params={
|
| 43 |
+
"contact_id": "str", "customer_email": "str", "name": "str",
|
| 44 |
+
"company": "str", "status": "str",
|
| 45 |
+
},
|
| 46 |
+
required=["contact_id"],
|
| 47 |
+
response_shape={"contact_id": "str", "status": "str"},
|
| 48 |
+
error_codes={200: "ok", 404: "not_found", 422: "validation", 429: "rate_limit"},
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class CRMAPI(BaseTool):
|
| 53 |
+
name = "crm"
|
| 54 |
+
baseline_schemas = {
|
| 55 |
+
"search_contacts": _SEARCH_CONTACTS_BASE,
|
| 56 |
+
"get_contact": _GET_CONTACT_BASE,
|
| 57 |
+
"create_contact": _CREATE_CONTACT_BASE,
|
| 58 |
+
"update_contact": _UPDATE_CONTACT_BASE,
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
def __init__(self, seed_data: dict) -> None:
|
| 62 |
+
super().__init__()
|
| 63 |
+
self.contacts: list[dict] = list(seed_data.get("contacts", []))
|
| 64 |
+
self._next_id: int = len(self.contacts) + 1
|
| 65 |
+
self._call_count: int = 0
|
| 66 |
+
self._rate_limit_active: bool = False
|
| 67 |
+
self.handlers = {
|
| 68 |
+
"search_contacts": self._search_contacts,
|
| 69 |
+
"get_contact": self._get_contact,
|
| 70 |
+
"create_contact": self._create_contact,
|
| 71 |
+
"update_contact": self._update_contact,
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
def call(self, endpoint: str, params: dict) -> ToolResponse:
|
| 75 |
+
rl = self._check_rate_limit()
|
| 76 |
+
if rl is not None:
|
| 77 |
+
return rl
|
| 78 |
+
return super().call(endpoint, params)
|
| 79 |
+
|
| 80 |
+
def _check_rate_limit(self) -> ToolResponse | None:
|
| 81 |
+
self._call_count += 1
|
| 82 |
+
if self._rate_limit_active and self._call_count > 2:
|
| 83 |
+
return ToolResponse(ok=False, status=429, error="Rate limit exceeded")
|
| 84 |
+
return None
|
| 85 |
+
|
| 86 |
+
def _email_field_active(self) -> str:
|
| 87 |
+
any_schema = next(iter(self.active_schemas.values()))
|
| 88 |
+
if "email_address" in any_schema.response_shape or "email_address" in any_schema.params:
|
| 89 |
+
return "email_address"
|
| 90 |
+
for s in self.active_schemas.values():
|
| 91 |
+
if "email_address" in s.response_shape or "email_address" in s.params:
|
| 92 |
+
return "email_address"
|
| 93 |
+
return "customer_email"
|
| 94 |
+
|
| 95 |
+
def _normalize_email_in(self, params: dict) -> dict:
|
| 96 |
+
out = dict(params)
|
| 97 |
+
if "email_address" in out and "customer_email" not in out:
|
| 98 |
+
out["customer_email"] = out.pop("email_address")
|
| 99 |
+
return out
|
| 100 |
+
|
| 101 |
+
def _project_contact(self, contact: dict) -> dict:
|
| 102 |
+
out = dict(contact)
|
| 103 |
+
drifted = self._email_field_active() == "email_address"
|
| 104 |
+
if drifted and "customer_email" in out:
|
| 105 |
+
out["email_address"] = out.pop("customer_email")
|
| 106 |
+
elif not drifted and "email_address" in out:
|
| 107 |
+
out["customer_email"] = out.pop("email_address")
|
| 108 |
+
return out
|
| 109 |
+
|
| 110 |
+
def _search_contacts(self, params: dict) -> ToolResponse:
|
| 111 |
+
email = params.get("email_address") or params.get("customer_email")
|
| 112 |
+
name = params.get("name")
|
| 113 |
+
|
| 114 |
+
if email is None and name is None:
|
| 115 |
+
matched = list(self.contacts)
|
| 116 |
+
else:
|
| 117 |
+
matched = []
|
| 118 |
+
for c in self.contacts:
|
| 119 |
+
email_match = email is not None and c.get("customer_email", "").lower() == email.lower()
|
| 120 |
+
name_match = name is not None and name.lower() in c.get("name", "").lower()
|
| 121 |
+
if email_match or name_match:
|
| 122 |
+
matched.append(c)
|
| 123 |
+
|
| 124 |
+
projected = [self._project_contact(c) for c in matched]
|
| 125 |
+
return ToolResponse(
|
| 126 |
+
ok=True, status=200,
|
| 127 |
+
body={"contacts": projected, "total": len(projected)},
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
def _get_contact(self, params: dict) -> ToolResponse:
|
| 131 |
+
cid = params["contact_id"]
|
| 132 |
+
for c in self.contacts:
|
| 133 |
+
if c.get("contact_id") == cid:
|
| 134 |
+
return ToolResponse(ok=True, status=200, body=self._project_contact(c))
|
| 135 |
+
return ToolResponse(ok=False, status=404, error=f"contact {cid} not found")
|
| 136 |
+
|
| 137 |
+
def _create_contact(self, params: dict) -> ToolResponse:
|
| 138 |
+
normalized = self._normalize_email_in(params)
|
| 139 |
+
cid = f"c_{self._next_id}"
|
| 140 |
+
self._next_id += 1
|
| 141 |
+
contact = {
|
| 142 |
+
"contact_id": cid,
|
| 143 |
+
"customer_email": normalized.get("customer_email", ""),
|
| 144 |
+
"name": normalized.get("name", ""),
|
| 145 |
+
"company": normalized.get("company", ""),
|
| 146 |
+
"status": normalized.get("status", "active"),
|
| 147 |
+
}
|
| 148 |
+
self.contacts.append(contact)
|
| 149 |
+
return ToolResponse(ok=True, status=200, body=self._project_contact(contact))
|
| 150 |
+
|
| 151 |
+
def _update_contact(self, params: dict) -> ToolResponse:
|
| 152 |
+
cid = params["contact_id"]
|
| 153 |
+
normalized = self._normalize_email_in(params)
|
| 154 |
+
for c in self.contacts:
|
| 155 |
+
if c.get("contact_id") == cid:
|
| 156 |
+
for field in ("customer_email", "name", "company", "status"):
|
| 157 |
+
if field in normalized:
|
| 158 |
+
c[field] = normalized[field]
|
| 159 |
+
return ToolResponse(ok=True, status=200, body=self._project_contact(c))
|
| 160 |
+
return ToolResponse(ok=False, status=404, error=f"contact {cid} not found")
|
| 161 |
+
|
| 162 |
+
def apply_drift(self, event: DriftEvent) -> None:
|
| 163 |
+
if event.kind == "field_rename":
|
| 164 |
+
targets = (
|
| 165 |
+
"search_contacts", "get_contact", "create_contact",
|
| 166 |
+
"update_contact", "contacts.patch",
|
| 167 |
+
)
|
| 168 |
+
for ep in targets:
|
| 169 |
+
if ep not in self.active_schemas:
|
| 170 |
+
continue
|
| 171 |
+
schema = self.active_schemas[ep]
|
| 172 |
+
if "customer_email" in schema.params:
|
| 173 |
+
schema.params.pop("customer_email")
|
| 174 |
+
schema.params["email_address"] = "str"
|
| 175 |
+
if "customer_email" in schema.required:
|
| 176 |
+
schema.required = [
|
| 177 |
+
"email_address" if r == "customer_email" else r
|
| 178 |
+
for r in schema.required
|
| 179 |
+
]
|
| 180 |
+
if "customer_email" in schema.response_shape:
|
| 181 |
+
schema.response_shape.pop("customer_email")
|
| 182 |
+
schema.response_shape["email_address"] = "str"
|
| 183 |
+
return
|
| 184 |
+
|
| 185 |
+
if event.kind == "rate_limit_tightening":
|
| 186 |
+
self._rate_limit_active = True
|
| 187 |
+
self._call_count = 0
|
| 188 |
+
return
|
| 189 |
+
|
| 190 |
+
if event.kind == "endpoint_deprecation" and event.endpoint == "update_contact":
|
| 191 |
+
drifted_rename = any(
|
| 192 |
+
"email_address" in s.params or "email_address" in s.response_shape
|
| 193 |
+
for s in self.active_schemas.values()
|
| 194 |
+
)
|
| 195 |
+
self.active_schemas.pop("update_contact", None)
|
| 196 |
+
self.handlers.pop("update_contact", None)
|
| 197 |
+
new_schema = copy.deepcopy(self.baseline_schemas["update_contact"])
|
| 198 |
+
new_schema.name = "contacts.patch"
|
| 199 |
+
if drifted_rename:
|
| 200 |
+
if "customer_email" in new_schema.params:
|
| 201 |
+
new_schema.params.pop("customer_email")
|
| 202 |
+
new_schema.params["email_address"] = "str"
|
| 203 |
+
if "customer_email" in new_schema.response_shape:
|
| 204 |
+
new_schema.response_shape.pop("customer_email")
|
| 205 |
+
new_schema.response_shape["email_address"] = "str"
|
| 206 |
+
self.active_schemas["contacts.patch"] = new_schema
|
| 207 |
+
self.handlers["contacts.patch"] = self._update_contact
|
| 208 |
+
return
|
| 209 |
+
|
| 210 |
+
raise ValueError(
|
| 211 |
+
f"CRMAPI.apply_drift: unhandled drift "
|
| 212 |
+
f"kind={event.kind!r} endpoint={event.endpoint!r}"
|
| 213 |
+
)
|