cloudshellgpt 1.0.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.
@@ -0,0 +1,1080 @@
1
+ """Intent parser — converts natural language into structured Intent objects."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Any, Literal, get_args
7
+
8
+ import langdetect
9
+ import langdetect.detector_factory
10
+ from pydantic import BaseModel, Field
11
+
12
+ # Seed para determinismo en langdetect
13
+ langdetect.detector_factory.LangDetectException = (
14
+ langdetect.lang_detect_exception.LangDetectException
15
+ )
16
+ langdetect.DetectorFactory.seed = 0
17
+
18
+ # --- Constants ---
19
+
20
+ SUPPORTED_LANGUAGES = ("es", "en", "pt", "fr", "de", "zh-cn", "zh-tw", "zh")
21
+
22
+ ActionType = Literal["list", "create", "delete", "update", "describe", "invoke", "unknown"]
23
+
24
+ # Keywords por servicio, multi-idioma: ES, EN, PT, FR, DE, ZH
25
+ SERVICE_KEYWORDS: dict[str, list[str]] = {
26
+ "s3": [
27
+ "s3",
28
+ "bucket",
29
+ "buckets",
30
+ # ES
31
+ "almacenamiento",
32
+ # PT
33
+ "armazenamento",
34
+ "balde",
35
+ # FR
36
+ "stockage",
37
+ "seau",
38
+ # DE
39
+ "speicher",
40
+ "eimer",
41
+ # ZH
42
+ "\u5b58\u50a8\u6876",
43
+ "\u5bf9\u8c61\u5b58\u50a8",
44
+ ],
45
+ "ec2": [
46
+ "ec2",
47
+ "instance",
48
+ "instances",
49
+ # ES
50
+ "instancia",
51
+ "instancias",
52
+ "servidor",
53
+ "servidores",
54
+ # EN
55
+ "vm",
56
+ "virtual machine",
57
+ # PT
58
+ "inst\u00e2ncia",
59
+ "inst\u00e2ncias",
60
+ "servidor",
61
+ # FR
62
+ "serveur",
63
+ "machine virtuelle",
64
+ # DE
65
+ "instanz",
66
+ "instanzen",
67
+ # ZH
68
+ "\u5b9e\u4f8b",
69
+ "\u670d\u52a1\u5668",
70
+ "\u865a\u62df\u673a",
71
+ ],
72
+ "lambda": [
73
+ "lambda",
74
+ "lamda",
75
+ # ES
76
+ "funci\u00f3n lambda",
77
+ "funcion lambda",
78
+ "funcion",
79
+ "funci\u00f3n",
80
+ "funciones",
81
+ # EN
82
+ "lambda function",
83
+ "function",
84
+ "functions",
85
+ # PT
86
+ "fun\u00e7\u00e3o lambda",
87
+ "fun\u00e7\u00e3o",
88
+ "fun\u00e7\u00f5es",
89
+ # FR
90
+ "fonction lambda",
91
+ "fonction",
92
+ "fonctions",
93
+ # DE
94
+ "lambda-funktion",
95
+ "funktion",
96
+ "funktionen",
97
+ # ZH
98
+ "lambda\u51fd\u6570",
99
+ "\u51fd\u6570",
100
+ ],
101
+ "dynamodb": [
102
+ "dynamodb",
103
+ "dynamo",
104
+ # ES
105
+ "tabla dynamodb",
106
+ "tabla",
107
+ "tablas",
108
+ "base de datos nosql",
109
+ # EN
110
+ "dynamodb table",
111
+ "table",
112
+ "tables",
113
+ "nosql",
114
+ # PT
115
+ "tabela dynamodb",
116
+ "tabela",
117
+ "tabelas",
118
+ # FR
119
+ "tableau dynamodb",
120
+ "tableau",
121
+ # DE
122
+ "dynamodb-tabelle",
123
+ "dynamodb tabelle",
124
+ "tabelle",
125
+ "tabellen",
126
+ # ZH
127
+ "dynamodb\u8868",
128
+ "\u8868",
129
+ ],
130
+ "iam": [
131
+ "iam",
132
+ # ES
133
+ "usuario",
134
+ "usuarios",
135
+ "rol",
136
+ "roles",
137
+ "permisos",
138
+ "permiso",
139
+ # EN
140
+ "user",
141
+ "users",
142
+ "role",
143
+ "permission",
144
+ "permissions",
145
+ "policy",
146
+ "policies",
147
+ # PT
148
+ "usu\u00e1rio",
149
+ "usu\u00e1rios",
150
+ "permiss\u00e3o",
151
+ "permiss\u00f5es",
152
+ # FR
153
+ "utilisateur iam",
154
+ "utilisateur",
155
+ "r\u00f4le iam",
156
+ "r\u00f4le",
157
+ # DE
158
+ "berechtigung",
159
+ "berechtigungen",
160
+ "rolle",
161
+ "rollen",
162
+ # ZH
163
+ "iam\u7528\u6237",
164
+ "iam\u89d2\u8272",
165
+ "\u6743\u9650",
166
+ ],
167
+ "rds": [
168
+ "rds",
169
+ # ES
170
+ "base de datos",
171
+ "bases de datos",
172
+ "postgres",
173
+ "mysql",
174
+ "aurora",
175
+ # EN
176
+ "database",
177
+ "databases",
178
+ # PT
179
+ "banco de dados",
180
+ "bancos de dados",
181
+ # FR
182
+ "base de donn\u00e9es",
183
+ "bases de donn\u00e9es",
184
+ # DE
185
+ "datenbank",
186
+ "datenbanken",
187
+ # ZH
188
+ "\u6570\u636e\u5e93",
189
+ "\u5173\u7cfb\u578b\u6570\u636e\u5e93",
190
+ ],
191
+ "vpc": [
192
+ "vpc",
193
+ # ES
194
+ "red",
195
+ "redes",
196
+ "subred",
197
+ "subredes",
198
+ # EN
199
+ "network",
200
+ "subnet",
201
+ "subnets",
202
+ # PT
203
+ "rede",
204
+ "redes",
205
+ "sub-rede",
206
+ # FR
207
+ "sous-r\u00e9seau",
208
+ "r\u00e9seau",
209
+ # DE
210
+ "netzwerk",
211
+ "netzwerke",
212
+ "subnetz",
213
+ # ZH
214
+ "\u5b50\u7f51",
215
+ "\u7f51\u7edc",
216
+ ],
217
+ "cloudfront": [
218
+ "cloudfront",
219
+ # ES
220
+ "cdn",
221
+ "distribuci\u00f3n",
222
+ "distribucion",
223
+ # EN
224
+ "cdn",
225
+ "distribution",
226
+ # PT
227
+ "distribui\u00e7\u00e3o cdn",
228
+ "distribui\u00e7\u00e3o",
229
+ "distribui\u00e7\u00f5es",
230
+ # FR
231
+ "distribution cloudfront",
232
+ # DE
233
+ "cloudfront-distribution",
234
+ "verteilung",
235
+ # ZH
236
+ "\u5185\u5bb9\u5206\u53d1",
237
+ "\u5206\u53d1",
238
+ ],
239
+ "sns": [
240
+ "sns",
241
+ # ES
242
+ "notificacion",
243
+ "notificaci\u00f3n",
244
+ "notificaciones",
245
+ # EN
246
+ "notification",
247
+ "notifications",
248
+ # PT
249
+ "notifica\u00e7\u00e3o",
250
+ "notifica\u00e7\u00f5es",
251
+ "t\u00f3pico",
252
+ # FR
253
+ "notification sns",
254
+ "sujet sns",
255
+ # DE
256
+ "sns-thema",
257
+ "benachrichtigung",
258
+ "benachrichtigungen",
259
+ # ZH
260
+ "sns\u4e3b\u9898",
261
+ "\u901a\u77e5",
262
+ ],
263
+ "sqs": [
264
+ "sqs",
265
+ # ES
266
+ "cola",
267
+ "colas",
268
+ # EN
269
+ "queue",
270
+ "queues",
271
+ # PT
272
+ "fila",
273
+ "filas",
274
+ # FR
275
+ "file d'attente",
276
+ "files d'attente",
277
+ # DE
278
+ "warteschlange",
279
+ "warteschlangen",
280
+ # ZH
281
+ "\u961f\u5217",
282
+ "\u6d88\u606f\u961f\u5217",
283
+ ],
284
+ }
285
+
286
+ # Keywords por acción, multi-idioma: ES, EN, PT, FR, DE, ZH
287
+ ACTION_KEYWORDS: dict[str, list[str]] = {
288
+ "list": [
289
+ # ES
290
+ "lista",
291
+ "listar",
292
+ "listes",
293
+ "muestra",
294
+ "mu\u00e9strame",
295
+ "mostrar",
296
+ "ver",
297
+ "dame",
298
+ "ens\u00e9\u00f1ame",
299
+ # EN
300
+ "list",
301
+ "show",
302
+ "display",
303
+ "get all",
304
+ "view",
305
+ # PT
306
+ "listar",
307
+ "exibir",
308
+ "mostre",
309
+ # FR
310
+ "lister",
311
+ "afficher",
312
+ "montrer",
313
+ "montrez",
314
+ # DE
315
+ "auflisten",
316
+ "anzeigen",
317
+ "zeige",
318
+ "zeigen",
319
+ # ZH
320
+ "\u5217\u51fa",
321
+ "\u5217\u8868",
322
+ "\u67e5\u770b",
323
+ "\u663e\u793a",
324
+ ],
325
+ "create": [
326
+ # ES
327
+ "crea",
328
+ "crear",
329
+ "genera",
330
+ "generar",
331
+ "nuevo",
332
+ "nueva",
333
+ "provisiona",
334
+ # EN
335
+ "create",
336
+ "make",
337
+ "new",
338
+ "provision",
339
+ "generate",
340
+ "launch",
341
+ # PT
342
+ "criar",
343
+ "crie",
344
+ "gerar",
345
+ "novo",
346
+ "nova",
347
+ # FR
348
+ "cr\u00e9er",
349
+ "cr\u00e9ez",
350
+ "g\u00e9n\u00e9rer",
351
+ "nouveau",
352
+ "nouvelle",
353
+ # DE
354
+ "erstellen",
355
+ "erstelle",
356
+ "erzeugen",
357
+ "neu",
358
+ "starte",
359
+ "starten",
360
+ # ZH
361
+ "\u521b\u5efa",
362
+ "\u65b0\u5efa",
363
+ "\u751f\u6210",
364
+ "\u542f\u52a8",
365
+ ],
366
+ "delete": [
367
+ # ES
368
+ "borra",
369
+ "borrar",
370
+ "borrame",
371
+ "elimina",
372
+ "eliminar",
373
+ "quita",
374
+ "quitar",
375
+ "vacia",
376
+ "vaciar",
377
+ # EN
378
+ "delete",
379
+ "remove",
380
+ "destroy",
381
+ "terminate",
382
+ "drop",
383
+ # PT
384
+ "excluir",
385
+ "deletar",
386
+ "remover",
387
+ "apagar",
388
+ "terminar",
389
+ # FR
390
+ "supprimer",
391
+ "supprimez",
392
+ "effacer",
393
+ "vider",
394
+ # DE
395
+ "l\u00f6schen",
396
+ "l\u00f6sche",
397
+ "entfernen",
398
+ "entferne",
399
+ # ZH
400
+ "\u5220\u9664",
401
+ "\u79fb\u9664",
402
+ "\u4e22\u5f03",
403
+ "\u7ec8\u6b62",
404
+ ],
405
+ "update": [
406
+ # ES
407
+ "actualiza",
408
+ "actualizar",
409
+ "modifica",
410
+ "modificar",
411
+ "cambia",
412
+ "cambiar",
413
+ # EN
414
+ "update",
415
+ "modify",
416
+ "change",
417
+ "edit",
418
+ "alter",
419
+ # PT
420
+ "atualizar",
421
+ "atualize",
422
+ "modificar",
423
+ "alterar",
424
+ # FR
425
+ "mettre \u00e0 jour",
426
+ "modifier",
427
+ "modifiez",
428
+ "changer",
429
+ # DE
430
+ "\u00e4ndern",
431
+ "\u00e4ndere",
432
+ "aktualisieren",
433
+ "bearbeiten",
434
+ # ZH
435
+ "\u66f4\u65b0",
436
+ "\u4fee\u6539",
437
+ "\u53d8\u66f4",
438
+ ],
439
+ "describe": [
440
+ # ES
441
+ "describe",
442
+ "describir",
443
+ "info",
444
+ "informacion",
445
+ "informaci\u00f3n",
446
+ "detalles",
447
+ "detalle",
448
+ # EN
449
+ "describe",
450
+ "detail",
451
+ "details",
452
+ "info",
453
+ "information",
454
+ # PT
455
+ "descrever",
456
+ "descreva",
457
+ "detalhes",
458
+ "informa\u00e7\u00e3o",
459
+ # FR
460
+ "d\u00e9crire",
461
+ "d\u00e9crivez",
462
+ "d\u00e9tails",
463
+ "informations",
464
+ # DE
465
+ "beschreiben",
466
+ "details",
467
+ "informationen",
468
+ # ZH
469
+ "\u63cf\u8ff0",
470
+ "\u8be6\u60c5",
471
+ "\u4fe1\u606f",
472
+ ],
473
+ "invoke": [
474
+ # ES
475
+ "invoca",
476
+ "invocar",
477
+ "ejecuta",
478
+ "ejecutar",
479
+ "llama",
480
+ "llamar",
481
+ # EN
482
+ "invoke",
483
+ "execute",
484
+ "run",
485
+ "call",
486
+ "trigger",
487
+ # PT
488
+ "invocar",
489
+ "executar",
490
+ "chamar",
491
+ # FR
492
+ "invoquer",
493
+ "ex\u00e9cuter",
494
+ "appeler",
495
+ # DE
496
+ "aufrufen",
497
+ "ausf\u00fchren",
498
+ "f\u00fchre",
499
+ # ZH
500
+ "\u8c03\u7528",
501
+ "\u6267\u884c",
502
+ "\u89e6\u53d1",
503
+ ],
504
+ }
505
+
506
+ # Keywords que implican "describe" con mayor prioridad si aparecen junto a un verbo de "list"
507
+ DESCRIBE_PRIORITY_KEYWORDS: list[str] = [
508
+ "detalles",
509
+ "detalle",
510
+ "details",
511
+ "detail",
512
+ "detalhes",
513
+ "d\u00e9tails",
514
+ "informaci\u00f3n",
515
+ "informacion",
516
+ "information",
517
+ "informa\u00e7\u00e3o",
518
+ "informations",
519
+ "informationen",
520
+ "\u8be6\u60c5",
521
+ "\u4fe1\u606f",
522
+ ]
523
+
524
+ # Longitud máxima de input que procesamos
525
+ MAX_INPUT_LENGTH = 500
526
+
527
+ # Regex para detectar CJK characters
528
+ _CJK_PATTERN = re.compile(r"[\u4e00-\u9fff\u3400-\u4dbf]")
529
+
530
+
531
+ # --- Pydantic Models ---
532
+
533
+
534
+ class Intent(BaseModel):
535
+ """Structured representation of user's natural language request."""
536
+
537
+ action: ActionType
538
+ service: str = Field(..., description="AWS service short name (s3, ec2, lambda, etc.)")
539
+ resource_type: str | None = None
540
+ filters: dict[str, Any] = Field(default_factory=dict)
541
+ region: str | None = None
542
+ confidence: float = Field(ge=0.0, le=1.0, default=0.0)
543
+ raw_input: str
544
+ detected_language: str = "en"
545
+ suggestion: str | None = None
546
+ clarification_needed: bool = False
547
+ clarification_question: str | None = None
548
+
549
+
550
+ # --- Classes ---
551
+
552
+
553
+ class IntentParser:
554
+ """Parses natural language into structured Intent objects.
555
+
556
+ Uses a hybrid approach combining rule-based keyword detection with
557
+ language identification via langdetect. For high-confidence cases,
558
+ rule-based matching is sufficient. Ambiguous inputs are flagged for
559
+ potential Bedrock fallback.
560
+
561
+ Supports 6 languages: ES, EN, PT, FR, DE, ZH.
562
+ Supports 10 AWS services and 6 action types.
563
+ """
564
+
565
+ def parse(self, text: str, region: str | None = None) -> Intent:
566
+ """Parse natural language text into an Intent.
567
+
568
+ Args:
569
+ text: The natural language input.
570
+ region: Optional default AWS region.
571
+
572
+ Returns:
573
+ Intent object with detected action, service, and metadata.
574
+ """
575
+ # Edge case: empty or whitespace-only input
576
+ if not text or not text.strip():
577
+ return Intent(
578
+ action="unknown",
579
+ service="unknown",
580
+ confidence=0.0,
581
+ raw_input=text or "",
582
+ detected_language="en",
583
+ region=region,
584
+ clarification_needed=True,
585
+ clarification_question=(
586
+ "No input provided. Please describe what AWS operation you'd like to perform."
587
+ ),
588
+ )
589
+
590
+ # Truncate very long input
591
+ original_text = text
592
+ if len(text) > MAX_INPUT_LENGTH:
593
+ text = text[:MAX_INPUT_LENGTH]
594
+
595
+ text_lower = text.lower().strip()
596
+
597
+ # Detect language with langdetect (seeded for determinism)
598
+ detected_lang = _detect_language(text)
599
+
600
+ # Detect service and action via keyword matching
601
+ service = _detect_service(text_lower)
602
+ action = _detect_action(text_lower)
603
+
604
+ # Calculate confidence based on detection results
605
+ confidence = _calculate_confidence(service, action)
606
+
607
+ # Build suggestion if low confidence
608
+ suggestion: str | None = None
609
+ if confidence < 0.5:
610
+ suggestion = _build_suggestion()
611
+
612
+ # AC-1.3: clarification needed when confidence < 0.7
613
+ clarification_needed = confidence < 0.7
614
+ clarification_question: str | None = None
615
+ if clarification_needed:
616
+ clarification_question = _build_clarification_question(service, action)
617
+
618
+ return Intent(
619
+ action=action,
620
+ service=service or "unknown",
621
+ confidence=confidence,
622
+ raw_input=original_text,
623
+ detected_language=detected_lang,
624
+ region=region,
625
+ suggestion=suggestion,
626
+ clarification_needed=clarification_needed,
627
+ clarification_question=clarification_question,
628
+ )
629
+
630
+
631
+ # --- Private helpers ---
632
+
633
+
634
+ def _detect_language(text: str) -> str:
635
+ """Detect language of the input text using langdetect with keyword-based fallback.
636
+
637
+ Pre-processes text by removing AWS identifiers and technical tokens
638
+ that confuse langdetect on short inputs. For texts that contain CJK
639
+ characters, returns 'zh-cn' directly. Uses keyword-based heuristic
640
+ as fallback for very short cleaned texts.
641
+
642
+ Args:
643
+ text: Input text to analyze.
644
+
645
+ Returns:
646
+ ISO 639-1 language code (e.g., 'es', 'en', 'pt', 'fr', 'de', 'zh-cn').
647
+ """
648
+ # If text contains CJK characters, it's Chinese
649
+ if _CJK_PATTERN.search(text):
650
+ return "zh-cn"
651
+
652
+ # Remove technical tokens that confuse langdetect:
653
+ cleaned = text
654
+ # Remove ARNs
655
+ cleaned = re.sub(r"arn:aws:\S+", " ", cleaned)
656
+ # Remove s3:// URIs
657
+ cleaned = re.sub(r"s3://\S+", " ", cleaned)
658
+ # Remove AWS resource IDs (i-xxxx, vpc-xxxx, etc.)
659
+ cleaned = re.sub(r"\b[a-z]+-[0-9a-f]{6,}\b", " ", cleaned)
660
+ # Remove instance types (t3.micro, db.t3.micro, db.r5.large)
661
+ cleaned = re.sub(r"\b(?:db\.)?[a-z]\d+\.\w+\b", " ", cleaned)
662
+ # Remove alphanumeric IDs that look like AWS distribution IDs (E1XYZ2ABC3)
663
+ cleaned = re.sub(r"\bE[0-9A-Z]{8,}\b", " ", cleaned)
664
+ # Remove CLI flags
665
+ cleaned = re.sub(r"--\w+", " ", cleaned)
666
+ # Remove AWS service abbreviations that don't help language detection
667
+ cleaned = re.sub(
668
+ r"\b(?:S3|EC2|RDS|VPC|IAM|SQS|SNS|Lambda|DynamoDB|CloudFront|CIDR)\b",
669
+ " ",
670
+ cleaned,
671
+ flags=re.IGNORECASE,
672
+ )
673
+ # Remove IP/CIDR ranges
674
+ cleaned = re.sub(r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(?:/\d{1,2})?", " ", cleaned)
675
+ # Remove JSON payloads
676
+ cleaned = re.sub(r"\{[^}]*\}", " ", cleaned)
677
+ # Remove remaining identifiers (like resource names with hyphens: temp-data, legacy-app-db)
678
+ cleaned = re.sub(r"\b[a-z]+-[a-z]+-[a-z0-9-]+\b", " ", cleaned)
679
+ cleaned = re.sub(r"\b[a-z]+-[a-z]+\b", " ", cleaned)
680
+ # Clean up multiple spaces
681
+ cleaned = re.sub(r"\s+", " ", cleaned).strip()
682
+
683
+ # If cleaned text is too short, use keyword-based detection
684
+ if len(cleaned) <= 15:
685
+ kw_lang = _detect_language_by_keywords(text.lower())
686
+ if kw_lang:
687
+ return kw_lang
688
+
689
+ try:
690
+ detected = str(langdetect.detect(cleaned if len(cleaned) > 5 else text))
691
+ # Always run keyword-based detection for cross-validation
692
+ kw_lang = _detect_language_by_keywords(text.lower())
693
+
694
+ # If keyword detection has a clear answer that differs from langdetect,
695
+ # trust the keyword detection — langdetect is unreliable for short technical texts
696
+ if kw_lang and kw_lang != detected:
697
+ # Only override if langdetect returned something wrong
698
+ # Common misdetections by langdetect on short technical texts:
699
+ # - Catalan (ca) for Spanish (es) or Portuguese (pt)
700
+ # - Italian (it) for French (fr)
701
+ # - Romanian (ro) for Portuguese (pt)
702
+ # - Spanish (es) for Portuguese (pt)
703
+ # - Danish/Swedish/Dutch/French for English (en)
704
+ # - French (fr) for Spanish (es) — short texts
705
+ if detected == "ca" and kw_lang == "pt":
706
+ return "pt"
707
+ if detected in ("it", "ca", "es") and kw_lang == "fr":
708
+ return "fr"
709
+ if detected in ("ro", "es", "ca") and kw_lang == "pt":
710
+ return "pt"
711
+ if detected in ("da", "sv", "nl", "no", "af", "fr") and kw_lang == "en":
712
+ return "en"
713
+ if detected in ("fr", "it", "ca") and kw_lang == "es":
714
+ return "es"
715
+ if detected == "it" and kw_lang != "it":
716
+ return kw_lang
717
+ # If langdetect returns unexpected language, trust keywords
718
+ if detected not in ("es", "en", "pt", "fr", "de"):
719
+ return kw_lang
720
+ # If langdetect returns es but keywords strongly say en, trust keywords
721
+ # This handles cases where short English texts after AWS-term removal
722
+ # get misclassified as Spanish
723
+ if detected == "es" and kw_lang == "en":
724
+ en_scores = _get_keyword_scores(text.lower())
725
+ if en_scores.get("en", 0) > en_scores.get("es", 0):
726
+ return "en"
727
+
728
+ # Simple corrections without keyword evidence
729
+ if detected == "ca":
730
+ return "es"
731
+ return detected
732
+ except langdetect.lang_detect_exception.LangDetectException:
733
+ return "en"
734
+
735
+
736
+ # Language-specific keywords for heuristic detection
737
+ _LANG_HINT_KEYWORDS: dict[str, list[str]] = {
738
+ "es": [
739
+ "lista",
740
+ "crea",
741
+ "crear",
742
+ "elimina",
743
+ "eliminar",
744
+ "borra",
745
+ "borrar",
746
+ "muestra",
747
+ "mu\u00e9strame",
748
+ "dame",
749
+ "ens\u00e9\u00f1ame",
750
+ "actualiza",
751
+ "modifica",
752
+ "quiero",
753
+ "necesito",
754
+ "por favor",
755
+ "todos los",
756
+ "todas las",
757
+ "del",
758
+ "los",
759
+ "las",
760
+ "hacer",
761
+ "cu\u00e1l",
762
+ "ejecuta",
763
+ "ejecutar",
764
+ "haz",
765
+ "algo",
766
+ "con",
767
+ "funci\u00f3n",
768
+ "base de datos",
769
+ ],
770
+ "en": [
771
+ "list",
772
+ "create",
773
+ "delete",
774
+ "remove",
775
+ "show",
776
+ "display",
777
+ "get",
778
+ "the",
779
+ "all",
780
+ "please",
781
+ "every",
782
+ "everything",
783
+ "drop",
784
+ "terminate",
785
+ "launch",
786
+ "run",
787
+ "with",
788
+ "from",
789
+ "permanently",
790
+ "immediately",
791
+ "named",
792
+ "queue",
793
+ "instance",
794
+ "bucket",
795
+ ],
796
+ "pt": [
797
+ "listar",
798
+ "criar",
799
+ "excluir",
800
+ "deletar",
801
+ "remover",
802
+ "terminar",
803
+ "todos os",
804
+ "todas as",
805
+ "mostre",
806
+ "da conta",
807
+ "dos",
808
+ "do",
809
+ "inst\u00e2ncia",
810
+ "inst\u00e2ncias",
811
+ "tabela",
812
+ "associadas",
813
+ "da",
814
+ "excluir a",
815
+ "deletar a",
816
+ ],
817
+ "fr": [
818
+ "lister",
819
+ "cr\u00e9er",
820
+ "cr\u00e9ez",
821
+ "supprimer",
822
+ "afficher",
823
+ "montrer",
824
+ "terminer",
825
+ "vider",
826
+ "tous les",
827
+ "toutes les",
828
+ "nomm\u00e9",
829
+ "nouveau",
830
+ "nouvelle",
831
+ "r\u00f4le",
832
+ "ancien",
833
+ "ancienne",
834
+ "fichier",
835
+ "serveur",
836
+ "r\u00e9seau",
837
+ "obtenir",
838
+ "ajouter",
839
+ "configurer",
840
+ "r\u00e9cursivement",
841
+ "l'instance",
842
+ "donn\u00e9es",
843
+ ],
844
+ "de": [
845
+ "erstellen",
846
+ "l\u00f6schen",
847
+ "l\u00f6sche",
848
+ "anzeigen",
849
+ "zeige",
850
+ "entferne",
851
+ "entfernen",
852
+ "starte",
853
+ "f\u00fchre",
854
+ "alle",
855
+ "der",
856
+ "die",
857
+ "das",
858
+ "den",
859
+ "dem",
860
+ "eine",
861
+ "einen",
862
+ "mit",
863
+ "auf",
864
+ "vom",
865
+ "und",
866
+ ],
867
+ }
868
+
869
+
870
+ def _detect_language_by_keywords(text: str) -> str | None:
871
+ """Detect language by counting keyword matches from each language.
872
+
873
+ Args:
874
+ text: Lowercased text to analyze.
875
+
876
+ Returns:
877
+ Language code or None if no clear winner.
878
+ """
879
+ scores = _get_keyword_scores(text)
880
+
881
+ if not scores:
882
+ return None
883
+
884
+ # Return language with highest score, but only if it clearly wins
885
+ best_lang = max(scores, key=scores.get) # type: ignore[arg-type]
886
+ return best_lang
887
+
888
+
889
+ def _get_keyword_scores(text: str) -> dict[str, int]:
890
+ """Get keyword match scores for each language.
891
+
892
+ Args:
893
+ text: Lowercased text to analyze.
894
+
895
+ Returns:
896
+ Dictionary mapping language codes to their keyword match scores.
897
+ """
898
+ scores: dict[str, int] = {}
899
+ for lang, keywords in _LANG_HINT_KEYWORDS.items():
900
+ score = 0
901
+ for kw in keywords:
902
+ if " " in kw:
903
+ if kw in text:
904
+ score += 2
905
+ else:
906
+ # Word-presence check (simple substring for common short words)
907
+ pattern = rf"(?:^|[\s,;:.!?\(\)\"'\-/]){re.escape(kw)}(?:[\s,;:.!?\(\)\"'\-/]|$)"
908
+ if re.search(pattern, text):
909
+ score += 1
910
+ if score > 0:
911
+ scores[lang] = score
912
+
913
+ return scores
914
+
915
+
916
+ def _contains_cjk(text: str) -> bool:
917
+ """Check if text contains CJK characters.
918
+
919
+ Args:
920
+ text: Text to check.
921
+
922
+ Returns:
923
+ True if CJK characters are present.
924
+ """
925
+ return bool(_CJK_PATTERN.search(text))
926
+
927
+
928
+ def _keyword_matches(keyword: str, text: str) -> bool:
929
+ """Check if a keyword matches in the text, with appropriate boundary handling.
930
+
931
+ For multi-word keywords or keywords containing CJK characters, uses substring match.
932
+ For single-word ASCII keywords in text with CJK characters, also uses substring match.
933
+ For single-word ASCII keywords in pure ASCII text, uses word boundary regex.
934
+
935
+ Args:
936
+ keyword: The keyword to search for.
937
+ text: The lowercased input text.
938
+
939
+ Returns:
940
+ True if the keyword matches.
941
+ """
942
+ # Multi-word keywords or CJK keywords: use substring match
943
+ if " " in keyword or _contains_cjk(keyword):
944
+ return keyword in text
945
+
946
+ # If the text contains CJK characters, use substring match for ASCII keywords too
947
+ # (CJK text doesn't have word boundaries between chars and embedded ASCII words)
948
+ if _contains_cjk(text):
949
+ return keyword in text
950
+
951
+ # Single-word keywords in non-CJK text: use word boundary matching
952
+ pattern = rf"(?:^|[\s,;:.!?\(\)\"'\-/]){re.escape(keyword)}(?:[\s,;:.!?\(\)\"'\-/]|$)"
953
+ return bool(re.search(pattern, text))
954
+
955
+
956
+ def _detect_service(text: str) -> str | None:
957
+ """Detect AWS service from text via keyword matching.
958
+
959
+ First checks for exact AWS service names (highest priority), then
960
+ falls back to keyword matching. Uses word boundary awareness for
961
+ single-word ASCII keywords and substring matching for CJK keywords.
962
+
963
+ Args:
964
+ text: Lowercased input text.
965
+
966
+ Returns:
967
+ Service identifier string or None if not detected.
968
+ """
969
+ # Priority 1: Exact service name matches (case-insensitive, already lowercase)
970
+ # These are unambiguous identifiers that should always win
971
+ # Order: longer/more specific first; IAM before lambda to avoid role name conflicts
972
+ exact_services = [
973
+ "cloudfront",
974
+ "dynamodb",
975
+ "iam",
976
+ "lambda",
977
+ "lamda",
978
+ "vpc",
979
+ "sqs",
980
+ "sns",
981
+ "rds",
982
+ "ec2",
983
+ "s3",
984
+ ]
985
+ for svc_name in exact_services:
986
+ # Use keyword matching which handles CJK and word boundaries
987
+ if _keyword_matches(svc_name, text):
988
+ # Map to canonical service key
989
+ if svc_name in ("lambda", "lamda"):
990
+ return "lambda"
991
+ return svc_name
992
+
993
+ # Priority 2: Keyword-based detection for when service name isn't mentioned
994
+ for service, keywords in SERVICE_KEYWORDS.items():
995
+ for kw in keywords:
996
+ if _keyword_matches(kw, text):
997
+ return service
998
+ return None
999
+
1000
+
1001
+ def _detect_action(text: str) -> ActionType:
1002
+ """Detect intended action from text via keyword matching.
1003
+
1004
+ If both a "list" keyword and a "describe" priority keyword are found,
1005
+ returns "describe" to handle cases like "muéstrame los detalles" or
1006
+ "mostre informação".
1007
+
1008
+ Args:
1009
+ text: Lowercased input text.
1010
+
1011
+ Returns:
1012
+ One of the valid ActionType values.
1013
+ """
1014
+ detected_action: ActionType = "unknown"
1015
+
1016
+ for action, keywords in ACTION_KEYWORDS.items():
1017
+ for kw in keywords:
1018
+ if _keyword_matches(kw, text):
1019
+ if action in get_args(ActionType):
1020
+ detected_action = action # type: ignore[assignment]
1021
+ break
1022
+ if detected_action != "unknown":
1023
+ break
1024
+
1025
+ # If detected "list", check if describe-priority keywords are present
1026
+ # (handles "muéstrame los detalles", "mostre informação", "zeige mir die Details")
1027
+ if detected_action == "list":
1028
+ for desc_kw in DESCRIBE_PRIORITY_KEYWORDS:
1029
+ if desc_kw in text:
1030
+ return "describe"
1031
+
1032
+ return detected_action
1033
+
1034
+
1035
+ def _calculate_confidence(service: str | None, action: ActionType) -> float:
1036
+ """Calculate confidence score based on detection results.
1037
+
1038
+ Args:
1039
+ service: Detected service or None.
1040
+ action: Detected action type.
1041
+
1042
+ Returns:
1043
+ Confidence float between 0.0 and 1.0.
1044
+ """
1045
+ if service and action != "unknown":
1046
+ return 0.9
1047
+ if service or action != "unknown":
1048
+ return 0.5
1049
+ return 0.2
1050
+
1051
+
1052
+ def _build_clarification_question(service: str | None, action: str) -> str:
1053
+ """Generate a clarification question when confidence is low.
1054
+
1055
+ Args:
1056
+ service: Detected AWS service or None.
1057
+ action: Detected action type.
1058
+
1059
+ Returns:
1060
+ A specific clarification question for the user.
1061
+ """
1062
+ if service is None and action == "unknown":
1063
+ return "Could you specify which AWS service and what operation you'd like to perform?"
1064
+ if service is None:
1065
+ return f"Which AWS service would you like to {action}?"
1066
+ if action == "unknown":
1067
+ return f"What would you like to do with {service}? (list, create, delete, describe)"
1068
+ return "Could you provide more details about what you'd like to do?"
1069
+
1070
+
1071
+ def _build_suggestion() -> str:
1072
+ """Generate a helpful suggestion when intent is unclear.
1073
+
1074
+ Returns:
1075
+ Suggestion string with usage examples.
1076
+ """
1077
+ return (
1078
+ "Try being more specific. Example: 'list the S3 buckets' "
1079
+ "or 'create a new EC2 instance t3.micro'"
1080
+ )