booking-agent-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- booking_agent/__init__.py +6 -0
- booking_agent/authorization.py +450 -0
- booking_agent/backend/__init__.py +217 -0
- booking_agent/backend/live.py +494 -0
- booking_agent/calendar/__init__.py +55 -0
- booking_agent/calendar/filter.py +41 -0
- booking_agent/calendar/google.py +582 -0
- booking_agent/calendar/oauth_listener.py +287 -0
- booking_agent/calendar/scope.py +97 -0
- booking_agent/circuit_breaker.py +116 -0
- booking_agent/config.py +51 -0
- booking_agent/defaults.env +50 -0
- booking_agent/discovery.py +188 -0
- booking_agent/headless.py +463 -0
- booking_agent/http_deadlines.py +43 -0
- booking_agent/http_retry.py +178 -0
- booking_agent/http_trace.py +428 -0
- booking_agent/merchant_pacing.py +73 -0
- booking_agent/models/__init__.py +14 -0
- booking_agent/models/platform.py +200 -0
- booking_agent/models/wire.py +868 -0
- booking_agent/namespace.py +53 -0
- booking_agent/observability.py +143 -0
- booking_agent/platform_profile.py +120 -0
- booking_agent/pop.py +347 -0
- booking_agent/profile_cache.py +130 -0
- booking_agent/registry_url.py +35 -0
- booking_agent/rest_client.py +1493 -0
- booking_agent/secure_file.py +52 -0
- booking_agent/settings.py +110 -0
- booking_agent/signals.py +130 -0
- booking_agent/step_timing.py +168 -0
- booking_agent/ucp_capabilities.py +148 -0
- booking_agent/ucp_checkout.py +1363 -0
- booking_agent/ucp_negotiation.py +184 -0
- booking_agent/ucp_profile.py +410 -0
- booking_agent/usp_methods.py +110 -0
- booking_agent_cli/__init__.py +3 -0
- booking_agent_cli/activity_log.py +106 -0
- booking_agent_cli/booking_poll.py +141 -0
- booking_agent_cli/booking_summary.py +151 -0
- booking_agent_cli/calendar_connection.py +196 -0
- booking_agent_cli/calendar_event.py +611 -0
- booking_agent_cli/calendar_preference.py +176 -0
- booking_agent_cli/catalog.py +864 -0
- booking_agent_cli/checkout_prereq.py +67 -0
- booking_agent_cli/commands/__init__.py +0 -0
- booking_agent_cli/commands/config.py +106 -0
- booking_agent_cli/commands/flow.py +6988 -0
- booking_agent_cli/commands/lowlevel.py +163 -0
- booking_agent_cli/commands/runtime.py +46 -0
- booking_agent_cli/config_loader.py +108 -0
- booking_agent_cli/engine.py +168 -0
- booking_agent_cli/flow_timings.py +34 -0
- booking_agent_cli/geo_eligibility.py +183 -0
- booking_agent_cli/geocode.py +233 -0
- booking_agent_cli/identity.py +104 -0
- booking_agent_cli/link_cli.py +620 -0
- booking_agent_cli/main.py +75 -0
- booking_agent_cli/output.py +199 -0
- booking_agent_cli/payment_helpers.py +884 -0
- booking_agent_cli/payment_token.py +23 -0
- booking_agent_cli/reschedule.py +137 -0
- booking_agent_cli/runtime.py +55 -0
- booking_agent_cli/runtime_prepare.py +129 -0
- booking_agent_cli/search_area.py +111 -0
- booking_agent_cli/session.py +837 -0
- booking_agent_cli/support_bundle_schema.py +557 -0
- booking_agent_cli/support_report.py +1210 -0
- booking_agent_cli/timezone_context.py +314 -0
- booking_agent_cli-0.1.0.dist-info/METADATA +75 -0
- booking_agent_cli-0.1.0.dist-info/RECORD +74 -0
- booking_agent_cli-0.1.0.dist-info/WHEEL +4 -0
- booking_agent_cli-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""USP Business Profile Discovery.
|
|
2
|
+
|
|
3
|
+
Implements the USP specification's /.well-known/usp profile discovery (Section 7.2).
|
|
4
|
+
Live HTTP discovery only.
|
|
5
|
+
"""
|
|
6
|
+
import logging
|
|
7
|
+
from typing import Any, Optional, Dict, List
|
|
8
|
+
from urllib.parse import urlparse
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from booking_agent.authorization import AuthorizationPolicy, parse_policy
|
|
13
|
+
from booking_agent.http_retry import with_http_retry
|
|
14
|
+
from booking_agent.http_trace import traced_async_client
|
|
15
|
+
from booking_agent.namespace import (
|
|
16
|
+
CAP_AVAILABILITY,
|
|
17
|
+
CAP_BOOKINGS,
|
|
18
|
+
CAP_CATALOG,
|
|
19
|
+
SVC_SERVICES,
|
|
20
|
+
check_origin_binding,
|
|
21
|
+
)
|
|
22
|
+
from pydantic import BaseModel, Field, ConfigDict
|
|
23
|
+
from pydantic.alias_generators import to_camel
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# Profile models
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
class USPTransportBinding(BaseModel):
|
|
33
|
+
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
|
|
34
|
+
|
|
35
|
+
version: str
|
|
36
|
+
spec: Optional[str] = None
|
|
37
|
+
transport: str
|
|
38
|
+
endpoint: str
|
|
39
|
+
schema_url: Optional[str] = Field(None, alias="schema")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class USPCapabilityEntry(BaseModel):
|
|
43
|
+
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
|
|
44
|
+
|
|
45
|
+
version: str
|
|
46
|
+
spec: Optional[str] = None
|
|
47
|
+
schema_url: Optional[str] = Field(None, alias="schema")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class USPBusinessInfo(BaseModel):
|
|
51
|
+
"""schemas/usp.json#/$defs/BusinessInfo.
|
|
52
|
+
|
|
53
|
+
Spec REQUIRES (name, timezone, currency). The client accepts documents
|
|
54
|
+
without ``timezone`` or ``currency``, a deliberate permissive divergence.
|
|
55
|
+
"""
|
|
56
|
+
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
|
|
57
|
+
|
|
58
|
+
name: str
|
|
59
|
+
timezone: Optional[str] = None
|
|
60
|
+
currency: Optional[str] = None
|
|
61
|
+
#: Business locations, referenced by ``Service.locations[]`` via their ``id``.
|
|
62
|
+
#: Kept loosely typed: this is display and map data the client passes through
|
|
63
|
+
#: rather than reasons about, and the spec's entry shape is inlined rather
|
|
64
|
+
#: than named as a ``$def``.
|
|
65
|
+
locations: Optional[List[Dict[str, Any]]] = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class USPBusinessProfile(BaseModel):
|
|
69
|
+
"""Parsed /.well-known/usp profile."""
|
|
70
|
+
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
|
|
71
|
+
|
|
72
|
+
version: str
|
|
73
|
+
services: Dict[str, List[USPTransportBinding]] = {}
|
|
74
|
+
capabilities: Dict[str, List[USPCapabilityEntry]] = {}
|
|
75
|
+
checkout_systems: List[str] = []
|
|
76
|
+
business: USPBusinessInfo
|
|
77
|
+
#: Standalone Mode: the top-level ``authorization`` policy (USP Section 10.1.6).
|
|
78
|
+
#: In UCP-Native Mode the canonical policy is ``config.authorization`` on the
|
|
79
|
+
#: ``dev.usp-protocol.services`` binding instead -- see ``ucp_profile``.
|
|
80
|
+
authorization: Optional[AuthorizationPolicy] = None
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def exposes_privileged_capabilities(self) -> bool:
|
|
84
|
+
"""True when the business advertises anything beyond browse-only.
|
|
85
|
+
|
|
86
|
+
Bookings imply create/cancel; a catalog alone does not.
|
|
87
|
+
"""
|
|
88
|
+
return self.has_bookings
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def rest_endpoint(self) -> Optional[str]:
|
|
92
|
+
for binding in self.services.get(SVC_SERVICES, []):
|
|
93
|
+
if binding.transport == "rest":
|
|
94
|
+
return binding.endpoint
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def has_catalog(self) -> bool:
|
|
99
|
+
return CAP_CATALOG in self.capabilities
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def has_availability(self) -> bool:
|
|
103
|
+
return CAP_AVAILABILITY in self.capabilities
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def has_bookings(self) -> bool:
|
|
107
|
+
return CAP_BOOKINGS in self.capabilities
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ---------------------------------------------------------------------------
|
|
111
|
+
# Resolver
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
class USPProfileResolver:
|
|
115
|
+
"""Resolves a business's USP profile via live HTTP from /.well-known/usp."""
|
|
116
|
+
|
|
117
|
+
async def resolve(self, business_base_url: str) -> USPBusinessProfile:
|
|
118
|
+
"""Fetch GET {business_base_url}/.well-known/usp and parse the profile."""
|
|
119
|
+
url = business_base_url.rstrip("/") + "/.well-known/usp"
|
|
120
|
+
logger.info("Fetching USP profile from %s", url)
|
|
121
|
+
|
|
122
|
+
async def _get() -> httpx.Response:
|
|
123
|
+
async with traced_async_client(timeout=10.0, follow_redirects=True) as client:
|
|
124
|
+
return await client.get(url)
|
|
125
|
+
|
|
126
|
+
response = await with_http_retry(_get)
|
|
127
|
+
response.raise_for_status()
|
|
128
|
+
return self._parse_profile(response.json())
|
|
129
|
+
|
|
130
|
+
async def resolve_from_url(self, usp_profile_url: str) -> USPBusinessProfile:
|
|
131
|
+
"""Resolve a business's USP profile from a direct profile URL.
|
|
132
|
+
|
|
133
|
+
Used when the profile URL is already known (e.g. from a registry
|
|
134
|
+
search result's ``business.usp_profile_url``).
|
|
135
|
+
"""
|
|
136
|
+
logger.info("Fetching USP profile from %s", usp_profile_url)
|
|
137
|
+
async def _get() -> httpx.Response:
|
|
138
|
+
async with traced_async_client(timeout=10.0, follow_redirects=True) as client:
|
|
139
|
+
return await client.get(usp_profile_url)
|
|
140
|
+
|
|
141
|
+
response = await with_http_retry(_get)
|
|
142
|
+
response.raise_for_status()
|
|
143
|
+
return self._parse_profile(response.json())
|
|
144
|
+
|
|
145
|
+
@staticmethod
|
|
146
|
+
def _parse_profile(data: dict) -> USPBusinessProfile:
|
|
147
|
+
usp_block = data.get("usp", data)
|
|
148
|
+
return USPBusinessProfile(
|
|
149
|
+
version=usp_block["version"],
|
|
150
|
+
services=_parse_transport_bindings(usp_block.get("services", {})),
|
|
151
|
+
capabilities=_parse_capabilities(usp_block.get("capabilities", {})),
|
|
152
|
+
checkout_systems=usp_block.get("checkout_systems", []),
|
|
153
|
+
business=USPBusinessInfo(**usp_block["business"]),
|
|
154
|
+
authorization=parse_policy(usp_block.get("authorization")),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
@staticmethod
|
|
158
|
+
def extract_base_url_from_agent_card_url(agent_card_url: str) -> str:
|
|
159
|
+
"""Derive business base URL from a merchant A2A agent card URL.
|
|
160
|
+
|
|
161
|
+
Strips ``/.well-known/agent.json`` or similar suffixes.
|
|
162
|
+
"""
|
|
163
|
+
parsed = urlparse(agent_card_url)
|
|
164
|
+
return f"{parsed.scheme}://{parsed.netloc}"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _parse_transport_bindings(
|
|
168
|
+
raw: Dict[str, list],
|
|
169
|
+
) -> Dict[str, List[USPTransportBinding]]:
|
|
170
|
+
result: Dict[str, List[USPTransportBinding]] = {}
|
|
171
|
+
for key, entries in raw.items():
|
|
172
|
+
bindings = [USPTransportBinding(**e) for e in entries]
|
|
173
|
+
for binding in bindings:
|
|
174
|
+
check_origin_binding(key, spec=binding.spec, schema=binding.schema_url)
|
|
175
|
+
result[key] = bindings
|
|
176
|
+
return result
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _parse_capabilities(
|
|
180
|
+
raw: Dict[str, list],
|
|
181
|
+
) -> Dict[str, List[USPCapabilityEntry]]:
|
|
182
|
+
result: Dict[str, List[USPCapabilityEntry]] = {}
|
|
183
|
+
for key, entries in raw.items():
|
|
184
|
+
caps = [USPCapabilityEntry(**e) for e in entries]
|
|
185
|
+
for cap in caps:
|
|
186
|
+
check_origin_binding(key, spec=cap.spec, schema=cap.schema_url)
|
|
187
|
+
result[key] = caps
|
|
188
|
+
return result
|
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
"""Headless USP orchestration helpers.
|
|
2
|
+
|
|
3
|
+
The booking-agent-cli flow engine and the low-level
|
|
4
|
+
``booking-agent`` subcommands both call into these.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import re
|
|
10
|
+
from datetime import datetime, timedelta, timezone
|
|
11
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
12
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _parse_iso(s: str) -> datetime:
|
|
16
|
+
"""Parse an ISO8601 datetime string, handling the Z suffix for Python 3.10."""
|
|
17
|
+
return datetime.fromisoformat(s.replace("Z", "+00:00"))
|
|
18
|
+
|
|
19
|
+
from .backend import USPBackend
|
|
20
|
+
from .discovery import USPBusinessProfile
|
|
21
|
+
from .models.platform import (
|
|
22
|
+
DesiredServiceTimeFrom,
|
|
23
|
+
DesiredServiceTimeMoment,
|
|
24
|
+
DesiredServiceTimePreference,
|
|
25
|
+
DesiredServiceTimeRange,
|
|
26
|
+
ServiceSearchIntent,
|
|
27
|
+
ServiceSearchResult,
|
|
28
|
+
)
|
|
29
|
+
from .models.wire import (
|
|
30
|
+
AvailabilityResult,
|
|
31
|
+
Booking,
|
|
32
|
+
CancellationPolicy,
|
|
33
|
+
Channel,
|
|
34
|
+
ChannelType,
|
|
35
|
+
Duration,
|
|
36
|
+
Pricing,
|
|
37
|
+
PricingModel,
|
|
38
|
+
ReschedulingPolicy,
|
|
39
|
+
ServiceCategory,
|
|
40
|
+
ServicePolicies,
|
|
41
|
+
TimeSlot,
|
|
42
|
+
USPService,
|
|
43
|
+
project_category_string,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
logger = logging.getLogger(__name__)
|
|
47
|
+
|
|
48
|
+
CATALOG_SOURCE_REGISTRY = "registry"
|
|
49
|
+
CATALOG_SOURCE_LIVE = "live"
|
|
50
|
+
METADATA_CATALOG_SOURCE = "catalog_source"
|
|
51
|
+
METADATA_PROFILE_RESOLVED = "profile_resolved"
|
|
52
|
+
METADATA_REGISTRY_SEARCH_RESULT = "registry_search_result"
|
|
53
|
+
METADATA_SEARCH_RANK = "search_rank"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def build_desired_service_time_preferences(
|
|
57
|
+
*,
|
|
58
|
+
desired_at: Optional[List[str]] = None,
|
|
59
|
+
desired_from: Optional[List[str]] = None,
|
|
60
|
+
desired_range: Optional[List[str]] = None,
|
|
61
|
+
) -> List[DesiredServiceTimePreference]:
|
|
62
|
+
"""Parse CLI ranking preferences and require timezone-aware RFC 3339 values."""
|
|
63
|
+
|
|
64
|
+
def valid_rfc3339(raw: str) -> str:
|
|
65
|
+
try:
|
|
66
|
+
parsed = _parse_iso(raw)
|
|
67
|
+
except (TypeError, ValueError) as exc:
|
|
68
|
+
raise ValueError(f"Invalid RFC3339 timestamp: {raw!r}") from exc
|
|
69
|
+
if parsed.tzinfo is None:
|
|
70
|
+
raise ValueError(f"RFC3339 timestamp must include a timezone: {raw!r}")
|
|
71
|
+
return raw
|
|
72
|
+
|
|
73
|
+
preferences: List[DesiredServiceTimePreference] = [
|
|
74
|
+
DesiredServiceTimeMoment(at=valid_rfc3339(value))
|
|
75
|
+
for value in desired_at or []
|
|
76
|
+
]
|
|
77
|
+
preferences.extend(
|
|
78
|
+
DesiredServiceTimeFrom(start=valid_rfc3339(value))
|
|
79
|
+
for value in desired_from or []
|
|
80
|
+
)
|
|
81
|
+
for value in desired_range or []:
|
|
82
|
+
parts = [part.strip() for part in value.split(",")]
|
|
83
|
+
if len(parts) != 2 or not all(parts):
|
|
84
|
+
raise ValueError(
|
|
85
|
+
"Desired range must be <startRfc3339>,<endRfc3339>"
|
|
86
|
+
)
|
|
87
|
+
start, end = (valid_rfc3339(part) for part in parts)
|
|
88
|
+
if _parse_iso(end) < _parse_iso(start):
|
|
89
|
+
raise ValueError("Desired range end must not be before start")
|
|
90
|
+
preferences.append(DesiredServiceTimeRange(start=start, end=end))
|
|
91
|
+
return preferences
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def attach_registry_hit(
|
|
95
|
+
service: USPService,
|
|
96
|
+
hit: ServiceSearchResult,
|
|
97
|
+
*,
|
|
98
|
+
rank: Optional[int] = None,
|
|
99
|
+
) -> USPService:
|
|
100
|
+
"""Persist the raw registry row on a discoverable service for agents and support reports."""
|
|
101
|
+
meta = dict(service.metadata or {})
|
|
102
|
+
meta[METADATA_REGISTRY_SEARCH_RESULT] = hit.model_dump(by_alias=True, exclude_none=True)
|
|
103
|
+
if rank is not None:
|
|
104
|
+
meta[METADATA_SEARCH_RANK] = rank
|
|
105
|
+
service.metadata = meta
|
|
106
|
+
return service
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _duration_fixed_from_minutes(minutes: Optional[int]) -> str:
|
|
110
|
+
if minutes is None or minutes <= 0:
|
|
111
|
+
return "PT1H"
|
|
112
|
+
if minutes % 60 == 0:
|
|
113
|
+
hours = minutes // 60
|
|
114
|
+
return f"PT{hours}H"
|
|
115
|
+
return f"PT{minutes}M"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _pricing_from_registry_hit(hit: ServiceSearchResult) -> Pricing:
|
|
119
|
+
"""Map registry search pricing onto a catalog ``Pricing`` snapshot."""
|
|
120
|
+
registry_price = hit.price
|
|
121
|
+
currency = (
|
|
122
|
+
registry_price.currency
|
|
123
|
+
if registry_price and registry_price.currency
|
|
124
|
+
else "USD"
|
|
125
|
+
)
|
|
126
|
+
pricing_model = PricingModel.VARIABLE
|
|
127
|
+
if registry_price and registry_price.model:
|
|
128
|
+
try:
|
|
129
|
+
pricing_model = PricingModel(registry_price.model)
|
|
130
|
+
except ValueError:
|
|
131
|
+
pricing_model = PricingModel.VARIABLE
|
|
132
|
+
|
|
133
|
+
if registry_price and registry_price.amount is not None:
|
|
134
|
+
if pricing_model is PricingModel.VARIABLE:
|
|
135
|
+
pricing_model = PricingModel.FIXED
|
|
136
|
+
return Pricing(
|
|
137
|
+
model=pricing_model,
|
|
138
|
+
amount=registry_price.amount,
|
|
139
|
+
currency=currency,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
if pricing_model is PricingModel.FREE:
|
|
143
|
+
return Pricing(model=PricingModel.FREE, currency=currency)
|
|
144
|
+
|
|
145
|
+
return Pricing(model=PricingModel.VARIABLE, currency=currency)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def service_from_registry_hit(hit: ServiceSearchResult) -> USPService:
|
|
149
|
+
"""Build a discoverable ``USPService`` snapshot from a registry search hit.
|
|
150
|
+
|
|
151
|
+
Used when profile resolution or live catalog fetch fails so agents can still
|
|
152
|
+
present registry-ranked options to the user.
|
|
153
|
+
"""
|
|
154
|
+
if not hit.business:
|
|
155
|
+
raise ValueError("Registry search hit is missing business info")
|
|
156
|
+
pricing = _pricing_from_registry_hit(hit)
|
|
157
|
+
categories = (
|
|
158
|
+
[ServiceCategory(taxonomy="merchant", name=hit.category)]
|
|
159
|
+
if hit.category
|
|
160
|
+
else None
|
|
161
|
+
)
|
|
162
|
+
return USPService(
|
|
163
|
+
id=hit.service_id,
|
|
164
|
+
business_id=hit.business.id,
|
|
165
|
+
name=hit.service_name,
|
|
166
|
+
type="appointment",
|
|
167
|
+
categories=categories,
|
|
168
|
+
duration=Duration(fixed=_duration_fixed_from_minutes(hit.duration_minutes)),
|
|
169
|
+
pricing=pricing,
|
|
170
|
+
channel=Channel(type=ChannelType.AT_BUSINESS_LOCATION),
|
|
171
|
+
policies=ServicePolicies(
|
|
172
|
+
cancellation=CancellationPolicy(allowed=True),
|
|
173
|
+
rescheduling=ReschedulingPolicy(allowed=True),
|
|
174
|
+
confirmation_mode="auto",
|
|
175
|
+
requires_payment=False,
|
|
176
|
+
),
|
|
177
|
+
business=hit.business,
|
|
178
|
+
availability_hint=hit.availability_hint,
|
|
179
|
+
metadata={
|
|
180
|
+
METADATA_CATALOG_SOURCE: CATALOG_SOURCE_REGISTRY,
|
|
181
|
+
METADATA_PROFILE_RESOLVED: False,
|
|
182
|
+
},
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def mark_live_catalog_service(service: USPService) -> USPService:
|
|
187
|
+
"""Tag a live-catalog service so discover output can distinguish sources."""
|
|
188
|
+
meta = dict(service.metadata or {})
|
|
189
|
+
meta[METADATA_CATALOG_SOURCE] = CATALOG_SOURCE_LIVE
|
|
190
|
+
meta[METADATA_PROFILE_RESOLVED] = True
|
|
191
|
+
service.metadata = meta
|
|
192
|
+
return service
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def parse_service_search_location(location: str) -> "ServiceSearchLocation":
|
|
196
|
+
"""Parse ``lat,lng`` into a registry ``ServiceSearchLocation`` (coordinates only)."""
|
|
197
|
+
from .models.platform import ServiceSearchLocation
|
|
198
|
+
from .models.wire import Coordinates
|
|
199
|
+
|
|
200
|
+
raw = (location or "").strip()
|
|
201
|
+
parts = [p.strip() for p in raw.split(",", 1)]
|
|
202
|
+
if len(parts) != 2:
|
|
203
|
+
raise ValueError(
|
|
204
|
+
"Location must be latitude,longitude (e.g. 32.6927,-114.6277)"
|
|
205
|
+
)
|
|
206
|
+
try:
|
|
207
|
+
lat = float(parts[0])
|
|
208
|
+
lng = float(parts[1])
|
|
209
|
+
except (TypeError, ValueError) as exc:
|
|
210
|
+
raise ValueError(
|
|
211
|
+
"Location must be numeric latitude,longitude (e.g. 32.6927,-114.6277)"
|
|
212
|
+
) from exc
|
|
213
|
+
return ServiceSearchLocation(coordinates=Coordinates(lat=lat, lng=lng))
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# USP §1.3.1 core verticals. The registry filters service search hits by exact
|
|
217
|
+
# match on each service's single ``type``, so an unlisted value matches nothing.
|
|
218
|
+
CORE_SERVICE_VERTICALS = (
|
|
219
|
+
"appointment",
|
|
220
|
+
"group",
|
|
221
|
+
"reservation",
|
|
222
|
+
"rental",
|
|
223
|
+
"field_service",
|
|
224
|
+
)
|
|
225
|
+
_CUSTOM_VERTICAL_RE = re.compile(r"^[a-z][a-z0-9-]*(\.[a-z0-9_-]+){2,}$")
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def parse_service_verticals(values: Optional[List[str]]) -> List[str]:
|
|
229
|
+
"""Validate ``--vertical`` values: USP core verticals or reverse-domain custom ones."""
|
|
230
|
+
verticals: List[str] = []
|
|
231
|
+
for raw in values or []:
|
|
232
|
+
value = (raw or "").strip()
|
|
233
|
+
if not value:
|
|
234
|
+
continue
|
|
235
|
+
if value not in CORE_SERVICE_VERTICALS and not _CUSTOM_VERTICAL_RE.match(value):
|
|
236
|
+
raise ValueError(
|
|
237
|
+
f"Unknown vertical {value!r}. Use one of "
|
|
238
|
+
f"{', '.join(CORE_SERVICE_VERTICALS)}, or a reverse-domain "
|
|
239
|
+
"custom vertical such as com.vendor.services.name."
|
|
240
|
+
)
|
|
241
|
+
if value not in verticals:
|
|
242
|
+
verticals.append(value)
|
|
243
|
+
return verticals
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def build_search_intent(
|
|
247
|
+
*,
|
|
248
|
+
query: str,
|
|
249
|
+
natural_language_description: Optional[str] = None,
|
|
250
|
+
location: Optional[Any] = None,
|
|
251
|
+
verticals: Optional[List[str]] = None,
|
|
252
|
+
categories: Optional[List[str]] = None,
|
|
253
|
+
date_preference: Optional[str] = None,
|
|
254
|
+
desired_service_time_ranges: Optional[List[DesiredServiceTimePreference]] = None,
|
|
255
|
+
prefer_sooner_availability_slots: Optional[bool] = None,
|
|
256
|
+
intent_expiry_minutes: int = 30,
|
|
257
|
+
) -> ServiceSearchIntent:
|
|
258
|
+
"""Build a ServiceSearchIntent with sane defaults."""
|
|
259
|
+
expiry = (
|
|
260
|
+
datetime.now(timezone.utc) + timedelta(minutes=intent_expiry_minutes)
|
|
261
|
+
).isoformat()
|
|
262
|
+
loc = location
|
|
263
|
+
if isinstance(location, dict):
|
|
264
|
+
from .models.platform import ServiceSearchLocation
|
|
265
|
+
|
|
266
|
+
loc = ServiceSearchLocation(**location)
|
|
267
|
+
return ServiceSearchIntent(
|
|
268
|
+
query=query,
|
|
269
|
+
natural_language_description=natural_language_description or query,
|
|
270
|
+
location=loc,
|
|
271
|
+
verticals=verticals or [],
|
|
272
|
+
categories=categories or [],
|
|
273
|
+
date_preference=date_preference,
|
|
274
|
+
desired_service_time_ranges=desired_service_time_ranges,
|
|
275
|
+
prefer_sooner_availability_slots=prefer_sooner_availability_slots,
|
|
276
|
+
intent_expiry=expiry,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
async def discover_service_via_registry(
|
|
281
|
+
backend: USPBackend,
|
|
282
|
+
*,
|
|
283
|
+
query: str,
|
|
284
|
+
location: Optional[Any] = None,
|
|
285
|
+
verticals: Optional[List[str]] = None,
|
|
286
|
+
categories: Optional[List[str]] = None,
|
|
287
|
+
) -> Tuple[str, str, ServiceSearchResult]:
|
|
288
|
+
"""Return ``(service_id, profile_url, raw_hit)`` from registry search (GH-021).
|
|
289
|
+
|
|
290
|
+
Does not post-filter by ``deployment_mode`` (per USP+UCP implementation plan).
|
|
291
|
+
"""
|
|
292
|
+
from .models.platform import ServiceSearchResult, ServiceSearchResultPrice
|
|
293
|
+
from .models.wire import RegistryBusinessInfo
|
|
294
|
+
|
|
295
|
+
intent = build_search_intent(
|
|
296
|
+
query=query,
|
|
297
|
+
location=location,
|
|
298
|
+
verticals=verticals,
|
|
299
|
+
categories=categories,
|
|
300
|
+
)
|
|
301
|
+
services = await backend.search_registry(intent)
|
|
302
|
+
for s in services:
|
|
303
|
+
biz = s.business
|
|
304
|
+
if not s.id or not biz or not biz.usp_profile_url:
|
|
305
|
+
continue
|
|
306
|
+
hit = ServiceSearchResult(
|
|
307
|
+
service_id=s.id,
|
|
308
|
+
service_name=s.name or "",
|
|
309
|
+
business=RegistryBusinessInfo(
|
|
310
|
+
id=biz.id,
|
|
311
|
+
name=biz.name,
|
|
312
|
+
profile_url=biz.usp_profile_url,
|
|
313
|
+
deployment_mode=biz.deployment_mode or "standalone",
|
|
314
|
+
),
|
|
315
|
+
category=project_category_string(
|
|
316
|
+
categories=s.categories,
|
|
317
|
+
service_type=s.type,
|
|
318
|
+
),
|
|
319
|
+
price=(
|
|
320
|
+
ServiceSearchResultPrice(
|
|
321
|
+
amount=s.pricing.amount if s.pricing else None,
|
|
322
|
+
currency=s.pricing.currency if s.pricing else None,
|
|
323
|
+
)
|
|
324
|
+
if s.pricing
|
|
325
|
+
else None
|
|
326
|
+
),
|
|
327
|
+
timezone=None,
|
|
328
|
+
)
|
|
329
|
+
return s.id, biz.usp_profile_url, hit
|
|
330
|
+
raise RuntimeError("No registry search hit with service_id and business.profile_url")
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
async def discover_services(
|
|
334
|
+
backend: USPBackend, intent: ServiceSearchIntent
|
|
335
|
+
) -> List[USPService]:
|
|
336
|
+
"""Search the USP registry."""
|
|
337
|
+
return await backend.search_registry(intent)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
async def resolve_profile(
|
|
341
|
+
backend: USPBackend, usp_profile_url: str
|
|
342
|
+
) -> USPBusinessProfile:
|
|
343
|
+
return await backend.resolve_profile(usp_profile_url)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _isoformat_day_bounds(day: str) -> Tuple[str, str]:
|
|
347
|
+
"""Return ``(start, end)`` ISO8601 UTC bounds for an ISO date ``YYYY-MM-DD``."""
|
|
348
|
+
d = datetime.fromisoformat(day).date()
|
|
349
|
+
start = datetime(d.year, d.month, d.day, 0, 0, tzinfo=timezone.utc)
|
|
350
|
+
end = start + timedelta(days=1) - timedelta(seconds=1)
|
|
351
|
+
return start.isoformat(), end.isoformat()
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
async def query_availability_with_quirk(
|
|
355
|
+
backend: USPBackend,
|
|
356
|
+
profile: USPBusinessProfile,
|
|
357
|
+
service_id: str,
|
|
358
|
+
start_date: str,
|
|
359
|
+
end_date: str,
|
|
360
|
+
*,
|
|
361
|
+
timezone_str: Optional[str] = None,
|
|
362
|
+
single_day_retry_days: int = 7,
|
|
363
|
+
) -> List[TimeSlot]:
|
|
364
|
+
"""Query availability, widening an empty single-day window once.
|
|
365
|
+
|
|
366
|
+
If the requested window is a single day and returns 0 slots, retry with a
|
|
367
|
+
``single_day_retry_days``-wide range starting at the same date and keep only
|
|
368
|
+
the slots that fall on that date.
|
|
369
|
+
"""
|
|
370
|
+
slots = await backend.query_availability(
|
|
371
|
+
profile, service_id, start_date, end_date, timezone_str
|
|
372
|
+
)
|
|
373
|
+
if slots:
|
|
374
|
+
return slots
|
|
375
|
+
|
|
376
|
+
try:
|
|
377
|
+
start_dt = _parse_iso(start_date)
|
|
378
|
+
end_dt = _parse_iso(end_date)
|
|
379
|
+
except (ValueError, TypeError):
|
|
380
|
+
return slots
|
|
381
|
+
|
|
382
|
+
if (end_dt.date() - start_dt.date()).days > 0:
|
|
383
|
+
return slots
|
|
384
|
+
|
|
385
|
+
target_date = start_dt.date()
|
|
386
|
+
date_only = _is_date_only(start_date) and _is_date_only(end_date)
|
|
387
|
+
if date_only:
|
|
388
|
+
retry_end = (target_date + timedelta(days=single_day_retry_days - 1)).isoformat()
|
|
389
|
+
else:
|
|
390
|
+
window_tz = start_dt.tzinfo or timezone.utc
|
|
391
|
+
retry_end = (
|
|
392
|
+
datetime(target_date.year, target_date.month, target_date.day, 0, 0, tzinfo=window_tz)
|
|
393
|
+
+ timedelta(days=single_day_retry_days)
|
|
394
|
+
- timedelta(seconds=1)
|
|
395
|
+
).isoformat()
|
|
396
|
+
logger.info(
|
|
397
|
+
"Single-day availability empty for %s on %s — retrying with %d-day range",
|
|
398
|
+
service_id, target_date, single_day_retry_days,
|
|
399
|
+
)
|
|
400
|
+
wide = await backend.query_availability(
|
|
401
|
+
profile, service_id, start_date, retry_end, timezone_str
|
|
402
|
+
)
|
|
403
|
+
if date_only:
|
|
404
|
+
applied = _valid_zone(getattr(wide, "applied_date_bounds_timezone", None))
|
|
405
|
+
window_tz = ZoneInfo(applied) if applied else None
|
|
406
|
+
|
|
407
|
+
def _on_target_day(raw: str) -> bool:
|
|
408
|
+
dt = _parse_iso(raw)
|
|
409
|
+
if dt.tzinfo is None:
|
|
410
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
411
|
+
local = dt.astimezone(window_tz) if window_tz is not None else dt
|
|
412
|
+
return local.date() == target_date
|
|
413
|
+
|
|
414
|
+
kept = [s for s in wide if _on_target_day(s.start)]
|
|
415
|
+
return AvailabilityResult(
|
|
416
|
+
kept,
|
|
417
|
+
business_timezone=getattr(wide, "business_timezone", None),
|
|
418
|
+
applied_date_bounds_timezone=getattr(wide, "applied_date_bounds_timezone", None),
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _is_date_only(value: str) -> bool:
|
|
423
|
+
return isinstance(value, str) and "T" not in value and len(value.strip()) == 10
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _valid_zone(value: Any) -> Optional[str]:
|
|
427
|
+
if not isinstance(value, str) or not value.strip():
|
|
428
|
+
return None
|
|
429
|
+
try:
|
|
430
|
+
ZoneInfo(value.strip())
|
|
431
|
+
except (ZoneInfoNotFoundError, ValueError):
|
|
432
|
+
return None
|
|
433
|
+
return value.strip()
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
async def create_booking(
|
|
437
|
+
backend: USPBackend,
|
|
438
|
+
profile: USPBusinessProfile,
|
|
439
|
+
*,
|
|
440
|
+
service_id: str,
|
|
441
|
+
slot_id: str,
|
|
442
|
+
buyer: Dict[str, Any],
|
|
443
|
+
party_size: int = 1,
|
|
444
|
+
resource_id: Optional[str] = None,
|
|
445
|
+
notes: Optional[str] = None,
|
|
446
|
+
delivery_address: Optional[Dict[str, Any]] = None,
|
|
447
|
+
) -> Booking:
|
|
448
|
+
return await backend.create_booking(
|
|
449
|
+
profile=profile,
|
|
450
|
+
service_id=service_id,
|
|
451
|
+
slot_id=slot_id,
|
|
452
|
+
buyer=buyer,
|
|
453
|
+
party_size=party_size,
|
|
454
|
+
resource_id=resource_id,
|
|
455
|
+
notes=notes,
|
|
456
|
+
delivery_address=delivery_address,
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
async def poll_booking(
|
|
461
|
+
backend: USPBackend, profile: USPBusinessProfile, booking_id: str
|
|
462
|
+
) -> Booking:
|
|
463
|
+
return await backend.get_booking(profile, booking_id)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Explicit connect and overall HTTP deadlines.
|
|
2
|
+
|
|
3
|
+
httpx treats a bare float as one value for connect, read, write, and pool.
|
|
4
|
+
Callers that pass ``timeout=15.0`` therefore never distinguish a hung TCP
|
|
5
|
+
handshake from a slow response. ``coerce_timeout`` always returns an
|
|
6
|
+
``httpx.Timeout`` with a dedicated connect budget that cannot exceed the
|
|
7
|
+
overall deadline.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Optional, Union
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from . import settings
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def connect_timeout_seconds() -> float:
|
|
20
|
+
return settings.get_float("BOOKING_AGENT_HTTP_CONNECT_TIMEOUT", minimum=0.1)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def default_overall_seconds() -> float:
|
|
24
|
+
return settings.get_float("BOOKING_AGENT_HTTP_TIMEOUT", minimum=0.1)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def coerce_timeout(
|
|
28
|
+
value: Union[httpx.Timeout, float, int, None] = None,
|
|
29
|
+
*,
|
|
30
|
+
connect: Optional[float] = None,
|
|
31
|
+
) -> httpx.Timeout:
|
|
32
|
+
"""Return an ``httpx.Timeout`` with both connect and overall (read/write/pool) set.
|
|
33
|
+
|
|
34
|
+
``None`` uses ``BOOKING_AGENT_HTTP_TIMEOUT`` (default 15s). A float/int is the
|
|
35
|
+
overall deadline. ``connect`` defaults to ``BOOKING_AGENT_HTTP_CONNECT_TIMEOUT``
|
|
36
|
+
(default 5s), capped at the overall deadline.
|
|
37
|
+
"""
|
|
38
|
+
if isinstance(value, httpx.Timeout):
|
|
39
|
+
return value
|
|
40
|
+
overall = default_overall_seconds() if value is None else float(value)
|
|
41
|
+
connect_budget = connect_timeout_seconds() if connect is None else float(connect)
|
|
42
|
+
connect_budget = min(connect_budget, overall)
|
|
43
|
+
return httpx.Timeout(overall, connect=connect_budget)
|