ai-lls-lib 1.4.0rc2__py3-none-any.whl → 1.4.0rc4__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 (34) hide show
  1. ai_lls_lib/__init__.py +1 -1
  2. ai_lls_lib/auth/__init__.py +4 -4
  3. ai_lls_lib/auth/context_parser.py +68 -68
  4. ai_lls_lib/cli/__init__.py +3 -3
  5. ai_lls_lib/cli/__main__.py +30 -30
  6. ai_lls_lib/cli/aws_client.py +115 -115
  7. ai_lls_lib/cli/commands/__init__.py +3 -3
  8. ai_lls_lib/cli/commands/admin.py +174 -174
  9. ai_lls_lib/cli/commands/cache.py +142 -142
  10. ai_lls_lib/cli/commands/stripe.py +377 -377
  11. ai_lls_lib/cli/commands/test_stack.py +216 -216
  12. ai_lls_lib/cli/commands/verify.py +111 -111
  13. ai_lls_lib/cli/env_loader.py +122 -122
  14. ai_lls_lib/core/__init__.py +3 -3
  15. ai_lls_lib/core/cache.py +106 -106
  16. ai_lls_lib/core/models.py +77 -77
  17. ai_lls_lib/core/processor.py +295 -295
  18. ai_lls_lib/core/verifier.py +84 -84
  19. ai_lls_lib/payment/__init__.py +13 -13
  20. ai_lls_lib/payment/credit_manager.py +186 -193
  21. ai_lls_lib/payment/models.py +102 -102
  22. ai_lls_lib/payment/stripe_manager.py +487 -487
  23. ai_lls_lib/payment/webhook_processor.py +215 -215
  24. ai_lls_lib/providers/__init__.py +7 -7
  25. ai_lls_lib/providers/base.py +28 -28
  26. ai_lls_lib/providers/external.py +87 -87
  27. ai_lls_lib/providers/stub.py +48 -48
  28. ai_lls_lib/testing/__init__.py +3 -3
  29. ai_lls_lib/testing/fixtures.py +104 -104
  30. {ai_lls_lib-1.4.0rc2.dist-info → ai_lls_lib-1.4.0rc4.dist-info}/METADATA +1 -1
  31. ai_lls_lib-1.4.0rc4.dist-info/RECORD +33 -0
  32. ai_lls_lib-1.4.0rc2.dist-info/RECORD +0 -33
  33. {ai_lls_lib-1.4.0rc2.dist-info → ai_lls_lib-1.4.0rc4.dist-info}/WHEEL +0 -0
  34. {ai_lls_lib-1.4.0rc2.dist-info → ai_lls_lib-1.4.0rc4.dist-info}/entry_points.txt +0 -0
@@ -1,174 +1,174 @@
1
- """
2
- Administrative commands for user and credit management
3
- """
4
- import click
5
- import json
6
- from datetime import datetime
7
- from ai_lls_lib.cli.aws_client import AWSClient
8
-
9
- @click.group(name="admin")
10
- def admin_group():
11
- """Administrative commands"""
12
- pass
13
-
14
- @admin_group.command(name="user-credits")
15
- @click.argument("user_id")
16
- @click.option("--add", type=int, help="Add credits")
17
- @click.option("--set", "set_credits", type=int, help="Set credits to specific value")
18
- @click.option("--stack", default="landline-api", help="CloudFormation stack name")
19
- @click.option("--profile", help="AWS profile")
20
- @click.option("--region", help="AWS region")
21
- def user_credits(user_id, add, set_credits, stack, profile, region):
22
- """Manage user credits"""
23
- aws = AWSClient(region=region, profile=profile)
24
- credits_table = aws.get_table_name(stack, "CreditsTable")
25
-
26
- try:
27
- table = aws.dynamodb.Table(credits_table)
28
-
29
- # Get current credits
30
- response = table.get_item(Key={"user_id": user_id})
31
- current = response.get('Item', {}).get('credits', 0)
32
-
33
- click.echo(f"User {user_id} - Current credits: {current}")
34
-
35
- if add:
36
- new_credits = current + add
37
- table.update_item(
38
- Key={"user_id": user_id},
39
- UpdateExpression="SET credits = :val, updated_at = :now",
40
- ExpressionAttributeValues={
41
- ":val": new_credits,
42
- ":now": datetime.utcnow().isoformat()
43
- }
44
- )
45
- click.echo(f"Added {add} credits. New balance: {new_credits}")
46
-
47
- elif set_credits is not None:
48
- table.update_item(
49
- Key={"user_id": user_id},
50
- UpdateExpression="SET credits = :val, updated_at = :now",
51
- ExpressionAttributeValues={
52
- ":val": set_credits,
53
- ":now": datetime.utcnow().isoformat()
54
- }
55
- )
56
- click.echo(f"Set credits to {set_credits}")
57
-
58
- except Exception as e:
59
- click.echo(f"Error managing credits: {e}", err=True)
60
-
61
- @admin_group.command(name="api-keys")
62
- @click.option("--user", help="Filter by user ID")
63
- @click.option("--stack", default="landline-api", help="CloudFormation stack name")
64
- @click.option("--profile", help="AWS profile")
65
- @click.option("--region", help="AWS region")
66
- def list_api_keys(user, stack, profile, region):
67
- """List API keys"""
68
- aws = AWSClient(region=region, profile=profile)
69
- keys_table = aws.get_table_name(stack, "ApiKeysTable")
70
-
71
- try:
72
- if user:
73
- # Query by user using GSI
74
- table = aws.dynamodb.Table(keys_table)
75
- response = table.query(
76
- IndexName='gsi_user',
77
- KeyConditionExpression='user_id = :uid',
78
- ExpressionAttributeValues={':uid': user}
79
- )
80
- items = response.get('Items', [])
81
- else:
82
- # Scan all
83
- items = aws.scan_table(keys_table)
84
-
85
- if not items:
86
- click.echo("No API keys found")
87
- return
88
-
89
- click.echo(f"\nAPI Keys ({len(items)} total):")
90
- click.echo("=" * 60)
91
-
92
- for item in items:
93
- click.echo(f"Key ID: {item.get('api_key_id', 'N/A')}")
94
- click.echo(f"User: {item.get('user_id', 'N/A')}")
95
- click.echo(f"Created: {item.get('created_at', 'N/A')}")
96
- click.echo(f"Last Used: {item.get('last_used', 'Never')}")
97
- click.echo("-" * 60)
98
-
99
- except Exception as e:
100
- click.echo(f"Error listing API keys: {e}", err=True)
101
-
102
- @admin_group.command(name="queue-stats")
103
- @click.option("--stack", default="landline-api", help="CloudFormation stack name")
104
- @click.option("--profile", help="AWS profile")
105
- @click.option("--region", help="AWS region")
106
- def queue_stats(stack, profile, region):
107
- """Show SQS queue statistics"""
108
- aws = AWSClient(region=region, profile=profile)
109
-
110
- try:
111
- # Get queue URLs from stack
112
- outputs = aws.get_stack_outputs(stack)
113
-
114
- # Find queue URLs or construct them
115
- queue_name = f"{stack}-bulk-processing"
116
- dlq_name = f"{stack}-bulk-processing-dlq"
117
-
118
- # Get queue attributes
119
- queues = [
120
- ("Processing Queue", queue_name),
121
- ("Dead Letter Queue", dlq_name)
122
- ]
123
-
124
- for display_name, queue in queues:
125
- try:
126
- response = aws.sqs.get_queue_url(QueueName=queue)
127
- queue_url = response['QueueUrl']
128
-
129
- attrs = aws.sqs.get_queue_attributes(
130
- QueueUrl=queue_url,
131
- AttributeNames=['All']
132
- )['Attributes']
133
-
134
- click.echo(f"\n{display_name}:")
135
- click.echo(f" Messages Available: {attrs.get('ApproximateNumberOfMessages', 0)}")
136
- click.echo(f" Messages In Flight: {attrs.get('ApproximateNumberOfMessagesNotVisible', 0)}")
137
- click.echo(f" Messages Delayed: {attrs.get('ApproximateNumberOfMessagesDelayed', 0)}")
138
-
139
- except aws.sqs.exceptions.QueueDoesNotExist:
140
- click.echo(f"\n{display_name}: Not found")
141
-
142
- except Exception as e:
143
- click.echo(f"Error getting queue stats: {e}", err=True)
144
-
145
- @admin_group.command(name="secrets")
146
- @click.option("--stack", default="landline-api", help="CloudFormation stack name")
147
- @click.option("--show", is_flag=True, help="Show secret values (CAREFUL!)")
148
- @click.option("--profile", help="AWS profile")
149
- @click.option("--region", help="AWS region")
150
- def manage_secrets(stack, show, profile, region):
151
- """Manage secrets"""
152
- aws = AWSClient(region=region, profile=profile)
153
- secret_name = f"{stack}-secrets"
154
-
155
- try:
156
- response = aws.secretsmanager.describe_secret(SecretId=secret_name)
157
- click.echo(f"\nSecret: {secret_name}")
158
- click.echo(f"ARN: {response['ARN']}")
159
- click.echo(f"Last Updated: {response.get('LastChangedDate', 'N/A')}")
160
-
161
- if show and click.confirm("Show secret values? This will display sensitive data!"):
162
- secret_value = aws.secretsmanager.get_secret_value(SecretId=secret_name)
163
- secrets = json.loads(secret_value['SecretString'])
164
-
165
- click.echo("\nSecret Values:")
166
- for key, value in secrets.items():
167
- # Mask most of the value
168
- masked = value[:4] + "*" * (len(value) - 8) + value[-4:] if len(value) > 8 else "*" * len(value)
169
- click.echo(f" {key}: {masked}")
170
-
171
- except aws.secretsmanager.exceptions.ResourceNotFoundException:
172
- click.echo(f"Secret '{secret_name}' not found")
173
- except Exception as e:
174
- click.echo(f"Error managing secrets: {e}", err=True)
1
+ """
2
+ Administrative commands for user and credit management
3
+ """
4
+ import click
5
+ import json
6
+ from datetime import datetime
7
+ from ai_lls_lib.cli.aws_client import AWSClient
8
+
9
+ @click.group(name="admin")
10
+ def admin_group():
11
+ """Administrative commands"""
12
+ pass
13
+
14
+ @admin_group.command(name="user-credits")
15
+ @click.argument("user_id")
16
+ @click.option("--add", type=int, help="Add credits")
17
+ @click.option("--set", "set_credits", type=int, help="Set credits to specific value")
18
+ @click.option("--stack", default="landline-api", help="CloudFormation stack name")
19
+ @click.option("--profile", help="AWS profile")
20
+ @click.option("--region", help="AWS region")
21
+ def user_credits(user_id, add, set_credits, stack, profile, region):
22
+ """Manage user credits"""
23
+ aws = AWSClient(region=region, profile=profile)
24
+ credits_table = aws.get_table_name(stack, "CreditsTable")
25
+
26
+ try:
27
+ table = aws.dynamodb.Table(credits_table)
28
+
29
+ # Get current credits
30
+ response = table.get_item(Key={"user_id": user_id})
31
+ current = response.get('Item', {}).get('credits', 0)
32
+
33
+ click.echo(f"User {user_id} - Current credits: {current}")
34
+
35
+ if add:
36
+ new_credits = current + add
37
+ table.update_item(
38
+ Key={"user_id": user_id},
39
+ UpdateExpression="SET credits = :val, updated_at = :now",
40
+ ExpressionAttributeValues={
41
+ ":val": new_credits,
42
+ ":now": datetime.utcnow().isoformat()
43
+ }
44
+ )
45
+ click.echo(f"Added {add} credits. New balance: {new_credits}")
46
+
47
+ elif set_credits is not None:
48
+ table.update_item(
49
+ Key={"user_id": user_id},
50
+ UpdateExpression="SET credits = :val, updated_at = :now",
51
+ ExpressionAttributeValues={
52
+ ":val": set_credits,
53
+ ":now": datetime.utcnow().isoformat()
54
+ }
55
+ )
56
+ click.echo(f"Set credits to {set_credits}")
57
+
58
+ except Exception as e:
59
+ click.echo(f"Error managing credits: {e}", err=True)
60
+
61
+ @admin_group.command(name="api-keys")
62
+ @click.option("--user", help="Filter by user ID")
63
+ @click.option("--stack", default="landline-api", help="CloudFormation stack name")
64
+ @click.option("--profile", help="AWS profile")
65
+ @click.option("--region", help="AWS region")
66
+ def list_api_keys(user, stack, profile, region):
67
+ """List API keys"""
68
+ aws = AWSClient(region=region, profile=profile)
69
+ keys_table = aws.get_table_name(stack, "ApiKeysTable")
70
+
71
+ try:
72
+ if user:
73
+ # Query by user using GSI
74
+ table = aws.dynamodb.Table(keys_table)
75
+ response = table.query(
76
+ IndexName='gsi_user',
77
+ KeyConditionExpression='user_id = :uid',
78
+ ExpressionAttributeValues={':uid': user}
79
+ )
80
+ items = response.get('Items', [])
81
+ else:
82
+ # Scan all
83
+ items = aws.scan_table(keys_table)
84
+
85
+ if not items:
86
+ click.echo("No API keys found")
87
+ return
88
+
89
+ click.echo(f"\nAPI Keys ({len(items)} total):")
90
+ click.echo("=" * 60)
91
+
92
+ for item in items:
93
+ click.echo(f"Key ID: {item.get('api_key_id', 'N/A')}")
94
+ click.echo(f"User: {item.get('user_id', 'N/A')}")
95
+ click.echo(f"Created: {item.get('created_at', 'N/A')}")
96
+ click.echo(f"Last Used: {item.get('last_used', 'Never')}")
97
+ click.echo("-" * 60)
98
+
99
+ except Exception as e:
100
+ click.echo(f"Error listing API keys: {e}", err=True)
101
+
102
+ @admin_group.command(name="queue-stats")
103
+ @click.option("--stack", default="landline-api", help="CloudFormation stack name")
104
+ @click.option("--profile", help="AWS profile")
105
+ @click.option("--region", help="AWS region")
106
+ def queue_stats(stack, profile, region):
107
+ """Show SQS queue statistics"""
108
+ aws = AWSClient(region=region, profile=profile)
109
+
110
+ try:
111
+ # Get queue URLs from stack
112
+ outputs = aws.get_stack_outputs(stack)
113
+
114
+ # Find queue URLs or construct them
115
+ queue_name = f"{stack}-bulk-processing"
116
+ dlq_name = f"{stack}-bulk-processing-dlq"
117
+
118
+ # Get queue attributes
119
+ queues = [
120
+ ("Processing Queue", queue_name),
121
+ ("Dead Letter Queue", dlq_name)
122
+ ]
123
+
124
+ for display_name, queue in queues:
125
+ try:
126
+ response = aws.sqs.get_queue_url(QueueName=queue)
127
+ queue_url = response['QueueUrl']
128
+
129
+ attrs = aws.sqs.get_queue_attributes(
130
+ QueueUrl=queue_url,
131
+ AttributeNames=['All']
132
+ )['Attributes']
133
+
134
+ click.echo(f"\n{display_name}:")
135
+ click.echo(f" Messages Available: {attrs.get('ApproximateNumberOfMessages', 0)}")
136
+ click.echo(f" Messages In Flight: {attrs.get('ApproximateNumberOfMessagesNotVisible', 0)}")
137
+ click.echo(f" Messages Delayed: {attrs.get('ApproximateNumberOfMessagesDelayed', 0)}")
138
+
139
+ except aws.sqs.exceptions.QueueDoesNotExist:
140
+ click.echo(f"\n{display_name}: Not found")
141
+
142
+ except Exception as e:
143
+ click.echo(f"Error getting queue stats: {e}", err=True)
144
+
145
+ @admin_group.command(name="secrets")
146
+ @click.option("--stack", default="landline-api", help="CloudFormation stack name")
147
+ @click.option("--show", is_flag=True, help="Show secret values (CAREFUL!)")
148
+ @click.option("--profile", help="AWS profile")
149
+ @click.option("--region", help="AWS region")
150
+ def manage_secrets(stack, show, profile, region):
151
+ """Manage secrets"""
152
+ aws = AWSClient(region=region, profile=profile)
153
+ secret_name = f"{stack}-secrets"
154
+
155
+ try:
156
+ response = aws.secretsmanager.describe_secret(SecretId=secret_name)
157
+ click.echo(f"\nSecret: {secret_name}")
158
+ click.echo(f"ARN: {response['ARN']}")
159
+ click.echo(f"Last Updated: {response.get('LastChangedDate', 'N/A')}")
160
+
161
+ if show and click.confirm("Show secret values? This will display sensitive data!"):
162
+ secret_value = aws.secretsmanager.get_secret_value(SecretId=secret_name)
163
+ secrets = json.loads(secret_value['SecretString'])
164
+
165
+ click.echo("\nSecret Values:")
166
+ for key, value in secrets.items():
167
+ # Mask most of the value
168
+ masked = value[:4] + "*" * (len(value) - 8) + value[-4:] if len(value) > 8 else "*" * len(value)
169
+ click.echo(f" {key}: {masked}")
170
+
171
+ except aws.secretsmanager.exceptions.ResourceNotFoundException:
172
+ click.echo(f"Secret '{secret_name}' not found")
173
+ except Exception as e:
174
+ click.echo(f"Error managing secrets: {e}", err=True)
@@ -1,142 +1,142 @@
1
- """
2
- Cache management commands
3
- """
4
- import click
5
- import json
6
- from datetime import datetime, timedelta
7
- from ai_lls_lib.cli.aws_client import AWSClient
8
-
9
- @click.group(name="cache")
10
- def cache_group():
11
- """Cache management commands"""
12
- pass
13
-
14
- @cache_group.command(name="stats")
15
- @click.option("--stack", default="landline-api", help="CloudFormation stack name")
16
- @click.option("--profile", help="AWS profile")
17
- @click.option("--region", help="AWS region")
18
- def cache_stats(stack, profile, region):
19
- """Show cache statistics"""
20
- aws = AWSClient(region=region, profile=profile)
21
- cache_table = aws.get_table_name(stack, "PhoneCacheTable")
22
-
23
- try:
24
- # Get table description for item count
25
- table = aws.dynamodb.Table(cache_table)
26
- desc = table.meta.client.describe_table(TableName=cache_table)
27
-
28
- click.echo(f"\nCache Table: {cache_table}")
29
- click.echo("=" * 50)
30
- click.echo(f"Item Count: {desc['Table']['ItemCount']:,}")
31
- click.echo(f"Table Size: {desc['Table']['TableSizeBytes']:,} bytes")
32
- click.echo(f"Status: {desc['Table']['TableStatus']}")
33
-
34
- # Sample some items to show age distribution
35
- items = aws.scan_table(cache_table, limit=100)
36
- if items:
37
- now = datetime.utcnow()
38
- ages = []
39
- for item in items:
40
- if 'verified_at' in item:
41
- verified = datetime.fromisoformat(item['verified_at'].replace('Z', '+00:00'))
42
- age = (now - verified.replace(tzinfo=None)).days
43
- ages.append(age)
44
-
45
- if ages:
46
- click.echo(f"\nCache Age (sample of {len(ages)} items):")
47
- click.echo(f" Newest: {min(ages)} days")
48
- click.echo(f" Oldest: {max(ages)} days")
49
- click.echo(f" Average: {sum(ages)/len(ages):.1f} days")
50
-
51
- except Exception as e:
52
- click.echo(f"Error getting cache stats: {e}", err=True)
53
-
54
- @cache_group.command(name="get")
55
- @click.argument("phone_number")
56
- @click.option("--stack", default="landline-api", help="CloudFormation stack name")
57
- @click.option("--profile", help="AWS profile")
58
- @click.option("--region", help="AWS region")
59
- def cache_get(phone_number, stack, profile, region):
60
- """Get cached entry for a phone number"""
61
- from ai_lls_lib.core.verifier import PhoneVerifier
62
- from ai_lls_lib.core.cache import DynamoDBCache
63
-
64
- aws = AWSClient(region=region, profile=profile)
65
- cache_table = aws.get_table_name(stack, "PhoneCacheTable")
66
-
67
- # Normalize phone first
68
- verifier = PhoneVerifier(cache=DynamoDBCache(table_name=cache_table))
69
- try:
70
- normalized = verifier.normalize_phone(phone_number)
71
- except ValueError as e:
72
- click.echo(f"Invalid phone: {e}", err=True)
73
- return
74
-
75
- # Get from cache
76
- cache = DynamoDBCache(table_name=cache_table)
77
- result = cache.get(normalized)
78
-
79
- if result:
80
- click.echo(f"\nCached entry for {normalized}:")
81
- click.echo(json.dumps(result.dict() if hasattr(result, 'dict') else result, indent=2, default=str))
82
- else:
83
- click.echo(f"No cache entry found for {normalized}")
84
-
85
- @cache_group.command(name="invalidate")
86
- @click.argument("phone_number")
87
- @click.option("--stack", default="landline-api", help="CloudFormation stack name")
88
- @click.option("--profile", help="AWS profile")
89
- @click.option("--region", help="AWS region")
90
- @click.confirmation_option(prompt="Are you sure you want to invalidate this cache entry?")
91
- def cache_invalidate(phone_number, stack, profile, region):
92
- """Invalidate cached entry for a phone number"""
93
- from ai_lls_lib.core.verifier import PhoneVerifier
94
- from ai_lls_lib.core.cache import DynamoDBCache
95
-
96
- aws = AWSClient(region=region, profile=profile)
97
- cache_table = aws.get_table_name(stack, "PhoneCacheTable")
98
-
99
- # Normalize phone
100
- verifier = PhoneVerifier(cache=DynamoDBCache(table_name=cache_table))
101
- try:
102
- normalized = verifier.normalize_phone(phone_number)
103
- except ValueError as e:
104
- click.echo(f"Invalid phone: {e}", err=True)
105
- return
106
-
107
- # Delete from cache
108
- try:
109
- aws.delete_item(cache_table, {"phone_number": normalized})
110
- click.echo(f"Cache entry invalidated for {normalized}")
111
- except Exception as e:
112
- click.echo(f"Error invalidating cache: {e}", err=True)
113
-
114
- @cache_group.command(name="clear")
115
- @click.option("--stack", default="landline-api", help="CloudFormation stack name")
116
- @click.option("--older-than", type=int, help="Clear entries older than N days")
117
- @click.option("--profile", help="AWS profile")
118
- @click.option("--region", help="AWS region")
119
- @click.confirmation_option(prompt="This will clear cache entries. Continue?")
120
- def cache_clear(stack, older_than, profile, region):
121
- """Clear cache entries"""
122
- aws = AWSClient(region=region, profile=profile)
123
- cache_table = aws.get_table_name(stack, "PhoneCacheTable")
124
-
125
- if older_than:
126
- click.echo(f"Clearing entries older than {older_than} days...")
127
- cutoff = datetime.utcnow() - timedelta(days=older_than)
128
-
129
- # Scan and delete old entries
130
- items = aws.scan_table(cache_table, limit=1000)
131
- deleted = 0
132
-
133
- for item in items:
134
- if 'verified_at' in item:
135
- verified = datetime.fromisoformat(item['verified_at'].replace('Z', '+00:00'))
136
- if verified.replace(tzinfo=None) < cutoff:
137
- aws.delete_item(cache_table, {"phone_number": item['phone_number']})
138
- deleted += 1
139
-
140
- click.echo(f"Deleted {deleted} entries older than {older_than} days")
141
- else:
142
- click.echo("Full cache clear not implemented for safety. Use --older-than option.")
1
+ """
2
+ Cache management commands
3
+ """
4
+ import click
5
+ import json
6
+ from datetime import datetime, timedelta
7
+ from ai_lls_lib.cli.aws_client import AWSClient
8
+
9
+ @click.group(name="cache")
10
+ def cache_group():
11
+ """Cache management commands"""
12
+ pass
13
+
14
+ @cache_group.command(name="stats")
15
+ @click.option("--stack", default="landline-api", help="CloudFormation stack name")
16
+ @click.option("--profile", help="AWS profile")
17
+ @click.option("--region", help="AWS region")
18
+ def cache_stats(stack, profile, region):
19
+ """Show cache statistics"""
20
+ aws = AWSClient(region=region, profile=profile)
21
+ cache_table = aws.get_table_name(stack, "PhoneCacheTable")
22
+
23
+ try:
24
+ # Get table description for item count
25
+ table = aws.dynamodb.Table(cache_table)
26
+ desc = table.meta.client.describe_table(TableName=cache_table)
27
+
28
+ click.echo(f"\nCache Table: {cache_table}")
29
+ click.echo("=" * 50)
30
+ click.echo(f"Item Count: {desc['Table']['ItemCount']:,}")
31
+ click.echo(f"Table Size: {desc['Table']['TableSizeBytes']:,} bytes")
32
+ click.echo(f"Status: {desc['Table']['TableStatus']}")
33
+
34
+ # Sample some items to show age distribution
35
+ items = aws.scan_table(cache_table, limit=100)
36
+ if items:
37
+ now = datetime.utcnow()
38
+ ages = []
39
+ for item in items:
40
+ if 'verified_at' in item:
41
+ verified = datetime.fromisoformat(item['verified_at'].replace('Z', '+00:00'))
42
+ age = (now - verified.replace(tzinfo=None)).days
43
+ ages.append(age)
44
+
45
+ if ages:
46
+ click.echo(f"\nCache Age (sample of {len(ages)} items):")
47
+ click.echo(f" Newest: {min(ages)} days")
48
+ click.echo(f" Oldest: {max(ages)} days")
49
+ click.echo(f" Average: {sum(ages)/len(ages):.1f} days")
50
+
51
+ except Exception as e:
52
+ click.echo(f"Error getting cache stats: {e}", err=True)
53
+
54
+ @cache_group.command(name="get")
55
+ @click.argument("phone_number")
56
+ @click.option("--stack", default="landline-api", help="CloudFormation stack name")
57
+ @click.option("--profile", help="AWS profile")
58
+ @click.option("--region", help="AWS region")
59
+ def cache_get(phone_number, stack, profile, region):
60
+ """Get cached entry for a phone number"""
61
+ from ai_lls_lib.core.verifier import PhoneVerifier
62
+ from ai_lls_lib.core.cache import DynamoDBCache
63
+
64
+ aws = AWSClient(region=region, profile=profile)
65
+ cache_table = aws.get_table_name(stack, "PhoneCacheTable")
66
+
67
+ # Normalize phone first
68
+ verifier = PhoneVerifier(cache=DynamoDBCache(table_name=cache_table))
69
+ try:
70
+ normalized = verifier.normalize_phone(phone_number)
71
+ except ValueError as e:
72
+ click.echo(f"Invalid phone: {e}", err=True)
73
+ return
74
+
75
+ # Get from cache
76
+ cache = DynamoDBCache(table_name=cache_table)
77
+ result = cache.get(normalized)
78
+
79
+ if result:
80
+ click.echo(f"\nCached entry for {normalized}:")
81
+ click.echo(json.dumps(result.dict() if hasattr(result, 'dict') else result, indent=2, default=str))
82
+ else:
83
+ click.echo(f"No cache entry found for {normalized}")
84
+
85
+ @cache_group.command(name="invalidate")
86
+ @click.argument("phone_number")
87
+ @click.option("--stack", default="landline-api", help="CloudFormation stack name")
88
+ @click.option("--profile", help="AWS profile")
89
+ @click.option("--region", help="AWS region")
90
+ @click.confirmation_option(prompt="Are you sure you want to invalidate this cache entry?")
91
+ def cache_invalidate(phone_number, stack, profile, region):
92
+ """Invalidate cached entry for a phone number"""
93
+ from ai_lls_lib.core.verifier import PhoneVerifier
94
+ from ai_lls_lib.core.cache import DynamoDBCache
95
+
96
+ aws = AWSClient(region=region, profile=profile)
97
+ cache_table = aws.get_table_name(stack, "PhoneCacheTable")
98
+
99
+ # Normalize phone
100
+ verifier = PhoneVerifier(cache=DynamoDBCache(table_name=cache_table))
101
+ try:
102
+ normalized = verifier.normalize_phone(phone_number)
103
+ except ValueError as e:
104
+ click.echo(f"Invalid phone: {e}", err=True)
105
+ return
106
+
107
+ # Delete from cache
108
+ try:
109
+ aws.delete_item(cache_table, {"phone_number": normalized})
110
+ click.echo(f"Cache entry invalidated for {normalized}")
111
+ except Exception as e:
112
+ click.echo(f"Error invalidating cache: {e}", err=True)
113
+
114
+ @cache_group.command(name="clear")
115
+ @click.option("--stack", default="landline-api", help="CloudFormation stack name")
116
+ @click.option("--older-than", type=int, help="Clear entries older than N days")
117
+ @click.option("--profile", help="AWS profile")
118
+ @click.option("--region", help="AWS region")
119
+ @click.confirmation_option(prompt="This will clear cache entries. Continue?")
120
+ def cache_clear(stack, older_than, profile, region):
121
+ """Clear cache entries"""
122
+ aws = AWSClient(region=region, profile=profile)
123
+ cache_table = aws.get_table_name(stack, "PhoneCacheTable")
124
+
125
+ if older_than:
126
+ click.echo(f"Clearing entries older than {older_than} days...")
127
+ cutoff = datetime.utcnow() - timedelta(days=older_than)
128
+
129
+ # Scan and delete old entries
130
+ items = aws.scan_table(cache_table, limit=1000)
131
+ deleted = 0
132
+
133
+ for item in items:
134
+ if 'verified_at' in item:
135
+ verified = datetime.fromisoformat(item['verified_at'].replace('Z', '+00:00'))
136
+ if verified.replace(tzinfo=None) < cutoff:
137
+ aws.delete_item(cache_table, {"phone_number": item['phone_number']})
138
+ deleted += 1
139
+
140
+ click.echo(f"Deleted {deleted} entries older than {older_than} days")
141
+ else:
142
+ click.echo("Full cache clear not implemented for safety. Use --older-than option.")