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.
Files changed (74) hide show
  1. booking_agent/__init__.py +6 -0
  2. booking_agent/authorization.py +450 -0
  3. booking_agent/backend/__init__.py +217 -0
  4. booking_agent/backend/live.py +494 -0
  5. booking_agent/calendar/__init__.py +55 -0
  6. booking_agent/calendar/filter.py +41 -0
  7. booking_agent/calendar/google.py +582 -0
  8. booking_agent/calendar/oauth_listener.py +287 -0
  9. booking_agent/calendar/scope.py +97 -0
  10. booking_agent/circuit_breaker.py +116 -0
  11. booking_agent/config.py +51 -0
  12. booking_agent/defaults.env +50 -0
  13. booking_agent/discovery.py +188 -0
  14. booking_agent/headless.py +463 -0
  15. booking_agent/http_deadlines.py +43 -0
  16. booking_agent/http_retry.py +178 -0
  17. booking_agent/http_trace.py +428 -0
  18. booking_agent/merchant_pacing.py +73 -0
  19. booking_agent/models/__init__.py +14 -0
  20. booking_agent/models/platform.py +200 -0
  21. booking_agent/models/wire.py +868 -0
  22. booking_agent/namespace.py +53 -0
  23. booking_agent/observability.py +143 -0
  24. booking_agent/platform_profile.py +120 -0
  25. booking_agent/pop.py +347 -0
  26. booking_agent/profile_cache.py +130 -0
  27. booking_agent/registry_url.py +35 -0
  28. booking_agent/rest_client.py +1493 -0
  29. booking_agent/secure_file.py +52 -0
  30. booking_agent/settings.py +110 -0
  31. booking_agent/signals.py +130 -0
  32. booking_agent/step_timing.py +168 -0
  33. booking_agent/ucp_capabilities.py +148 -0
  34. booking_agent/ucp_checkout.py +1363 -0
  35. booking_agent/ucp_negotiation.py +184 -0
  36. booking_agent/ucp_profile.py +410 -0
  37. booking_agent/usp_methods.py +110 -0
  38. booking_agent_cli/__init__.py +3 -0
  39. booking_agent_cli/activity_log.py +106 -0
  40. booking_agent_cli/booking_poll.py +141 -0
  41. booking_agent_cli/booking_summary.py +151 -0
  42. booking_agent_cli/calendar_connection.py +196 -0
  43. booking_agent_cli/calendar_event.py +611 -0
  44. booking_agent_cli/calendar_preference.py +176 -0
  45. booking_agent_cli/catalog.py +864 -0
  46. booking_agent_cli/checkout_prereq.py +67 -0
  47. booking_agent_cli/commands/__init__.py +0 -0
  48. booking_agent_cli/commands/config.py +106 -0
  49. booking_agent_cli/commands/flow.py +6988 -0
  50. booking_agent_cli/commands/lowlevel.py +163 -0
  51. booking_agent_cli/commands/runtime.py +46 -0
  52. booking_agent_cli/config_loader.py +108 -0
  53. booking_agent_cli/engine.py +168 -0
  54. booking_agent_cli/flow_timings.py +34 -0
  55. booking_agent_cli/geo_eligibility.py +183 -0
  56. booking_agent_cli/geocode.py +233 -0
  57. booking_agent_cli/identity.py +104 -0
  58. booking_agent_cli/link_cli.py +620 -0
  59. booking_agent_cli/main.py +75 -0
  60. booking_agent_cli/output.py +199 -0
  61. booking_agent_cli/payment_helpers.py +884 -0
  62. booking_agent_cli/payment_token.py +23 -0
  63. booking_agent_cli/reschedule.py +137 -0
  64. booking_agent_cli/runtime.py +55 -0
  65. booking_agent_cli/runtime_prepare.py +129 -0
  66. booking_agent_cli/search_area.py +111 -0
  67. booking_agent_cli/session.py +837 -0
  68. booking_agent_cli/support_bundle_schema.py +557 -0
  69. booking_agent_cli/support_report.py +1210 -0
  70. booking_agent_cli/timezone_context.py +314 -0
  71. booking_agent_cli-0.1.0.dist-info/METADATA +75 -0
  72. booking_agent_cli-0.1.0.dist-info/RECORD +74 -0
  73. booking_agent_cli-0.1.0.dist-info/WHEEL +4 -0
  74. 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)