- FastAPI skeleton, SQLAlchemy models (§13), Alembic initial migration - SchedulingProvider interface with google_calendar (free/busy read-only), partner_api (Appendix B client) and mock implementations - Proposal engine: create → provider-routed delivery → owner actions (resolve/confirm+SMS/reject) → expiry + reminders (§9) - Signed single-use action links, .ics METHOD:REQUEST attachment - Partner outcome webhook with HMAC verification + polling fallback - SmsProvider (console) with Bosnian templates (§5.5), EmailProvider (console/SMTP) - Fake partner API server in tests/ — Appendix B reference implementation - 43 tests: slot math, proposal lifecycle, action links, partner contract Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
88 lines
2.5 KiB
Python
88 lines
2.5 KiB
Python
"""Shared domain value objects used across providers, agent tools and the proposal engine.
|
|
|
|
These are deliberately plain (pydantic) models decoupled from the ORM so that
|
|
SchedulingProvider implementations and the agent can be tested without a database.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import enum
|
|
from datetime import datetime
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
|
|
class BookingStatus(enum.StrEnum):
|
|
pending = "pending"
|
|
resolved_by_owner = "resolved_by_owner" # owner contacted client directly (primary flow)
|
|
confirmed = "confirmed" # owner confirmed a slot → Gogo sends confirmation SMS
|
|
rejected = "rejected"
|
|
expired = "expired"
|
|
|
|
|
|
class CallOutcome(enum.StrEnum):
|
|
human_answered = "human_answered"
|
|
request_created = "request_created"
|
|
info_only = "info_only"
|
|
message_taken = "message_taken"
|
|
abandoned = "abandoned"
|
|
|
|
|
|
class ProviderType(enum.StrEnum):
|
|
google_calendar = "google_calendar"
|
|
partner_api = "partner_api"
|
|
mock = "mock" # in-memory, for tests and demos
|
|
|
|
|
|
class Slot(BaseModel):
|
|
"""One concrete offerable time slot (timezone-aware datetimes)."""
|
|
|
|
start: datetime
|
|
end: datetime
|
|
staff_id: str | None = None
|
|
staff_name: str | None = None
|
|
|
|
def model_post_init(self, __context) -> None:
|
|
if self.start.tzinfo is None or self.end.tzinfo is None:
|
|
raise ValueError("Slot datetimes must be timezone-aware")
|
|
|
|
|
|
class ServiceInfo(BaseModel):
|
|
"""Service as seen by providers / the agent (decoupled from ORM row)."""
|
|
|
|
id: str # our service UUID as string, or partner service id for catalog sync
|
|
name: str
|
|
duration_min: int
|
|
price_min: float | None = None
|
|
price_max: float | None = None
|
|
currency: str = "BAM"
|
|
home_visit: bool = False
|
|
agent_note: str | None = None
|
|
active: bool = True
|
|
|
|
|
|
class BookingRequestData(BaseModel):
|
|
"""Payload the proposal engine hands to a provider's deliver_request()."""
|
|
|
|
gogo_request_id: str
|
|
tenant_id: str
|
|
created_at: datetime
|
|
source: str # "voice" | "chat"
|
|
client_name: str
|
|
client_phone: str
|
|
service_id: str | None = None
|
|
service_name_raw: str = ""
|
|
requested_slots: list[Slot] = Field(default_factory=list) # 0-3, ordered by preference
|
|
time_preference_text: str = ""
|
|
home_visit: bool = False
|
|
address: str | None = None
|
|
summary: str = ""
|
|
transcript_url: str = ""
|
|
dry_run: bool = False
|
|
|
|
|
|
class DeliveryResult(BaseModel):
|
|
ok: bool
|
|
partner_request_id: str | None = None
|
|
detail: str = ""
|