texas-grocery-mcp 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.
- texas_grocery_mcp/__init__.py +3 -0
- texas_grocery_mcp/auth/__init__.py +5 -0
- texas_grocery_mcp/auth/browser_refresh.py +1629 -0
- texas_grocery_mcp/auth/credentials.py +337 -0
- texas_grocery_mcp/auth/session.py +767 -0
- texas_grocery_mcp/clients/__init__.py +5 -0
- texas_grocery_mcp/clients/graphql.py +2400 -0
- texas_grocery_mcp/models/__init__.py +54 -0
- texas_grocery_mcp/models/cart.py +60 -0
- texas_grocery_mcp/models/coupon.py +44 -0
- texas_grocery_mcp/models/errors.py +43 -0
- texas_grocery_mcp/models/health.py +41 -0
- texas_grocery_mcp/models/product.py +274 -0
- texas_grocery_mcp/models/store.py +77 -0
- texas_grocery_mcp/observability/__init__.py +6 -0
- texas_grocery_mcp/observability/health.py +141 -0
- texas_grocery_mcp/observability/logging.py +73 -0
- texas_grocery_mcp/reliability/__init__.py +23 -0
- texas_grocery_mcp/reliability/cache.py +116 -0
- texas_grocery_mcp/reliability/circuit_breaker.py +138 -0
- texas_grocery_mcp/reliability/retry.py +96 -0
- texas_grocery_mcp/reliability/throttle.py +113 -0
- texas_grocery_mcp/server.py +211 -0
- texas_grocery_mcp/services/__init__.py +5 -0
- texas_grocery_mcp/services/geocoding.py +227 -0
- texas_grocery_mcp/state.py +166 -0
- texas_grocery_mcp/tools/__init__.py +5 -0
- texas_grocery_mcp/tools/cart.py +821 -0
- texas_grocery_mcp/tools/coupon.py +381 -0
- texas_grocery_mcp/tools/product.py +437 -0
- texas_grocery_mcp/tools/session.py +486 -0
- texas_grocery_mcp/tools/store.py +353 -0
- texas_grocery_mcp/utils/__init__.py +5 -0
- texas_grocery_mcp/utils/config.py +146 -0
- texas_grocery_mcp/utils/secure_file.py +123 -0
- texas_grocery_mcp-0.1.0.dist-info/METADATA +296 -0
- texas_grocery_mcp-0.1.0.dist-info/RECORD +40 -0
- texas_grocery_mcp-0.1.0.dist-info/WHEEL +4 -0
- texas_grocery_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- texas_grocery_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Data models for Texas Grocery MCP."""
|
|
2
|
+
|
|
3
|
+
from texas_grocery_mcp.models.cart import AppliedCoupon, Cart, CartItem
|
|
4
|
+
from texas_grocery_mcp.models.coupon import Coupon, CouponCategory, CouponSearchResult
|
|
5
|
+
from texas_grocery_mcp.models.errors import AuthRequiredResponse, ErrorResponse
|
|
6
|
+
from texas_grocery_mcp.models.health import (
|
|
7
|
+
CircuitBreakerStatus,
|
|
8
|
+
ComponentHealth,
|
|
9
|
+
HealthResponse,
|
|
10
|
+
)
|
|
11
|
+
from texas_grocery_mcp.models.product import (
|
|
12
|
+
ExtendedNutrition,
|
|
13
|
+
NutrientInfo,
|
|
14
|
+
Product,
|
|
15
|
+
ProductCoupon,
|
|
16
|
+
ProductDetails,
|
|
17
|
+
ProductNutrition,
|
|
18
|
+
ProductSearchAttempt,
|
|
19
|
+
ProductSearchResult,
|
|
20
|
+
)
|
|
21
|
+
from texas_grocery_mcp.models.store import (
|
|
22
|
+
GeocodedLocation,
|
|
23
|
+
SearchAttempt,
|
|
24
|
+
Store,
|
|
25
|
+
StoreHours,
|
|
26
|
+
StoreSearchResult,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"AppliedCoupon",
|
|
31
|
+
"AuthRequiredResponse",
|
|
32
|
+
"Cart",
|
|
33
|
+
"CartItem",
|
|
34
|
+
"CircuitBreakerStatus",
|
|
35
|
+
"ComponentHealth",
|
|
36
|
+
"Coupon",
|
|
37
|
+
"CouponCategory",
|
|
38
|
+
"CouponSearchResult",
|
|
39
|
+
"ErrorResponse",
|
|
40
|
+
"ExtendedNutrition",
|
|
41
|
+
"GeocodedLocation",
|
|
42
|
+
"HealthResponse",
|
|
43
|
+
"NutrientInfo",
|
|
44
|
+
"Product",
|
|
45
|
+
"ProductCoupon",
|
|
46
|
+
"ProductDetails",
|
|
47
|
+
"ProductNutrition",
|
|
48
|
+
"ProductSearchAttempt",
|
|
49
|
+
"ProductSearchResult",
|
|
50
|
+
"SearchAttempt",
|
|
51
|
+
"Store",
|
|
52
|
+
"StoreHours",
|
|
53
|
+
"StoreSearchResult",
|
|
54
|
+
]
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Cart data models."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field, computed_field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class CartItem(BaseModel):
|
|
7
|
+
"""Item in shopping cart."""
|
|
8
|
+
|
|
9
|
+
sku: str = Field(description="Product SKU")
|
|
10
|
+
name: str = Field(description="Product name")
|
|
11
|
+
price: float = Field(description="Unit price")
|
|
12
|
+
quantity: int = Field(ge=1, description="Quantity in cart")
|
|
13
|
+
image_url: str | None = Field(default=None, description="Product image")
|
|
14
|
+
|
|
15
|
+
@computed_field # type: ignore[prop-decorator]
|
|
16
|
+
@property
|
|
17
|
+
def subtotal(self) -> float:
|
|
18
|
+
"""Calculate item subtotal."""
|
|
19
|
+
return round(self.price * self.quantity, 2)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AppliedCoupon(BaseModel):
|
|
23
|
+
"""Coupon applied to cart."""
|
|
24
|
+
|
|
25
|
+
code: str = Field(description="Coupon code")
|
|
26
|
+
discount: float = Field(description="Discount amount")
|
|
27
|
+
description: str | None = Field(default=None, description="Coupon description")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Cart(BaseModel):
|
|
31
|
+
"""Shopping cart."""
|
|
32
|
+
|
|
33
|
+
items: list[CartItem] = Field(default_factory=list, description="Cart items")
|
|
34
|
+
coupons_applied: list[AppliedCoupon] = Field(
|
|
35
|
+
default_factory=list, description="Applied coupons"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
@computed_field # type: ignore[prop-decorator]
|
|
39
|
+
@property
|
|
40
|
+
def subtotal(self) -> float:
|
|
41
|
+
"""Calculate cart subtotal before coupons."""
|
|
42
|
+
return round(sum(item.subtotal for item in self.items), 2)
|
|
43
|
+
|
|
44
|
+
@computed_field # type: ignore[prop-decorator]
|
|
45
|
+
@property
|
|
46
|
+
def total_discount(self) -> float:
|
|
47
|
+
"""Calculate total coupon discount."""
|
|
48
|
+
return round(sum(c.discount for c in self.coupons_applied), 2)
|
|
49
|
+
|
|
50
|
+
@computed_field # type: ignore[prop-decorator]
|
|
51
|
+
@property
|
|
52
|
+
def estimated_total(self) -> float:
|
|
53
|
+
"""Calculate estimated total after discounts."""
|
|
54
|
+
return round(self.subtotal - self.total_discount, 2)
|
|
55
|
+
|
|
56
|
+
@computed_field # type: ignore[prop-decorator]
|
|
57
|
+
@property
|
|
58
|
+
def item_count(self) -> int:
|
|
59
|
+
"""Total number of items in cart."""
|
|
60
|
+
return sum(item.quantity for item in self.items)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Coupon data models."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class CouponCategory(BaseModel):
|
|
7
|
+
"""Coupon category/department."""
|
|
8
|
+
|
|
9
|
+
id: int = Field(description="Category ID")
|
|
10
|
+
name: str = Field(description="Category display name")
|
|
11
|
+
count: int = Field(description="Number of coupons in this category")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Coupon(BaseModel):
|
|
15
|
+
"""HEB digital coupon."""
|
|
16
|
+
|
|
17
|
+
coupon_id: int = Field(description="Unique coupon identifier")
|
|
18
|
+
headline: str = Field(description="Short discount headline (e.g., '25% off', '$2 off')")
|
|
19
|
+
description: str = Field(description="Full description of eligible products")
|
|
20
|
+
expires: str | None = Field(default=None, description="Expiration date (YYYY-MM-DD)")
|
|
21
|
+
expires_display: str | None = Field(default=None, description="Human-readable expiration")
|
|
22
|
+
image_url: str | None = Field(default=None, description="Coupon image URL")
|
|
23
|
+
|
|
24
|
+
# Coupon type and status
|
|
25
|
+
coupon_type: str = Field(default="NORMAL", description="Type: NORMAL, COMBO_LOCO, MEAL_DEAL")
|
|
26
|
+
clipped: bool = Field(default=False, description="Whether user has clipped this coupon")
|
|
27
|
+
redeemable: bool = Field(default=True, description="Whether coupon can be redeemed")
|
|
28
|
+
|
|
29
|
+
# Usage info
|
|
30
|
+
usage_limit: str | None = Field(default=None, description="Usage limit (e.g., 'Limit 1')")
|
|
31
|
+
digital_only: bool = Field(default=False, description="Exclusive digital coupon")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class CouponSearchResult(BaseModel):
|
|
35
|
+
"""Result from coupon list/search."""
|
|
36
|
+
|
|
37
|
+
coupons: list[Coupon] = Field(default_factory=list)
|
|
38
|
+
count: int = Field(description="Number of coupons returned")
|
|
39
|
+
total: int = Field(description="Total coupons available")
|
|
40
|
+
page: int = Field(default=1, description="Current page number")
|
|
41
|
+
categories: list[CouponCategory] = Field(
|
|
42
|
+
default_factory=list,
|
|
43
|
+
description="Available categories",
|
|
44
|
+
)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Error response models."""
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ErrorResponse(BaseModel):
|
|
9
|
+
"""Structured error response."""
|
|
10
|
+
|
|
11
|
+
error: bool = Field(default=True, description="Always true for errors")
|
|
12
|
+
code: str = Field(description="Error code (e.g., HEB_API_TIMEOUT)")
|
|
13
|
+
category: Literal["client", "server", "external"] = Field(
|
|
14
|
+
description="Error category for handling"
|
|
15
|
+
)
|
|
16
|
+
message: str = Field(description="Human-readable error message")
|
|
17
|
+
retry_after_seconds: int | None = Field(
|
|
18
|
+
default=None, description="Seconds to wait before retry"
|
|
19
|
+
)
|
|
20
|
+
fallback_used: bool = Field(
|
|
21
|
+
default=False, description="Whether fallback data source was used"
|
|
22
|
+
)
|
|
23
|
+
fallback_source: str | None = Field(
|
|
24
|
+
default=None, description="Which fallback was used (cache, scraper)"
|
|
25
|
+
)
|
|
26
|
+
suggestions: list[str] = Field(
|
|
27
|
+
default_factory=list, description="Actionable suggestions"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AuthRequiredResponse(BaseModel):
|
|
32
|
+
"""Response when authentication is required."""
|
|
33
|
+
|
|
34
|
+
auth_required: bool = Field(default=True)
|
|
35
|
+
message: str = Field(default="Login required for this operation")
|
|
36
|
+
instructions: list[str] = Field(
|
|
37
|
+
default_factory=lambda: [
|
|
38
|
+
"1. Use Playwright MCP: browser_navigate to 'https://www.heb.com/my-account/login'",
|
|
39
|
+
"2. Complete login in the browser",
|
|
40
|
+
"3. Use Playwright MCP: browser_run_code to save storage state",
|
|
41
|
+
"4. Retry this operation",
|
|
42
|
+
]
|
|
43
|
+
)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Health check response models."""
|
|
2
|
+
|
|
3
|
+
from datetime import UTC, datetime
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ComponentHealth(BaseModel):
|
|
10
|
+
"""Health status of a single component."""
|
|
11
|
+
|
|
12
|
+
status: Literal["up", "down", "degraded"] = Field(description="Component status")
|
|
13
|
+
latency_ms: float | None = Field(default=None, description="Response latency")
|
|
14
|
+
message: str | None = Field(default=None, description="Status message")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CircuitBreakerStatus(BaseModel):
|
|
18
|
+
"""Status of a circuit breaker."""
|
|
19
|
+
|
|
20
|
+
state: Literal["closed", "open", "half_open"] = Field(
|
|
21
|
+
description="Circuit state"
|
|
22
|
+
)
|
|
23
|
+
failures: int = Field(default=0, description="Current failure count")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class HealthResponse(BaseModel):
|
|
27
|
+
"""Health check response."""
|
|
28
|
+
|
|
29
|
+
status: Literal["healthy", "degraded", "unhealthy"] = Field(
|
|
30
|
+
description="Overall health status"
|
|
31
|
+
)
|
|
32
|
+
timestamp: str = Field(
|
|
33
|
+
default_factory=lambda: datetime.now(UTC).isoformat(),
|
|
34
|
+
description="Check timestamp",
|
|
35
|
+
)
|
|
36
|
+
components: dict[str, ComponentHealth] = Field(
|
|
37
|
+
default_factory=dict, description="Component health statuses"
|
|
38
|
+
)
|
|
39
|
+
circuit_breakers: dict[str, CircuitBreakerStatus] = Field(
|
|
40
|
+
default_factory=dict, description="Circuit breaker statuses"
|
|
41
|
+
)
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""Product data models."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ProductNutrition(BaseModel):
|
|
9
|
+
"""Nutritional information."""
|
|
10
|
+
|
|
11
|
+
calories: int | None = None
|
|
12
|
+
protein: str | None = None
|
|
13
|
+
carbohydrates: str | None = None
|
|
14
|
+
fat: str | None = None
|
|
15
|
+
fiber: str | None = None
|
|
16
|
+
sodium: str | None = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ProductCoupon(BaseModel):
|
|
20
|
+
"""Coupon applicable to product."""
|
|
21
|
+
|
|
22
|
+
code: str
|
|
23
|
+
discount: str
|
|
24
|
+
expires: str | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ============================================================================
|
|
28
|
+
# Product Details Models (for product_get tool)
|
|
29
|
+
# ============================================================================
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class NutrientInfo(BaseModel):
|
|
33
|
+
"""Individual nutrient from FDA nutrition facts panel.
|
|
34
|
+
|
|
35
|
+
Supports nested sub_items for nutrients like:
|
|
36
|
+
- Total Fat -> Saturated Fat, Trans Fat, etc.
|
|
37
|
+
- Total Carbohydrate -> Dietary Fiber, Total Sugars, etc.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
title: str = Field(description="Nutrient name (e.g., 'Total Fat')")
|
|
41
|
+
unit: str = Field(description="Amount with unit (e.g., '14g')")
|
|
42
|
+
percentage: str | None = Field(default=None, description="% Daily Value (e.g., '18%')")
|
|
43
|
+
font_modifier: str | None = Field(
|
|
44
|
+
default=None, description="Display style: BOLD, PLAIN, ITALIC"
|
|
45
|
+
)
|
|
46
|
+
sub_items: list["NutrientInfo"] | None = Field(
|
|
47
|
+
default=None, description="Nested nutrients (e.g., Saturated Fat under Total Fat)"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ExtendedNutrition(BaseModel):
|
|
52
|
+
"""Complete FDA-style nutrition facts panel.
|
|
53
|
+
|
|
54
|
+
Matches the structure returned by HEB's nutritionLabels API field.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
serving_size: str | None = Field(default=None, description="e.g., '1 Tbsp (15mL)'")
|
|
58
|
+
servings_per_container: str | None = Field(default=None, description="e.g., 'about 34'")
|
|
59
|
+
calories: str | None = Field(default=None, description="Calories per serving")
|
|
60
|
+
label_modifier: str | None = Field(default=None, description="e.g., '15 mL'")
|
|
61
|
+
nutrients: list[NutrientInfo] = Field(
|
|
62
|
+
default_factory=list,
|
|
63
|
+
description="Main nutrients (fat, cholesterol, sodium, carbs, protein)",
|
|
64
|
+
)
|
|
65
|
+
vitamins_and_minerals: list[NutrientInfo] = Field(
|
|
66
|
+
default_factory=list, description="Vitamins and minerals section"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ProductDetails(BaseModel):
|
|
71
|
+
"""Comprehensive product information from HEB product detail page.
|
|
72
|
+
|
|
73
|
+
Returned by the product_get tool. Contains all available details
|
|
74
|
+
including ingredients, nutrition facts, warnings, and instructions.
|
|
75
|
+
|
|
76
|
+
Note: Many fields are optional as availability varies by product type:
|
|
77
|
+
- Food items: Have nutrition, ingredients, may have warnings
|
|
78
|
+
- Produce: Have ingredients, dietary attributes, no nutrition panel
|
|
79
|
+
- Non-food: Have chemical ingredients, extensive warnings, no nutrition
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
# === Identifiers ===
|
|
83
|
+
product_id: str = Field(description="Product ID (e.g., '127074')")
|
|
84
|
+
sku: str = Field(description="SKU ID (e.g., '4122071073')")
|
|
85
|
+
upc: str | None = Field(default=None, description="12-digit UPC barcode")
|
|
86
|
+
|
|
87
|
+
# === Basic Info ===
|
|
88
|
+
name: str = Field(description="Full product display name")
|
|
89
|
+
description: str | None = Field(
|
|
90
|
+
default=None, description="Product description (may contain HTML)"
|
|
91
|
+
)
|
|
92
|
+
brand: str | None = Field(default=None, description="Brand name")
|
|
93
|
+
is_own_brand: bool = Field(default=False, description="True if HEB own brand")
|
|
94
|
+
|
|
95
|
+
# === Pricing & Availability ===
|
|
96
|
+
price: float = Field(description="Curbside/delivery price")
|
|
97
|
+
price_online: float | None = Field(default=None, description="In-store/online price")
|
|
98
|
+
on_sale: bool = Field(default=False, description="Currently on sale")
|
|
99
|
+
is_price_cut: bool = Field(default=False, description="Price cut active")
|
|
100
|
+
available: bool = Field(description="In stock at store")
|
|
101
|
+
price_per_unit: str | None = Field(default=None, description="e.g., '$0.41 / fl oz'")
|
|
102
|
+
|
|
103
|
+
# === Size ===
|
|
104
|
+
size: str | None = Field(default=None, description="Package size (e.g., '17 oz')")
|
|
105
|
+
|
|
106
|
+
# === Ingredients ===
|
|
107
|
+
ingredients: str | None = Field(
|
|
108
|
+
default=None,
|
|
109
|
+
description="Full ingredients text (string, not list)"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# === Safety Warning ===
|
|
113
|
+
safety_warning: str | None = Field(
|
|
114
|
+
default=None,
|
|
115
|
+
description="Safety/allergen warnings (may include allergen info for food)"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
# === Instructions ===
|
|
119
|
+
instructions: str | None = Field(
|
|
120
|
+
default=None,
|
|
121
|
+
description="Preparation, storage, or usage instructions"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# === Dietary Attributes ===
|
|
125
|
+
dietary_attributes: list[str] = Field(
|
|
126
|
+
default_factory=list,
|
|
127
|
+
description=(
|
|
128
|
+
"Dietary info from lifestyles (e.g., 'Gluten free verified', "
|
|
129
|
+
"'Organic', 'Vegan')"
|
|
130
|
+
),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# === Nutrition (only for packaged food) ===
|
|
134
|
+
nutrition: ExtendedNutrition | None = Field(
|
|
135
|
+
default=None,
|
|
136
|
+
description="Full FDA nutrition panel (null for produce/non-food)"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# === Category ===
|
|
140
|
+
category_path: list[str] = Field(
|
|
141
|
+
default_factory=list,
|
|
142
|
+
description="Category breadcrumb (e.g., ['Shop', 'Pantry', 'Oils'])"
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# === Media ===
|
|
146
|
+
image_url: str | None = Field(default=None, description="Primary product image URL")
|
|
147
|
+
images: list[str] = Field(default_factory=list, description="All product image URLs")
|
|
148
|
+
|
|
149
|
+
# === Store Location ===
|
|
150
|
+
location: str | None = Field(
|
|
151
|
+
default=None,
|
|
152
|
+
description="Store location (e.g., 'Aisle 5' or 'In Produce')"
|
|
153
|
+
)
|
|
154
|
+
store_id: int | None = Field(default=None, description="Store ID for this data")
|
|
155
|
+
|
|
156
|
+
# === Availability Channels ===
|
|
157
|
+
availability_channels: list[str] = Field(
|
|
158
|
+
default_factory=list,
|
|
159
|
+
description="How product can be purchased (e.g., ['IN_STORE', 'CURBSIDE_PICKUP'])"
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
# === SNAP/EBT ===
|
|
163
|
+
is_snap_eligible: bool = Field(default=False, description="SNAP EBT eligible")
|
|
164
|
+
|
|
165
|
+
# === URL ===
|
|
166
|
+
product_url: str | None = Field(default=None, description="Full URL to product page")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class Product(BaseModel):
|
|
170
|
+
"""HEB product information.
|
|
171
|
+
|
|
172
|
+
IMPORTANT FOR CART OPERATIONS:
|
|
173
|
+
- Use `product_id` as the first argument to cart_add
|
|
174
|
+
- Use `sku` as the second argument (sku_id) to cart_add
|
|
175
|
+
|
|
176
|
+
Example:
|
|
177
|
+
cart_add(product_id=product.product_id, sku_id=product.sku, quantity=1)
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
# SKU - the longer identifier needed for cart operations
|
|
181
|
+
sku: str = Field(description="SKU ID (longer numeric ID) - use as sku_id in cart_add")
|
|
182
|
+
name: str = Field(description="Product name")
|
|
183
|
+
price: float = Field(description="Current price")
|
|
184
|
+
available: bool = Field(description="In stock at store")
|
|
185
|
+
|
|
186
|
+
# Product ID - the shorter identifier needed for cart operations
|
|
187
|
+
product_id: str | None = Field(
|
|
188
|
+
default=None,
|
|
189
|
+
description="Product ID (shorter numeric ID) - use as product_id in cart_add"
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
# Standard fields (optional)
|
|
193
|
+
brand: str | None = Field(default=None, description="Brand name")
|
|
194
|
+
size: str | None = Field(default=None, description="Package size")
|
|
195
|
+
price_per_unit: str | None = Field(default=None, description="Unit price display")
|
|
196
|
+
image_url: str | None = Field(default=None, description="Product image URL")
|
|
197
|
+
aisle: str | None = Field(default=None, description="Store aisle number")
|
|
198
|
+
section: str | None = Field(default=None, description="Store section")
|
|
199
|
+
has_coupon: bool = Field(default=False, description="Coupon available for this product")
|
|
200
|
+
|
|
201
|
+
# Extended fields (optional)
|
|
202
|
+
nutrition: ProductNutrition | None = Field(default=None, description="Nutrition facts")
|
|
203
|
+
ingredients: list[str] | None = Field(default=None, description="Ingredient list")
|
|
204
|
+
on_sale: bool = Field(default=False, description="Currently on sale")
|
|
205
|
+
original_price: float | None = Field(default=None, description="Price before sale")
|
|
206
|
+
rating: float | None = Field(default=None, ge=0, le=5, description="Customer rating")
|
|
207
|
+
coupons: list[ProductCoupon] = Field(
|
|
208
|
+
default_factory=list, description="Applicable coupons"
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class ProductSearchAttempt(BaseModel):
|
|
213
|
+
"""Record of a product search attempt for diagnostics."""
|
|
214
|
+
|
|
215
|
+
query: str = Field(description="Query string used")
|
|
216
|
+
method: Literal["ssr", "typeahead_as_ssr", "typeahead"] = Field(
|
|
217
|
+
description="Search method attempted"
|
|
218
|
+
)
|
|
219
|
+
result: Literal["success", "empty", "security_challenge", "error"] = Field(
|
|
220
|
+
description="Result of the attempt"
|
|
221
|
+
)
|
|
222
|
+
error_detail: str | None = Field(default=None, description="Error details if failed")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class ProductSearchResult(BaseModel):
|
|
226
|
+
"""Result of a product search with diagnostic metadata.
|
|
227
|
+
|
|
228
|
+
Provides transparency about the search process, including which methods
|
|
229
|
+
were tried and why fallbacks were used.
|
|
230
|
+
"""
|
|
231
|
+
|
|
232
|
+
# Core results
|
|
233
|
+
products: list[Product] = Field(default_factory=list, description="Found products")
|
|
234
|
+
count: int = Field(description="Number of products found")
|
|
235
|
+
query: str = Field(description="Original search query")
|
|
236
|
+
store_id: str = Field(description="Store ID used for search")
|
|
237
|
+
|
|
238
|
+
# Data source tracking
|
|
239
|
+
data_source: Literal["ssr", "playwright", "typeahead_suggestions"] = Field(
|
|
240
|
+
description="Source of the product data"
|
|
241
|
+
)
|
|
242
|
+
authenticated: bool = Field(
|
|
243
|
+
default=False, description="Whether authenticated search was attempted"
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
# Diagnostic fields
|
|
247
|
+
fallback_reason: str | None = Field(
|
|
248
|
+
default=None, description="Human-readable reason fallback was used"
|
|
249
|
+
)
|
|
250
|
+
security_challenge_detected: bool = Field(
|
|
251
|
+
default=False, description="Whether WAF/captcha was detected"
|
|
252
|
+
)
|
|
253
|
+
attempts: list[ProductSearchAttempt] = Field(
|
|
254
|
+
default_factory=list, description="Query attempts made during search"
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
# Actionable guidance
|
|
258
|
+
search_url: str | None = Field(
|
|
259
|
+
default=None, description="Direct URL to search results on heb.com"
|
|
260
|
+
)
|
|
261
|
+
playwright_fallback_available: bool = Field(
|
|
262
|
+
default=False, description="Whether Playwright can be used as fallback"
|
|
263
|
+
)
|
|
264
|
+
playwright_instructions: list[str] | None = Field(
|
|
265
|
+
default=None, description="Instructions for using Playwright MCP fallback"
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
# Session status fields (for proactive refresh guidance)
|
|
269
|
+
session_needs_refresh: bool = Field(
|
|
270
|
+
default=False, description="Whether session needs refresh before searching"
|
|
271
|
+
)
|
|
272
|
+
session_freshness: dict[str, Any] | None = Field(
|
|
273
|
+
default=None, description="Session freshness details from check_session_freshness()"
|
|
274
|
+
)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Store data models."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class StoreHours(BaseModel):
|
|
7
|
+
"""Store operating hours."""
|
|
8
|
+
|
|
9
|
+
monday: str = Field(alias="mon", default="6am-11pm")
|
|
10
|
+
tuesday: str = Field(alias="tue", default="6am-11pm")
|
|
11
|
+
wednesday: str = Field(alias="wed", default="6am-11pm")
|
|
12
|
+
thursday: str = Field(alias="thu", default="6am-11pm")
|
|
13
|
+
friday: str = Field(alias="fri", default="6am-11pm")
|
|
14
|
+
saturday: str = Field(alias="sat", default="6am-11pm")
|
|
15
|
+
sunday: str = Field(alias="sun", default="6am-11pm")
|
|
16
|
+
|
|
17
|
+
model_config = {"populate_by_name": True}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Store(BaseModel):
|
|
21
|
+
"""HEB store information."""
|
|
22
|
+
|
|
23
|
+
store_id: str = Field(description="Unique store identifier")
|
|
24
|
+
name: str = Field(description="Store display name")
|
|
25
|
+
address: str = Field(description="Full street address")
|
|
26
|
+
phone: str | None = Field(default=None, description="Store phone number")
|
|
27
|
+
distance_miles: float | None = Field(
|
|
28
|
+
default=None, description="Distance from search location"
|
|
29
|
+
)
|
|
30
|
+
hours: StoreHours | None = Field(default=None, description="Operating hours")
|
|
31
|
+
services: list[str] = Field(
|
|
32
|
+
default_factory=list,
|
|
33
|
+
description="Available services (curbside, delivery, pharmacy)",
|
|
34
|
+
)
|
|
35
|
+
latitude: float | None = Field(default=None, description="Store latitude")
|
|
36
|
+
longitude: float | None = Field(default=None, description="Store longitude")
|
|
37
|
+
supports_curbside: bool = Field(
|
|
38
|
+
default=True,
|
|
39
|
+
description="Whether store supports curbside pickup for online orders",
|
|
40
|
+
)
|
|
41
|
+
supports_delivery: bool = Field(
|
|
42
|
+
default=False,
|
|
43
|
+
description="Whether store supports home delivery",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class GeocodedLocation(BaseModel):
|
|
48
|
+
"""Geocoded location information."""
|
|
49
|
+
|
|
50
|
+
latitude: float = Field(description="Latitude of geocoded location")
|
|
51
|
+
longitude: float = Field(description="Longitude of geocoded location")
|
|
52
|
+
display_name: str = Field(description="Human-readable location name")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class SearchAttempt(BaseModel):
|
|
56
|
+
"""Record of a store search attempt."""
|
|
57
|
+
|
|
58
|
+
query: str = Field(description="Query string used")
|
|
59
|
+
result: str = Field(description="Result: 'success', 'no_stores', 'error'")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class StoreSearchResult(BaseModel):
|
|
63
|
+
"""Result of a store search including metadata."""
|
|
64
|
+
|
|
65
|
+
stores: list[Store] = Field(default_factory=list, description="Found stores")
|
|
66
|
+
count: int = Field(description="Number of stores found")
|
|
67
|
+
search_address: str = Field(description="Original search address")
|
|
68
|
+
geocoded: GeocodedLocation | None = Field(
|
|
69
|
+
default=None, description="Geocoded location if available"
|
|
70
|
+
)
|
|
71
|
+
attempts: list[SearchAttempt] = Field(
|
|
72
|
+
default_factory=list, description="Query attempts made"
|
|
73
|
+
)
|
|
74
|
+
error: str | None = Field(default=None, description="Error message if search failed")
|
|
75
|
+
suggestions: list[str] = Field(
|
|
76
|
+
default_factory=list, description="Suggestions for improving search"
|
|
77
|
+
)
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Observability: logging, metrics, and health checks."""
|
|
2
|
+
|
|
3
|
+
from texas_grocery_mcp.observability.health import health_live, health_ready
|
|
4
|
+
from texas_grocery_mcp.observability.logging import configure_logging, get_logger
|
|
5
|
+
|
|
6
|
+
__all__ = ["configure_logging", "get_logger", "health_live", "health_ready"]
|