File size: 50,162 Bytes
0d3f7cc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
"""
ECCN (Export Control Classification Number) classification engine.

Classifies items under the US Bureau of Industry and Security (BIS) Export
Administration Regulations (EAR). Uses the Commerce Control List (CCL)
structured as ECCN codes (e.g., 3A001, 5D002).

Classification pipeline:
1. Static ECCN database lookup (instant, FREE)
2. LLM classification via DeepSeek V4 Flash (requires API key)
3. Confidence scoring + human review flagging

Data sources:
- BIS Commerce Control List (CCL) β€” 10 categories Γ— 5 product groups
- Common ECCNs embedded as static dataset (public domain structure)
- Country Chart / license requirements per reason for control

Typing conventions:
    All public APIs use explicit type hints. Literal types enforce valid
    categories and product groups. Final constants prevent mutation.
"""

from __future__ import annotations

import json
import logging
import os
import re
import threading
import time as _time
from dataclasses import dataclass, field
from typing import Any, Final, Literal, Optional

import httpx

logger: Final = logging.getLogger(__name__)


# ── Domain Exceptions ─────────────────────────────────────────────────


class ECCNClassificationError(Exception):
    """Base exception for all ECCN classification errors."""


class ECCNValidationError(ECCNClassificationError):
    """Raised when an ECCN code fails structural validation.

    Attributes:
        code: The invalid code.
        reason: Why validation failed.
    """

    def __init__(self, code: str, reason: str) -> None:
        self.code = code
        self.reason = reason
        super().__init__(f"Invalid ECCN code '{code}': {reason}")


class ECCNLookupError(ECCNClassificationError):
    """Raised when an ECCN code lookup fails in the database."""


class ECCNLLMClassificationError(ECCNClassificationError):
    """Raised when LLM-based classification fails.

    Attributes:
        provider: The LLM provider name.
        detail: Error detail.
    """

    def __init__(self, provider: str, detail: str) -> None:
        self.provider = provider
        self.detail = detail
        super().__init__(f"LLM classification failed ({provider}): {detail}")


# ── Constants ─────────────────────────────────────────────────────────

# ECCN code pattern: letter + 4 digits (e.g., 3A001, 5D002)
ECCN_PATTERN: Final[re.Pattern[str]] = re.compile(r"^[0-9][A-E]\d{3}$")

# Confidence thresholds
HIGH_CONFIDENCE_THRESHOLD: Final[float] = 0.85
MEDIUM_CONFIDENCE_THRESHOLD: Final[float] = 0.65
LLM_MAX_CONFIDENCE: Final[float] = 0.85

# Type aliases
ProductGroup = Literal["A", "B", "C", "D", "E"]
ControlReason = Literal[
    "NS", "MT", "CB", "CC", "RS", "AT", "EI", "SL", "GB",
]

# CCL Category names (EAR Part 774, Supplement No. 1)
CCL_CATEGORIES: Final[dict[int, str]] = {
    0: "Nuclear Materials, Facilities & Equipment",
    1: "Materials, Chemicals, Microorganisms & Toxins",
    2: "Materials Processing",
    3: "Electronics",
    4: "Computers",
    5: "Telecommunications & Information Security",
    6: "Sensors and Lasers",
    7: "Navigation & Avionics",
    8: "Marine",
    9: "Aerospace & Propulsion",
}

# Product groups
PRODUCT_GROUPS: Final[dict[str, str]] = {
    "A": "Equipment, Components, and Materials",
    "B": "Test, Inspection, and Production Equipment",
    "C": "Materials",
    "D": "Software",
    "E": "Technology",
}

# Reasons for control
CONTROL_REASONS: Final[dict[str, str]] = {
    "NS": "National Security (NS applies under NSG, Wassenaar, etc.)",
    "MT": "Missile Technology (MTCR)",
    "CB": "Chemical & Biological Weapons (Australia Group)",
    "CC": "Chemical & Biological Weapons (CBW end-use)",
    "RS": "Regional Stability",
    "AT": "Anti-Terrorism (Country Group E:1/E:2)",
    "EI": "Encryption Items",
    "SL": "Surreptitious Listening",
    "GB": "Firearms / Guns & Ammunition (legacy)",
}

# Country Chart: reason for control β†’ license requirement per country group
# Based on EAR Part 738 Supplement No. 1 (simplified)
COUNTRY_CHART: Final[dict[str, str]] = {
    "NS": "Yes β€” License required for NS Group D:1 countries",
    "MT": "Yes β€” License required for all destinations except Canada",
    "CB": "Yes β€” License required for AG Group D:1 countries",
    "CC": "Yes β€” License required for CBW-end-use countries",
    "RS": "Yes β€” License required for RS Group D:2 countries",
    "AT": "Yes β€” License required for E:1/E:2 countries",
    "EI": "Yes β€” License required for Country Group D:5",
    "SL": "Yes β€” License required for all destinations except Canada",
    "GB": "Yes β€” License required for E:1/E:2 countries",
}

# Common ECCN database (static, public domain structure)
# Format: eccn_code β†’ {description, items_controlled, control_reasons, product_group}
_COMMON_ECCNS: Final[dict[str, dict[str, Any]]] = {
    # ── Category 0: Nuclear Materials, Facilities & Equipment ──────────
    "0A001": {
        "description": "Nuclear reactors and related equipment",
        "items_controlled": "Nuclear reactors, fuel elements, control rods, moderator assemblies",
        "control_reasons": ["NS"],
        "product_group": "A",
    },
    "0A002": {
        "description": "Nuclear reactor internals and components",
        "items_controlled": "Pressure vessels, heat exchangers, gas turbine generators for nuclear",
        "control_reasons": ["NS"],
        "product_group": "A",
    },
    "0B001": {
        "description": "Nuclear reactor equipment for fuel processing",
        "items_controlled": "Equipment for the production of heavy water, uranium enrichment",
        "control_reasons": ["NS"],
        "product_group": "B",
    },
    "0C001": {
        "description": "Nuclear reactor fuels",
        "items_controlled": "Natural uranium, depleted uranium, thorium, fuel assemblies",
        "control_reasons": ["NS"],
        "product_group": "C",
    },
    "0D001": {
        "description": "Software for nuclear reactors",
        "items_controlled": "Software for design, operation, or maintenance of nuclear reactors",
        "control_reasons": ["NS"],
        "product_group": "D",
    },
    "0E001": {
        "description": "Technology for nuclear materials",
        "items_controlled": "Technology for development, production, or use of nuclear materials",
        "control_reasons": ["NS"],
        "product_group": "E",
    },

    # ── Category 1: Materials, Chemicals, Microorganisms & Toxins ──────
    "1A001": {
        "description": "Composite structures or laminates",
        "items_controlled": "Fibrous or filamentary materials, prepregs, composite structures",
        "control_reasons": ["NS", "CB"],
        "product_group": "A",
    },
    "1A002": {
        "description": "Materials for the production of chemical weapons precursors",
        "items_controlled": "Equipment for the production of chemical weapons precursors",
        "control_reasons": ["CB"],
        "product_group": "A",
    },
    "1A003": {
        "description": "Toxicological agents and related equipment",
        "items_controlled": "Chemical agents, biological agents, toxins, dissemination equipment",
        "control_reasons": ["CB"],
        "product_group": "A",
    },
    "1B001": {
        "description": "Chemical manufacturing equipment",
        "items_controlled": "Chemical production equipment, bioprocessing equipment",
        "control_reasons": ["CB"],
        "product_group": "B",
    },
    "1C001": {
        "description": "Chemical weapons precursors",
        "items_controlled": "Schedule 2 chemicals (thiodiglycol, phosphorus, etc.)",
        "control_reasons": ["CB"],
        "product_group": "C",
    },
    "1C002": {
        "description": "Chemical weapon agents and precursors",
        "items_controlled": "Schedule 3 chemicals (phosgene, cyanogen chloride, etc.)",
        "control_reasons": ["CB"],
        "product_group": "C",
    },
    "1C350": {
        "description": "Toxic chemicals and precursors",
        "items_controlled": "Schedule 1 chemicals (sarin, VX, etc.)",
        "control_reasons": ["CB"],
        "product_group": "C",
    },
    "1D001": {
        "description": "Software for chemical weapons",
        "items_controlled": "Software for chemical weapon design, production, or delivery",
        "control_reasons": ["CB"],
        "product_group": "D",
    },
    "1E001": {
        "description": "Technology for chemical weapons",
        "items_controlled": "Technology for chemical weapon development or production",
        "control_reasons": ["CB"],
        "product_group": "E",
    },

    # ── Category 2: Materials Processing ───────────────────────────────
    "2A001": {
        "description": "Specialized manufacturing equipment",
        "items_controlled": "Numerically controlled machine tools, precision forming machines",
        "control_reasons": ["NS"],
        "product_group": "A",
    },
    "2A002": {
        "description": "Numerically controlled machine tools",
        "items_controlled": "CNC lathes, milling machines, grinding machines",
        "control_reasons": ["NS"],
        "product_group": "A",
    },
    "2B001": {
        "description": "Numerically controlled machine tool equipment",
        "items_controlled": "Dimension inspection machines, coordinate measuring machines",
        "control_reasons": ["NS"],
        "product_group": "B",
    },
    "2B002": {
        "description": "Vacuum or controlled atmosphere furnaces",
        "items_controlled": "Vacuum furnaces, inert atmosphere furnaces",
        "control_reasons": ["NS"],
        "product_group": "B",
    },
    "2B003": {
        "description": "Numerically controlled machine tools (additional)",
        "items_controlled": "NC drilling machines, NC boring machines",
        "control_reasons": ["NS"],
        "product_group": "B",
    },
    "2C001": {
        "description": "Materials associated with chemical weapon precursors",
        "items_controlled": "Chemical materials for CW precursors",
        "control_reasons": ["CB"],
        "product_group": "C",
    },
    "2C350": {
        "description": "Chemical weapon precursors (Category 2)",
        "items_controlled": "Precursor chemicals for chemical weapons",
        "control_reasons": ["CB"],
        "product_group": "C",
    },
    "2D001": {
        "description": "Software for numerical control of machine tools",
        "items_controlled": "Software for NC machines, adaptive control software",
        "control_reasons": ["NS"],
        "product_group": "D",
    },
    "2E001": {
        "description": "Technology for numerical control machine tools",
        "items_controlled": "Technology for development or production of NC machine tools",
        "control_reasons": ["NS"],
        "product_group": "E",
    },

    # ── Category 3: Electronics ────────────────────────────────────────
    "3A001": {
        "description": "Electronic components",
        "items_controlled": "Integrated circuits, microprocessor chips, semiconductor devices, transistors",
        "control_reasons": ["NS"],
        "product_group": "A",
    },
    "3A002": {
        "description": "General-purpose electronic equipment",
        "items_controlled": "Signal analyzers, spectrum analyzers, frequency synthesizers",
        "control_reasons": ["NS"],
        "product_group": "A",
    },
    "3A229": {
        "description": "Electronic components (Missile Technology)",
        "items_controlled": "Electronics for missile technology applications",
        "control_reasons": ["MT"],
        "product_group": "A",
    },
    "3A991": {
        "description": "Electronic components (not controlled by 3A001)",
        "items_controlled": "Electronic components for non-military applications",
        "control_reasons": ["AT"],
        "product_group": "A",
    },
    "3B001": {
        "description": "Test equipment for electronics",
        "items_controlled": "Semiconductor test equipment, wafer probers",
        "control_reasons": ["NS"],
        "product_group": "B",
    },
    "3B002": {
        "description": "Test equipment for electron tubes, semiconductors",
        "items_controlled": "Equipment for testing integrated circuits",
        "control_reasons": ["NS"],
        "product_group": "B",
    },
    "3C001": {
        "description": "Electronic materials",
        "items_controlled": "Epitaxial materials, semiconductor substrates, photoresists",
        "control_reasons": ["NS"],
        "product_group": "C",
    },
    "3C002": {
        "description": "Precursor chemicals for semiconductors",
        "items_controlled": "Chemical precursors for semiconductor manufacturing",
        "control_reasons": ["NS"],
        "product_group": "C",
    },
    "3D001": {
        "description": "Software for electronic design and development",
        "items_controlled": "EDA software, IC design software, simulation software",
        "control_reasons": ["NS"],
        "product_group": "D",
    },
    "3E001": {
        "description": "Technology for electronics",
        "items_controlled": "Technology for development or production of electronic components",
        "control_reasons": ["NS"],
        "product_group": "E",
    },

    # ── Category 4: Computers ──────────────────────────────────────────
    "4A001": {
        "description": "Digital computers and related equipment",
        "items_controlled": "Digital computers, electronic assemblies, related equipment",
        "control_reasons": ["NS", "AT"],
        "product_group": "A",
    },
    "4A002": {
        "description": "Computers for 'signal processing'",
        "items_controlled": "Computers designed for signal processing applications",
        "control_reasons": ["NS"],
        "product_group": "A",
    },
    "4A003": {
        "description": "Digital computers (advanced)",
        "items_controlled": "High-performance digital computers and digital computer systems",
        "control_reasons": ["NS"],
        "product_group": "A",
    },
    "4A004": {
        "description": "Computers for 'signal processing' (advanced)",
        "items_controlled": "Advanced signal processing computers",
        "control_reasons": ["NS"],
        "product_group": "A",
    },
    "4A994": {
        "description": "Computers not controlled by 4A001-4A004",
        "items_controlled": "General-purpose computers for non-military use",
        "control_reasons": ["AT"],
        "product_group": "A",
    },
    "4B001": {
        "description": "Test equipment for computers",
        "items_controlled": "Equipment for testing digital computers",
        "control_reasons": ["NS"],
        "product_group": "B",
    },
    "4C001": {
        "description": "Chemicals for computers",
        "items_controlled": "Chemicals for semiconductor manufacturing in computers",
        "control_reasons": ["NS"],
        "product_group": "C",
    },
    "4D001": {
        "description": "Software for computers",
        "items_controlled": "Operating systems, application software for controlled computers",
        "control_reasons": ["NS"],
        "product_group": "D",
    },
    "4D995": {
        "description": "Software not controlled by 4D001",
        "items_controlled": "General-purpose software for non-military computers",
        "control_reasons": ["AT"],
        "product_group": "D",
    },
    "4E001": {
        "description": "Technology for computers",
        "items_controlled": "Technology for development, production, or use of computers",
        "control_reasons": ["NS"],
        "product_group": "E",
    },

    # ── Category 5: Telecommunications & Information Security ──────────
    "5A001": {
        "description": "Telecommunications systems and equipment",
        "items_controlled": "Telecom systems, transmission equipment, digital switching",
        "control_reasons": ["NS"],
        "product_group": "A",
    },
    "5A002": {
        "description": "Information security systems and equipment",
        "items_controlled": "Cryptographic systems, encryption devices, VPN appliances",
        "control_reasons": ["EI"],
        "product_group": "A",
    },
    "5A003": {
        "description": "Defence-related telecom equipment",
        "items_controlled": "Military telecom equipment, secure communications",
        "control_reasons": ["NS", "AT"],
        "product_group": "A",
    },
    "5A991": {
        "description": "Telecom equipment not controlled by 5A001-5A003",
        "items_controlled": "General-purpose telecom equipment",
        "control_reasons": ["AT"],
        "product_group": "A",
    },
    "5B001": {
        "description": "Telecom test equipment",
        "items_controlled": "Equipment for testing telecom systems",
        "control_reasons": ["NS"],
        "product_group": "B",
    },
    "5B002": {
        "description": "Information security test equipment",
        "items_controlled": "Equipment for testing information security systems",
        "control_reasons": ["EI"],
        "product_group": "B",
    },
    "5C001": {
        "description": "Telecom materials",
        "items_controlled": "Materials for telecom equipment manufacturing",
        "control_reasons": ["NS"],
        "product_group": "C",
    },
    "5D001": {
        "description": "Software for telecommunications",
        "items_controlled": "Telecom system software, network management software",
        "control_reasons": ["NS"],
        "product_group": "D",
    },
    "5D002": {
        "description": "Information security software",
        "items_controlled": "Encryption software, cryptographic software, security software",
        "control_reasons": ["EI"],
        "product_group": "D",
    },
    "5E001": {
        "description": "Technology for telecommunications",
        "items_controlled": "Technology for telecom equipment development or production",
        "control_reasons": ["NS"],
        "product_group": "E",
    },
    "5E002": {
        "description": "Technology for information security",
        "items_controlled": "Technology for development or production of cryptographic systems",
        "control_reasons": ["EI"],
        "product_group": "E",
    },

    # ── Category 6: Sensors and Lasers ─────────────────────────────────
    "6A001": {
        "description": "Acoustics and sonar systems",
        "items_controlled": "Hydrophones, sonar systems, acoustic arrays",
        "control_reasons": ["NS"],
        "product_group": "A",
    },
    "6A002": {
        "description": "Optical sensors and equipment",
        "items_controlled": "Thermal imaging cameras, infrared sensors, night vision equipment",
        "control_reasons": ["NS"],
        "product_group": "A",
    },
    "6A003": {
        "description": "Radar systems and equipment",
        "items_controlled": "Radar systems, antenna assemblies, signal processing equipment",
        "control_reasons": ["NS"],
        "product_group": "A",
    },
    "6A004": {
        "description": "Lasers and related equipment",
        "items_controlled": "High-energy lasers, laser amplifiers, laser optics",
        "control_reasons": ["NS"],
        "product_group": "A",
    },
    "6A005": {
        "description": "Magnetometers and related equipment",
        "items_controlled": "Magnetometers, magnetic field sensors, related equipment",
        "control_reasons": ["NS"],
        "product_group": "A",
    },
    "6B001": {
        "description": "Acoustic test and inspection equipment",
        "items_controlled": "Equipment for testing acoustic systems",
        "control_reasons": ["NS"],
        "product_group": "B",
    },
    "6B002": {
        "description": "Optical test equipment",
        "items_controlled": "Equipment for testing optical sensors",
        "control_reasons": ["NS"],
        "product_group": "B",
    },
    "6C001": {
        "description": "Optical materials",
        "items_controlled": "Optical crystals, infrared optical materials",
        "control_reasons": ["NS"],
        "product_group": "C",
    },
    "6D001": {
        "description": "Software for sensors",
        "items_controlled": "Software for design, development, or operation of sensors",
        "control_reasons": ["NS"],
        "product_group": "D",
    },
    "6E001": {
        "description": "Technology for sensors",
        "items_controlled": "Technology for development or production of sensors",
        "control_reasons": ["NS"],
        "product_group": "E",
    },

    # ── Category 7: Navigation & Avionics ──────────────────────────────
    "7A001": {
        "description": "Accelerometers and gyroscopes",
        "items_controlled": "Accelerometers, gyroscopes, inertial measurement units",
        "control_reasons": ["NS", "MT"],
        "product_group": "A",
    },
    "7A002": {
        "description": "Gravity meters and related equipment",
        "items_controlled": "Gravity meters, gravimeters, related equipment",
        "control_reasons": ["NS"],
        "product_group": "A",
    },
    "7A003": {
        "description": "Navigation and avionics equipment",
        "items_controlled": "GPS receivers, INS, flight control systems",
        "control_reasons": ["NS", "AT"],
        "product_group": "A",
    },
    "7B001": {
        "description": "Navigation test equipment",
        "items_controlled": "Equipment for testing navigation systems",
        "control_reasons": ["NS"],
        "product_group": "B",
    },
    "7C001": {
        "description": "Navigation materials",
        "items_controlled": "Materials for navigation equipment manufacturing",
        "control_reasons": ["NS"],
        "product_group": "C",
    },
    "7D001": {
        "description": "Software for navigation and avionics",
        "items_controlled": "Navigation software, flight control software",
        "control_reasons": ["NS"],
        "product_group": "D",
    },
    "7E001": {
        "description": "Technology for navigation and avionics",
        "items_controlled": "Technology for navigation equipment development or production",
        "control_reasons": ["NS"],
        "product_group": "E",
    },

    # ── Category 8: Marine ─────────────────────────────────────────────
    "8A001": {
        "description": "Marine vessels and related equipment",
        "items_controlled": "Submersibles, marine engines, related equipment",
        "control_reasons": ["NS", "AT"],
        "product_group": "A",
    },
    "8A002": {
        "description": "Underwater vehicles and related equipment",
        "items_controlled": "Remotely operated vehicles, autonomous underwater vehicles",
        "control_reasons": ["NS"],
        "product_group": "A",
    },
    "8B001": {
        "description": "Marine test equipment",
        "items_controlled": "Equipment for testing marine systems",
        "control_reasons": ["NS"],
        "product_group": "B",
    },
    "8C001": {
        "description": "Marine materials",
        "items_controlled": "Materials for marine equipment manufacturing",
        "control_reasons": ["NS"],
        "product_group": "C",
    },
    "8D001": {
        "description": "Software for marine systems",
        "items_controlled": "Software for design, development, or operation of marine systems",
        "control_reasons": ["NS"],
        "product_group": "D",
    },
    "8E001": {
        "description": "Technology for marine systems",
        "items_controlled": "Technology for development or production of marine systems",
        "control_reasons": ["NS"],
        "product_group": "E",
    },

    # ── Category 9: Aerospace & Propulsion ─────────────────────────────
    "9A001": {
        "description": "Aero gas turbine engines",
        "items_controlled": "Gas turbine engines, turbofan engines, turboprop engines",
        "control_reasons": ["NS", "AT"],
        "product_group": "A",
    },
    "9A002": {
        "description": "Missile technology (rockets, missiles, drones)",
        "items_controlled": "Rockets, missiles, unmanned aerial vehicles, cruise missiles",
        "control_reasons": ["MT", "NS"],
        "product_group": "A",
    },
    "9A003": {
        "description": "Space launch vehicles and sounding rockets",
        "items_controlled": "Space launch vehicles, sounding rockets, related equipment",
        "control_reasons": ["MT", "NS"],
        "product_group": "A",
    },
    "9A004": {
        "description": "Space launch vehicles and sounding rockets",
        "items_controlled": "Space launch vehicles, sounding rockets, ground equipment",
        "control_reasons": ["MT", "NS"],
        "product_group": "A",
    },
    "9A005": {
        "description": "Unmanned aerial vehicles (UAVs)",
        "items_controlled": "Unmanned aerial vehicles, drones, related equipment",
        "control_reasons": ["MT", "NS"],
        "product_group": "A",
    },
    "9A007": {
        "description": "Aircraft and related equipment",
        "items_controlled": "Military aircraft, stealth aircraft, related equipment",
        "control_reasons": ["NS", "AT"],
        "product_group": "A",
    },
    "9A008": {
        "description": "Aerospace propulsion systems",
        "items_controlled": "Liquid/solid rocket propulsion systems, ramjet/scramjet engines",
        "control_reasons": ["MT", "NS"],
        "product_group": "A",
    },
    "9A990": {
        "description": "Aircraft and related equipment (non-controlled)",
        "items_controlled": "General aviation aircraft, non-military aircraft",
        "control_reasons": ["AT"],
        "product_group": "A",
    },
    "9B001": {
        "description": "Aerospace test equipment",
        "items_controlled": "Equipment for testing gas turbine engines, wind tunnels",
        "control_reasons": ["NS"],
        "product_group": "B",
    },
    "9B002": {
        "description": "Aero gas turbine engine test equipment",
        "items_controlled": "Equipment for testing aero gas turbine engines",
        "control_reasons": ["NS"],
        "product_group": "B",
    },
    "9C001": {
        "description": "Aerospace materials",
        "items_controlled": "Materials for aerospace equipment manufacturing",
        "control_reasons": ["NS", "MT"],
        "product_group": "C",
    },
    "9C002": {
        "description": "Composite materials for aerospace",
        "items_controlled": "Fibrous or filamentary materials for aerospace applications",
        "control_reasons": ["NS", "MT"],
        "product_group": "C",
    },
    "9D001": {
        "description": "Software for aerospace",
        "items_controlled": "Software for design, development, or production of aerospace equipment",
        "control_reasons": ["NS", "MT"],
        "product_group": "D",
    },
    "9E001": {
        "description": "Technology for aerospace",
        "items_controlled": "Technology for development or production of aerospace equipment",
        "control_reasons": ["NS", "MT"],
        "product_group": "E",
    },
    "9E002": {
        "description": "Technology for aerospace propulsion",
        "items_controlled": "Technology for development or production of aerospace propulsion systems",
        "control_reasons": ["NS", "MT"],
        "product_group": "E",
    },
    "9E003": {
        "description": "Technology for missile technology",
        "items_controlled": "Technology for development or production of missiles",
        "control_reasons": ["MT"],
        "product_group": "E",
    },

    # ── Legacy / Miscellaneous ─────────────────────────────────────────
    "6A008": {
        "description": "Radar systems (specialized)",
        "items_controlled": "Specialized radar systems for military applications",
        "control_reasons": ["NS"],
        "product_group": "A",
    },
    "6A006": {
        "description": "Magnetometers (specialized)",
        "items_controlled": "Specialized magnetometers for military applications",
        "control_reasons": ["NS"],
        "product_group": "A",
    },
}


# ── Data Models ───────────────────────────────────────────────────────


@dataclass(frozen=False, slots=True)
class ECCNResult:
    """Represents an ECCN classification result.

    Attributes:
        eccn_code: The classified ECCN code (e.g., "3A001").
        confidence: Classification confidence from 0.0 to 1.0.
        category: CCL category number (0-9).
        product_group: Product group letter (A-E).
        description: Human-readable description of the ECCN.
        control_reasons: List of reasons for control (NS, MT, CB, etc.).
        license_requirement: License requirement string.
    """

    eccn_code: str
    confidence: float
    category: int
    product_group: str
    description: str
    control_reasons: list[str]
    license_requirement: str

    def __post_init__(self) -> None:
        """Validate and normalize the result after creation."""
        self.eccn_code = self.eccn_code.upper().strip()
        if len(self.eccn_code) >= 1 and self.eccn_code[0].isdigit():
            self.category = int(self.eccn_code[0])
        if len(self.eccn_code) >= 2:
            self.product_group = self.eccn_code[1].upper()

    @property
    def needs_review(self) -> bool:
        """Whether human review is recommended (low confidence or LLM-only)."""
        return self.confidence < MEDIUM_CONFIDENCE_THRESHOLD


@dataclass(frozen=False, slots=True)
class ECCNInfo:
    """Detailed information about an ECCN code.

    Attributes:
        eccn_code: The ECCN code.
        category: CCL category number (0-9).
        product_group: Product group letter (A-E).
        description: Human-readable description.
        items_controlled: Specific items controlled under this ECCN.
        control_reasons: List of reasons for control.
        country_chart: Reason β†’ license requirement per country group.
    """

    eccn_code: str
    category: int
    product_group: str
    description: str
    items_controlled: str
    control_reasons: list[str]
    country_chart: dict[str, str]

    def __post_init__(self) -> None:
        """Validate and normalize the info after creation."""
        self.eccn_code = self.eccn_code.upper().strip()
        if len(self.eccn_code) >= 1 and self.eccn_code[0].isdigit():
            self.category = int(self.eccn_code[0])
        if len(self.eccn_code) >= 2:
            self.product_group = self.eccn_code[1].upper()


# ── ECCN Validator ────────────────────────────────────────────────────


class ECCNValidator:
    """Validates ECCN code structure.

    Provides static methods for structural validation of ECCN codes
    according to EAR Part 774 rules.
    """

    @staticmethod
    def is_valid_eccn(code: str) -> bool:
        """Validate ECCN code structure.

        Args:
            code: The code to validate.

        Returns:
            True if code is a valid 5-character ECCN code.
        """
        normalized: str = code.upper().strip()
        if len(normalized) != 5:
            return False
        if not normalized[0].isdigit():
            return False
        category: int = int(normalized[0])
        if category < 0 or category > 9:
            return False
        if normalized[1] not in "ABCDE":
            return False
        if not normalized[2:5].isdigit():
            return False
        return True

    @staticmethod
    def parse(code: str) -> dict[str, Any]:
        """Parse ECCN code into components.

        Args:
            code: The ECCN code to parse.

        Returns:
            Dict with keys: category, product_group, group_name, category_name.

        Raises:
            ECCNValidationError: If code is not a valid ECCN.
        """
        if not ECCNValidator.is_valid_eccn(code):
            raise ECCNValidationError(
                code=code,
                reason="Must be 5 characters: digit (0-9) + letter (A-E) + 3 digits",
            )
        normalized: str = code.upper().strip()
        category: int = int(normalized[0])
        product_group: str = normalized[1]
        return {
            "category": category,
            "product_group": product_group,
            "category_name": CCL_CATEGORIES.get(category, "Unknown"),
            "group_name": PRODUCT_GROUPS.get(product_group, "Unknown"),
        }


# ── Main Classification Engine ────────────────────────────────────────


class ECCNClassifier:
    """Main ECCN classification interface.

    Combines a static ECCN database with LLM-based classification for
    optimal accuracy. Static lookup handles known items; LLM handles
    novel or ambiguous items.

    Attributes:
        _eccn_db: Static ECCN database (embedded).
    """

    def __init__(self) -> None:
        """Initialize the ECCN classifier with the static database."""
        self._eccn_db: dict[str, dict[str, Any]] = dict(_COMMON_ECCNS)
        self._llm_provider: str = os.environ.get("MODEL_PROVIDER", "gemini")
        self._llm_model: str = os.environ.get("MODEL_NAME", "gemini-2.5-flash")
        self._llm_api_key: str = (
            os.environ.get("GEMINI_API_KEY")
            or os.environ.get("GOOGLE_API_KEY")
            or os.environ.get("MODEL_API_KEY", "")
        )

    def classify(self, product_description: str) -> ECCNResult:
        """Classify a product description to an ECCN code.

        Pipeline:
        1. Check static ECCN database for exact/description match
        2. Use LLM classification if no static match found
        3. Return result with confidence score

        Args:
            product_description: Product description to classify.

        Returns:
            ECCNResult with classification details and confidence.
        """
        start_time: float = _time.time()

        # Step 1: Static database lookup (keyword matching)
        static_result: Optional[ECCNResult] = self._static_lookup(product_description)
        if static_result is not None and static_result.confidence >= HIGH_CONFIDENCE_THRESHOLD:
            logger.info(
                "Static ECCN lookup HIT β†’ %s (%.0f%%)",
                static_result.eccn_code,
                static_result.confidence * 100,
            )
            return static_result

        # Step 2: LLM classification
        llm_result: Optional[ECCNResult] = self._classify_with_llm(product_description)
        if llm_result is not None:
            logger.info(
                "LLM ECCN classification β†’ %s (%.0f%%, %.1fms)",
                llm_result.eccn_code,
                llm_result.confidence * 100,
                (_time.time() - start_time) * 1000,
            )
            return llm_result

        # Step 3: Return best available result
        if static_result is not None:
            return static_result

        # Fallback: unknown classification
        return ECCNResult(
            eccn_code="0A999",
            confidence=0.0,
            category=0,
            product_group="A",
            description="Unable to classify β€” manual review required",
            control_reasons=[],
            license_requirement="Unknown β€” consult BIS",
        )

    def lookup(self, eccn_code: str) -> ECCNInfo:
        """Look up an ECCN code for detailed information.

        Args:
            eccn_code: The ECCN code to look up (e.g., "3A001").

        Returns:
            ECCNInfo with full details about the ECCN.

        Raises:
            ECCNValidationError: If code structure is invalid.
            ECCNLookupError: If code is not found in the database.
        """
        normalized: str = eccn_code.upper().strip()
        if not ECCNValidator.is_valid_eccn(normalized):
            raise ECCNValidationError(
                code=eccn_code,
                reason="Must be 5 characters: digit (0-9) + letter (A-E) + 3 digits",
            )

        info: Optional[dict[str, Any]] = self._eccn_db.get(normalized)
        if info is None:
            raise ECCNLookupError(f"ECCN code '{normalized}' not found in database")

        category: int = int(normalized[0])
        product_group: str = normalized[1]
        control_reasons: list[str] = info.get("control_reasons", [])

        country_chart: dict[str, str] = {}
        for reason in control_reasons:
            country_chart[reason] = COUNTRY_CHART.get(reason, "License requirement not specified")

        return ECCNInfo(
            eccn_code=normalized,
            category=category,
            product_group=product_group,
            description=info.get("description", ""),
            items_controlled=info.get("items_controlled", ""),
            control_reasons=control_reasons,
            country_chart=country_chart,
        )

    def get_control_reason(self, eccn_code: str) -> list[str]:
        """Get reasons for control for an ECCN code.

        Args:
            eccn_code: The ECCN code to look up.

        Returns:
            List of control reason codes (e.g., ["NS", "AT"]).

        Raises:
            ECCNValidationError: If code structure is invalid.
            ECCNLookupError: If code is not found in the database.
        """
        info: ECCNInfo = self.lookup(eccn_code)
        return info.control_reasons

    def _static_lookup(self, description: str) -> Optional[ECCNResult]:
        """Search the static ECCN database for a matching description.

        Uses keyword matching against descriptions and items_controlled
        fields in the embedded database.

        Args:
            description: Product description to search for.

        Returns:
            ECCNResult if a match is found, None otherwise.
        """
        desc_lower: str = description.lower().strip()
        words: set[str] = set(re.findall(r"[a-zA-Z]{3,}", desc_lower))

        best_match: Optional[tuple[str, float, dict[str, Any]]] = None

        for eccn_code, info in self._eccn_db.items():
            desc_text: str = info.get("description", "").lower()
            items_text: str = info.get("items_controlled", "").lower()
            combined: str = f"{desc_text} {items_text}"

            # Exact substring match
            if desc_lower in combined or combined in desc_lower:
                score: float = 0.95
                if best_match is None or score > best_match[1]:
                    best_match = (eccn_code, score, info)
                continue

            # Token overlap matching
            combined_words: set[str] = set(re.findall(r"[a-zA-Z]{3,}", combined))
            if not combined_words:
                continue

            overlap: set[str] = words & combined_words
            if not overlap:
                continue

            overlap_ratio: float = len(overlap) / len(words) if words else 0.0
            score = 0.6 + (overlap_ratio * 0.3)
            score = min(score, 0.90)

            if best_match is None or score > best_match[1]:
                best_match = (eccn_code, score, info)

        if best_match is None:
            return None

        eccn_code, confidence, info = best_match
        category: int = int(eccn_code[0])
        product_group: str = eccn_code[1]
        control_reasons: list[str] = info.get("control_reasons", [])

        license_parts: list[str] = []
        for reason in control_reasons:
            req: str = COUNTRY_CHART.get(reason, "License requirement not specified")
            license_parts.append(req)

        return ECCNResult(
            eccn_code=eccn_code,
            confidence=confidence,
            category=category,
            product_group=product_group,
            description=info.get("description", ""),
            control_reasons=control_reasons,
            license_requirement="; ".join(license_parts) if license_parts else "No license required",
        )

    def _classify_with_llm(self, description: str) -> Optional[ECCNResult]:
        """Use LLM for ECCN classification.

        Sends a prompt to Gemini 2.5 Flash via Google AI API
        and parses the structured response.

        Args:
            description: Product description to classify.

        Returns:
            ECCNResult from LLM, or None if unavailable/failed.
        """
        prompt: str = self._build_classification_prompt(description)

        if not self._llm_api_key:
            logger.warning("No LLM API key configured β€” skipping LLM classification")
            return None

        try:
            url = (
                f"https://generativelanguage.googleapis.com/v1beta/"
                f"models/{self._llm_model}:generateContent?key={self._llm_api_key}"
            )
            payload = {
                "contents": [{"parts": [{"text": prompt}]}],
                "generationConfig": {
                    "temperature": 0.1,
                    "maxOutputTokens": 4096,
                },
            }

            with httpx.Client(timeout=30.0) as client:
                resp = client.post(url, json=payload)
                resp.raise_for_status()
                data = resp.json()

            content = data["candidates"][0]["content"]["parts"][0]["text"]
            return self._parse_llm_response(content, description)

        except Exception as exc:
            logger.error("LLM ECCN classification failed: %s", exc)
            return None

    def _build_classification_prompt(self, description: str) -> str:
        """Build prompt for LLM ECCN classification.

        Args:
            description: Product description to classify.

        Returns:
            Formatted prompt string.
        """
        categories_text: str = "\n".join(
            f"  Category {cat_id}: {cat_name}"
            for cat_id, cat_name in sorted(CCL_CATEGORIES.items())
        )
        groups_text: str = "\n".join(
            f"  Group {grp_id}: {grp_name}"
            for grp_id, grp_name in PRODUCT_GROUPS.items()
        )

        return (
            "You are an expert export control compliance officer specializing in "
            "ECCN (Export Control Classification Number) classification under the "
            "US Bureau of Industry and Security (BIS) Export Administration "
            "Regulations (EAR).\n\n"
            "The Commerce Control List (CCL) has 10 categories (0-9) and "
            "5 product groups (A-E):\n\n"
            f"CCL Categories:\n{categories_text}\n\n"
            f"Product Groups:\n{groups_text}\n\n"
            f"Classify the following product to an ECCN code:\n\n"
            f"Product Description: {description}\n\n"
            "Provide your classification in JSON format:\n"
            "{\n"
            '    "eccn_code": "XYYYY (e.g., 3A001)",\n'
            '    "confidence": 0.0-1.0,\n'
            '    "category": 0-9,\n'
            '    "product_group": "A-E",\n'
            '    "description": "Brief description of the ECCN classification",\n'
            '    "control_reasons": ["NS", "AT", ...],\n'
            '    "license_requirement": "License requirement summary"\n'
            "}\n\n"
            "Common control reasons:\n"
            "  NS = National Security\n"
            "  MT = Missile Technology\n"
            "  CB = Chemical & Biological Weapons\n"
            "  CC = Chemical & Biological Weapons (end-use)\n"
            "  RS = Regional Stability\n"
            "  AT = Anti-Terrorism\n"
            "  EI = Encryption Items\n"
            "  SL = Surreptitious Listening\n\n"
            "Rules:\n"
            "1. Use the 5-character ECCN format: digit (category) + letter (group) + 3 digits\n"
            "2. If the item is not controlled under any ECCN, use EAR99\n"
            "3. Provide the most specific ECCN that applies\n"
            "4. Include applicable control reasons\n"
            "5. Return ONLY the JSON object, no other text"
        )

    def _parse_llm_response(
        self, content: str, original_description: str
    ) -> Optional[ECCNResult]:
        """Parse LLM response into ECCNResult.

        LLM self-reported confidence is capped at LLM_MAX_CONFIDENCE (0.85)
        because LLMs may output inflated confidence for hallucinated codes.

        Args:
            content: Raw LLM response text.
            original_description: Original product description.

        Returns:
            ECCNResult if parsing succeeds, None otherwise.
        """
        try:
            # Strip markdown code blocks if present
            cleaned: str = content.strip()
            if cleaned.startswith("```"):
                first_newline: int = cleaned.find("\n")
                if first_newline != -1:
                    cleaned = cleaned[first_newline + 1:]
                if cleaned.rstrip().endswith("```"):
                    cleaned = cleaned.rstrip()[:-len("```")].rstrip()

            # Extract outermost JSON block
            start: int = cleaned.find("{")
            if start == -1:
                logger.error("No JSON found in LLM response")
                return None
            depth: int = 0
            end: int = -1
            for i in range(start, len(cleaned)):
                if cleaned[i] == "{":
                    depth += 1
                elif cleaned[i] == "}":
                    depth -= 1
                    if depth == 0:
                        end = i
                        break
            if end == -1:
                logger.error("Unbalanced JSON braces in LLM response")
                return None

            data: dict[str, Any] = json.loads(cleaned[start:end + 1])

            eccn_code: str = data.get("eccn_code", "")
            # Normalize: strip dots, dashes, spaces
            eccn_code = re.sub(r"[.\-\s]", "", eccn_code).upper()

            # Validate ECCN format
            if not ECCNValidator.is_valid_eccn(eccn_code):
                logger.warning("LLM returned invalid ECCN: %s", eccn_code)
                return None

            raw_confidence: float = data.get("confidence", 0.5)
            capped_confidence: float = min(raw_confidence, LLM_MAX_CONFIDENCE)
            if raw_confidence > LLM_MAX_CONFIDENCE:
                logger.warning(
                    "LLM self-reported confidence %.2f capped to %.2f",
                    raw_confidence,
                    LLM_MAX_CONFIDENCE,
                )

            category: int = int(eccn_code[0])
            product_group: str = eccn_code[1]
            control_reasons: list[str] = data.get("control_reasons", [])
            license_req: str = data.get("license_requirement", "")

            # Build license requirement from control reasons if not provided
            if not license_req and control_reasons:
                license_parts: list[str] = []
                for reason in control_reasons:
                    req: str = COUNTRY_CHART.get(reason, "License requirement not specified")
                    license_parts.append(req)
                license_req = "; ".join(license_parts)

            return ECCNResult(
                eccn_code=eccn_code,
                confidence=capped_confidence,
                category=category,
                product_group=product_group,
                description=data.get("description", ""),
                control_reasons=control_reasons,
                license_requirement=license_req,
            )

        except Exception as exc:
            logger.error("Failed to parse LLM ECCN response: %s", exc)
            return None


# ── Convenience Functions ─────────────────────────────────────────────

_engine: Optional[ECCNClassifier] = None
_engine_lock: threading.Lock = threading.Lock()


def get_engine() -> ECCNClassifier:
    """Get or create the global ECCN classifier (thread-safe).

    Returns:
        The singleton ECCNClassifier instance.
    """
    global _engine
    if _engine is None:
        with _engine_lock:
            if _engine is None:
                _engine = ECCNClassifier()
    return _engine


def classify_product(description: str) -> ECCNResult:
    """Classify a product to an ECCN code.

    Args:
        description: Product description.

    Returns:
        ECCNResult with classification details and confidence.
    """
    engine: ECCNClassifier = get_engine()
    return engine.classify(description)


def lookup_eccn(eccn_code: str) -> ECCNInfo:
    """Look up an ECCN code for detailed information.

    Args:
        eccn_code: The ECCN code to look up.

    Returns:
        ECCNInfo with full details about the ECCN.
    """
    engine: ECCNClassifier = get_engine()
    return engine.lookup(eccn_code)


def get_control_reasons(eccn_code: str) -> list[str]:
    """Get reasons for control for an ECCN code.

    Args:
        eccn_code: The ECCN code to look up.

    Returns:
        List of control reason codes.
    """
    engine: ECCNClassifier = get_engine()
    return engine.get_control_reason(eccn_code)