Soulay commited on
Commit
ec393c2
Β·
1 Parent(s): 0ae0efc

fourth commit

Browse files
Files changed (4) hide show
  1. .env.example +14 -0
  2. .gitignore +1 -0
  3. app.py +737 -54
  4. requirements.txt +1 -0
.env.example ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Customs Compass - Environment Configuration
2
+ #
3
+ # Copy this file to `.env` and fill in your real values. The .env file
4
+ # is loaded automatically when the app starts and is git-ignored.
5
+
6
+ # --- Ollama (local LLM for the main Q&A assistant) ---
7
+ OLLAMA_URL=http://localhost:11434
8
+ OLLAMA_MODEL=llama3.2:3b
9
+
10
+ # --- OrbitAI (used for premium agent features: Market Intelligence,
11
+ # Localization, GTM roadmap, etc.). Get your key from Orbit AI dashboard.
12
+ ORBITAI_API_KEY=
13
+ ORBITAI_BASE_URL=https://api.orbitai.global/v1
14
+ ORBITAI_MODEL=gpt-5.4
.gitignore CHANGED
@@ -3,3 +3,4 @@ __pycache__/
3
  .env
4
  venv/
5
  .venv/
 
 
3
  .env
4
  venv/
5
  .venv/
6
+ .streamlit/secrets.toml
app.py CHANGED
@@ -7,6 +7,7 @@ electronics) exporting to the United States.
7
  from __future__ import annotations
8
 
9
  import json
 
10
  import re
11
  from html.parser import HTMLParser
12
  from pathlib import Path
@@ -16,23 +17,36 @@ import pandas as pd
16
  import requests
17
  import streamlit as st
18
 
 
 
 
 
 
 
19
 
20
  # =============================================================================
21
  # Section A β€” Configuration
22
  # =============================================================================
23
 
24
  st.set_page_config(
25
- page_title="Customs Compass",
26
  page_icon="🧭",
27
  layout="wide",
28
  initial_sidebar_state="expanded",
29
  )
30
 
31
- OLLAMA_URL = "http://localhost:11434"
32
- OLLAMA_MODEL = "llama3.2:3b"
33
  OLLAMA_TIMEOUT = 30
34
  OLLAMA_PROBE_TIMEOUT = 2
35
 
 
 
 
 
 
 
 
36
  CBP_NEWSROOM_URL = "https://www.cbp.gov/newsroom"
37
  CBP_TRADE_URL = "https://www.cbp.gov/trade"
38
  NEWS_FETCH_TIMEOUT = 5
@@ -42,6 +56,15 @@ USER_AGENT = "CustomsCompass/1.0 (Educational)"
42
 
43
  DATA_DIR = Path(__file__).parent
44
 
 
 
 
 
 
 
 
 
 
45
  SYSTEM_PROMPT = (
46
  "You are Customs Compass, an AI assistant specialized in US sales tax, "
47
  "nexus thresholds, customs duties, and product compliance. Rules: "
@@ -697,6 +720,55 @@ def call_ollama(system_prompt: str, user_prompt: str) -> str:
697
  return data.get("message", {}).get("content", "").strip()
698
 
699
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
700
  # --- Fallback engine -----------------------------------------------------------
701
 
702
  RISK_BADGE = {
@@ -1031,39 +1103,49 @@ def render_response(answer: str, mode: str, payload: dict, news_items: list[dict
1031
  })
1032
 
1033
  if payload.get("alerts"):
1034
- with st.expander(f"⚠️ CBP alerts triggered ({len(payload['alerts'])})", expanded=True):
1035
- for alert in payload["alerts"]:
1036
- st.markdown(
1037
- f"**[{alert['severity']}] {alert['category']} β€” {alert['title']}**\n\n"
1038
- f"{alert['summary']}\n\n"
1039
- f"_Action required:_ {alert['action_required']}\n\n"
1040
- f"[Source]({alert['source_url']})"
1041
- )
1042
- st.markdown("---")
 
 
 
 
 
 
 
 
 
1043
 
1044
  if payload.get("chunks"):
1045
- with st.expander(f"πŸ“š CBP knowledge base excerpts ({len(payload['chunks'])})", expanded=True):
1046
- st.caption(
1047
- "Full text from the most relevant CBP pages β€” no need to click out. "
1048
- "The link goes to the original source page on cbp.gov."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1049
  )
1050
- for c in payload["chunks"]:
1051
- published = c.get("published_date", "")
1052
- meta = f"section: {c['section']} Β· type: {c['page_type']} Β· relevance: {c['score']}"
1053
- if published:
1054
- meta += f" Β· {published}"
1055
- st.markdown(f"**{c['title']}** \n_{meta}_")
1056
- full = c.get("full_text") or c.get("excerpt", "")
1057
- import html as _html
1058
- safe = _html.escape(full)
1059
- st.markdown(
1060
- f"<div style='background:#f6f8fa;padding:12px;border-left:4px solid #4a90e2;"
1061
- f"border-radius:4px;font-size:0.92em;line-height:1.5;white-space:pre-wrap'>"
1062
- f"{safe}</div>",
1063
- unsafe_allow_html=True,
1064
- )
1065
- st.markdown(f"πŸ”— [View original page on cbp.gov]({c['url']})")
1066
- st.markdown("---")
1067
 
1068
  if payload.get("news"):
1069
  with st.expander(f"πŸ“° Relevant CBP news ({len(payload['news'])})"):
@@ -1074,10 +1156,586 @@ def render_response(answer: str, mode: str, payload: dict, news_items: list[dict
1074
 
1075
 
1076
  # =============================================================================
1077
- # Section H β€” Main Entry Point
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1078
  # =============================================================================
1079
 
1080
  def main() -> None:
 
 
 
1081
  # Load data
1082
  try:
1083
  nexus_df = load_nexus_thresholds()
@@ -1128,29 +1786,54 @@ def main() -> None:
1128
 
1129
  if sidebar_state["example_clicked"]:
1130
  st.session_state["prefilled"] = sidebar_state["example_clicked"]
 
 
1131
  st.rerun()
1132
 
1133
- form = render_main_form(prefilled_question=st.session_state.get("prefilled", ""))
1134
-
1135
- if form["submitted"]:
1136
- combined = (form["product_desc"] + "\n" + form["question"]).strip()
1137
- if not combined:
1138
- st.warning("Please enter a product description or a question.")
1139
- return
1140
-
1141
- with st.spinner("Analyzing… (consulting knowledge base and LLM)"):
1142
- answer, mode, payload = get_answer(
1143
- question=combined,
1144
- nexus_df=nexus_df,
1145
- hts_df=hts_df,
1146
- tax_rates=tax_rates,
1147
- news_items=news_items,
1148
- alerts_df=alerts_df,
1149
- chunk_index=chunk_index,
1150
- force_fallback=sidebar_state["force_fallback"],
1151
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1152
 
1153
- render_response(answer, mode, payload, news_items)
 
 
1154
 
1155
 
1156
  if __name__ == "__main__":
 
7
  from __future__ import annotations
8
 
9
  import json
10
+ import os
11
  import re
12
  from html.parser import HTMLParser
13
  from pathlib import Path
 
17
  import requests
18
  import streamlit as st
19
 
20
+ try:
21
+ from dotenv import load_dotenv
22
+ load_dotenv(Path(__file__).parent / ".env")
23
+ except ImportError:
24
+ pass
25
+
26
 
27
  # =============================================================================
28
  # Section A β€” Configuration
29
  # =============================================================================
30
 
31
  st.set_page_config(
32
+ page_title="Customs Compass β€” AI Trade Compliance",
33
  page_icon="🧭",
34
  layout="wide",
35
  initial_sidebar_state="expanded",
36
  )
37
 
38
+ OLLAMA_URL = os.getenv("OLLAMA_URL", "http://localhost:11434")
39
+ OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "llama3.2:3b")
40
  OLLAMA_TIMEOUT = 30
41
  OLLAMA_PROBE_TIMEOUT = 2
42
 
43
+ # OrbitAI β€” used for premium agent features (market intel, localization,
44
+ # GTM roadmap). The main Q&A flow still uses local Ollama for privacy/cost.
45
+ ORBITAI_API_KEY = os.getenv("ORBITAI_API_KEY", "")
46
+ ORBITAI_BASE_URL = os.getenv("ORBITAI_BASE_URL", "https://api.orbitai.global/v1")
47
+ ORBITAI_MODEL = os.getenv("ORBITAI_MODEL", "gpt-5.4")
48
+ ORBITAI_TIMEOUT = 60
49
+
50
  CBP_NEWSROOM_URL = "https://www.cbp.gov/newsroom"
51
  CBP_TRADE_URL = "https://www.cbp.gov/trade"
52
  NEWS_FETCH_TIMEOUT = 5
 
56
 
57
  DATA_DIR = Path(__file__).parent
58
 
59
+ # US customs fees (FY2026 rates)
60
+ MPF_RATE = 0.003464 # Merchandise Processing Fee: 0.3464% ad valorem
61
+ MPF_MIN = 32.71 # USD
62
+ MPF_MAX = 634.62 # USD
63
+ HMF_RATE = 0.00125 # Harbor Maintenance Fee: 0.125% (sea freight only)
64
+
65
+ # Section 301 List-4A surcharge on most Chinese electronics
66
+ SECTION_301_RATE = 0.25 # 25% additional ad valorem
67
+
68
  SYSTEM_PROMPT = (
69
  "You are Customs Compass, an AI assistant specialized in US sales tax, "
70
  "nexus thresholds, customs duties, and product compliance. Rules: "
 
720
  return data.get("message", {}).get("content", "").strip()
721
 
722
 
723
+ # --- OrbitAI client (OpenAI-compatible) ---------------------------------------
724
+ # Used for premium agent features that need a stronger model than llama3.2:3b
725
+ # (market intelligence, localization, GTM roadmap generation, document analysis).
726
+
727
+ def is_orbitai_configured() -> bool:
728
+ return bool(ORBITAI_API_KEY) and ORBITAI_API_KEY.startswith("sk-")
729
+
730
+
731
+ def call_orbitai(
732
+ system_prompt: str,
733
+ user_prompt: str,
734
+ model: Optional[str] = None,
735
+ temperature: float = 0.4,
736
+ ) -> str:
737
+ """Call OrbitAI's OpenAI-compatible chat completions endpoint.
738
+
739
+ Raises requests.RequestException on network errors. Callers should catch
740
+ and fall back gracefully (typically to Ollama or a deterministic template).
741
+ """
742
+ if not is_orbitai_configured():
743
+ raise RuntimeError("ORBITAI_API_KEY is not set; cannot call OrbitAI.")
744
+ payload = {
745
+ "model": model or ORBITAI_MODEL,
746
+ "messages": [
747
+ {"role": "system", "content": system_prompt},
748
+ {"role": "user", "content": user_prompt},
749
+ ],
750
+ "temperature": temperature,
751
+ "stream": False,
752
+ }
753
+ headers = {
754
+ "Authorization": f"Bearer {ORBITAI_API_KEY}",
755
+ "Content-Type": "application/json",
756
+ "User-Agent": USER_AGENT,
757
+ }
758
+ resp = requests.post(
759
+ f"{ORBITAI_BASE_URL.rstrip('/')}/chat/completions",
760
+ json=payload,
761
+ headers=headers,
762
+ timeout=ORBITAI_TIMEOUT,
763
+ )
764
+ resp.raise_for_status()
765
+ data = resp.json()
766
+ choices = data.get("choices") or []
767
+ if not choices:
768
+ return ""
769
+ return (choices[0].get("message") or {}).get("content", "").strip()
770
+
771
+
772
  # --- Fallback engine -----------------------------------------------------------
773
 
774
  RISK_BADGE = {
 
1103
  })
1104
 
1105
  if payload.get("alerts"):
1106
+ st.markdown(f"#### ⚠️ CBP alerts triggered ({len(payload['alerts'])})")
1107
+ import html as _html
1108
+ for alert in payload["alerts"]:
1109
+ sev = alert["severity"].lower()
1110
+ st.markdown(
1111
+ f"""
1112
+ <div class="cc-alert cc-alert-{sev}">
1113
+ <div class="cc-alert-head">
1114
+ <span class="cc-badge cc-badge-{sev}">{alert['severity']}</span>
1115
+ <span>{alert['category']} β€” {alert['title']}</span>
1116
+ </div>
1117
+ <div class="cc-alert-body">{_html.escape(alert['summary'])}</div>
1118
+ <div class="cc-alert-action">βœ… <b>Action:</b> {_html.escape(alert['action_required'])}</div>
1119
+ <div style="margin-top:6px"><a href="{alert['source_url']}" target="_blank">πŸ”— Source on cbp.gov</a></div>
1120
+ </div>
1121
+ """,
1122
+ unsafe_allow_html=True,
1123
+ )
1124
 
1125
  if payload.get("chunks"):
1126
+ st.markdown(f"#### πŸ“š CBP knowledge base excerpts ({len(payload['chunks'])})")
1127
+ st.caption(
1128
+ "Full text from the most relevant CBP pages β€” no need to click out. The link goes to the source page."
1129
+ )
1130
+ import html as _html
1131
+ for c in payload["chunks"]:
1132
+ published = c.get("published_date", "")
1133
+ meta = f"{c['section']} Β· {c['page_type']} Β· relevance {c['score']}"
1134
+ if published:
1135
+ meta += f" Β· {published}"
1136
+ full = c.get("full_text") or c.get("excerpt", "")
1137
+ safe = _html.escape(full)
1138
+ st.markdown(
1139
+ f"""
1140
+ <div class="cc-card">
1141
+ <div class="cc-card-title">{_html.escape(c['title'])}</div>
1142
+ <div class="cc-card-meta">{meta}</div>
1143
+ <div class="cc-chunk">{safe}</div>
1144
+ <div style="margin-top:10px"><a href="{c['url']}" target="_blank">πŸ”— View original page on cbp.gov</a></div>
1145
+ </div>
1146
+ """,
1147
+ unsafe_allow_html=True,
1148
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1149
 
1150
  if payload.get("news"):
1151
  with st.expander(f"πŸ“° Relevant CBP news ({len(payload['news'])})"):
 
1156
 
1157
 
1158
  # =============================================================================
1159
+ # Section H β€” Premium Visual Polish (custom CSS + hero)
1160
+ # =============================================================================
1161
+
1162
+ _CSS = """
1163
+ <style>
1164
+ /* ----------- Fonts & base ----------- */
1165
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap');
1166
+
1167
+ html, body, [class*="css"], .stApp {
1168
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
1169
+ }
1170
+
1171
+ code, pre, .stCode {
1172
+ font-family: 'JetBrains Mono', monospace !important;
1173
+ }
1174
+
1175
+ /* ----------- Hide default Streamlit chrome ----------- */
1176
+ #MainMenu {visibility: hidden;}
1177
+ footer {visibility: hidden;}
1178
+ header[data-testid="stHeader"] {background: transparent;}
1179
+
1180
+ /* ----------- Color tokens ----------- */
1181
+ :root {
1182
+ --cc-primary: #6366F1;
1183
+ --cc-primary-dark: #4F46E5;
1184
+ --cc-accent: #EC4899;
1185
+ --cc-surface: #F8FAFC;
1186
+ --cc-border: #E2E8F0;
1187
+ --cc-text: #0F172A;
1188
+ --cc-muted: #64748B;
1189
+ --cc-success: #10B981;
1190
+ --cc-warning: #F59E0B;
1191
+ --cc-danger: #EF4444;
1192
+ --cc-critical: #DC2626;
1193
+ }
1194
+
1195
+ /* ----------- Hero header ----------- */
1196
+ .cc-hero {
1197
+ background: linear-gradient(135deg, #6366F1 0%, #8B5CF6 50%, #EC4899 100%);
1198
+ border-radius: 16px;
1199
+ padding: 28px 32px;
1200
+ margin-bottom: 20px;
1201
+ color: white;
1202
+ box-shadow: 0 10px 30px -10px rgba(99,102,241,0.45);
1203
+ }
1204
+ .cc-hero h1 {
1205
+ font-size: 2.0rem;
1206
+ font-weight: 800;
1207
+ margin: 0 0 4px 0;
1208
+ color: white;
1209
+ letter-spacing: -0.02em;
1210
+ }
1211
+ .cc-hero p {
1212
+ margin: 0;
1213
+ font-size: 1.0rem;
1214
+ opacity: 0.95;
1215
+ font-weight: 400;
1216
+ }
1217
+ .cc-hero-stats {
1218
+ display: flex;
1219
+ gap: 12px;
1220
+ margin-top: 16px;
1221
+ flex-wrap: wrap;
1222
+ }
1223
+ .cc-stat {
1224
+ background: rgba(255,255,255,0.18);
1225
+ backdrop-filter: blur(8px);
1226
+ -webkit-backdrop-filter: blur(8px);
1227
+ padding: 8px 14px;
1228
+ border-radius: 100px;
1229
+ font-size: 0.85rem;
1230
+ font-weight: 500;
1231
+ border: 1px solid rgba(255,255,255,0.25);
1232
+ }
1233
+ .cc-stat b { font-weight: 700; }
1234
+
1235
+ /* ----------- Tabs ----------- */
1236
+ .stTabs [data-baseweb="tab-list"] {
1237
+ gap: 4px;
1238
+ border-bottom: 2px solid var(--cc-border);
1239
+ }
1240
+ .stTabs [data-baseweb="tab"] {
1241
+ padding: 10px 18px;
1242
+ border-radius: 8px 8px 0 0;
1243
+ font-weight: 600;
1244
+ color: var(--cc-muted);
1245
+ background: transparent;
1246
+ }
1247
+ .stTabs [data-baseweb="tab"][aria-selected="true"] {
1248
+ color: var(--cc-primary);
1249
+ background: linear-gradient(180deg, rgba(99,102,241,0.06), transparent);
1250
+ border-bottom: 2px solid var(--cc-primary);
1251
+ }
1252
+
1253
+ /* ----------- Buttons ----------- */
1254
+ .stButton > button[kind="primary"] {
1255
+ background: linear-gradient(135deg, #6366F1, #8B5CF6);
1256
+ border: 0;
1257
+ font-weight: 600;
1258
+ padding: 0.6rem 1.3rem;
1259
+ box-shadow: 0 4px 14px rgba(99,102,241,0.35);
1260
+ transition: transform 0.15s ease, box-shadow 0.15s ease;
1261
+ }
1262
+ .stButton > button[kind="primary"]:hover {
1263
+ transform: translateY(-1px);
1264
+ box-shadow: 0 8px 24px rgba(99,102,241,0.45);
1265
+ }
1266
+
1267
+ /* ----------- Card components (use via st.markdown + html) ----------- */
1268
+ .cc-card {
1269
+ background: white;
1270
+ border: 1px solid var(--cc-border);
1271
+ border-radius: 12px;
1272
+ padding: 18px 20px;
1273
+ box-shadow: 0 1px 3px rgba(0,0,0,0.04);
1274
+ margin-bottom: 12px;
1275
+ }
1276
+ .cc-card-title {
1277
+ font-weight: 700;
1278
+ font-size: 1rem;
1279
+ margin-bottom: 4px;
1280
+ color: var(--cc-text);
1281
+ }
1282
+ .cc-card-meta {
1283
+ font-size: 0.78rem;
1284
+ color: var(--cc-muted);
1285
+ margin-bottom: 10px;
1286
+ font-weight: 500;
1287
+ }
1288
+ .cc-card-body {
1289
+ color: #334155;
1290
+ font-size: 0.92rem;
1291
+ line-height: 1.55;
1292
+ }
1293
+
1294
+ /* ----------- Risk badges ----------- */
1295
+ .cc-badge {
1296
+ display: inline-block;
1297
+ padding: 4px 10px;
1298
+ border-radius: 100px;
1299
+ font-size: 0.78rem;
1300
+ font-weight: 600;
1301
+ letter-spacing: 0.02em;
1302
+ text-transform: uppercase;
1303
+ }
1304
+ .cc-badge-low { background: #D1FAE5; color: #047857; }
1305
+ .cc-badge-medium { background: #FEF3C7; color: #92400E; }
1306
+ .cc-badge-high { background: #FEE2E2; color: #B91C1C; }
1307
+ .cc-badge-critical { background: #DC2626; color: white; }
1308
+ .cc-badge-info { background: #DBEAFE; color: #1E40AF; }
1309
+ .cc-badge-unknown { background: #E2E8F0; color: #475569; }
1310
+
1311
+ /* ----------- Alert cards (color-coded by severity) ----------- */
1312
+ .cc-alert {
1313
+ border-left: 4px solid;
1314
+ padding: 14px 18px;
1315
+ border-radius: 8px;
1316
+ margin-bottom: 12px;
1317
+ background: white;
1318
+ box-shadow: 0 1px 2px rgba(0,0,0,0.04);
1319
+ }
1320
+ .cc-alert-critical { border-color: var(--cc-critical); background: #FEF2F2; }
1321
+ .cc-alert-high { border-color: var(--cc-danger); background: #FFF7ED; }
1322
+ .cc-alert-medium { border-color: var(--cc-warning); background: #FFFBEB; }
1323
+ .cc-alert-info { border-color: var(--cc-primary); background: #EEF2FF; }
1324
+
1325
+ .cc-alert-head {
1326
+ display: flex;
1327
+ align-items: center;
1328
+ gap: 8px;
1329
+ margin-bottom: 6px;
1330
+ font-weight: 700;
1331
+ color: var(--cc-text);
1332
+ }
1333
+ .cc-alert-body {
1334
+ font-size: 0.9rem;
1335
+ color: #334155;
1336
+ line-height: 1.5;
1337
+ }
1338
+ .cc-alert-action {
1339
+ margin-top: 8px;
1340
+ padding: 8px 12px;
1341
+ background: rgba(99,102,241,0.07);
1342
+ border-radius: 6px;
1343
+ font-size: 0.85rem;
1344
+ color: #1E293B;
1345
+ }
1346
+
1347
+ /* ----------- Big-number stat card for cost calculator ----------- */
1348
+ .cc-total-card {
1349
+ background: linear-gradient(135deg, #6366F1, #8B5CF6);
1350
+ color: white;
1351
+ padding: 24px;
1352
+ border-radius: 14px;
1353
+ text-align: center;
1354
+ box-shadow: 0 10px 25px -8px rgba(99,102,241,0.5);
1355
+ }
1356
+ .cc-total-card .label {
1357
+ font-size: 0.85rem;
1358
+ opacity: 0.9;
1359
+ text-transform: uppercase;
1360
+ letter-spacing: 0.08em;
1361
+ font-weight: 500;
1362
+ }
1363
+ .cc-total-card .amount {
1364
+ font-size: 2.4rem;
1365
+ font-weight: 800;
1366
+ margin: 4px 0;
1367
+ letter-spacing: -0.02em;
1368
+ }
1369
+ .cc-total-card .markup {
1370
+ font-size: 0.85rem;
1371
+ opacity: 0.92;
1372
+ }
1373
+
1374
+ /* ----------- Chunk excerpts ----------- */
1375
+ .cc-chunk {
1376
+ background: #F8FAFC;
1377
+ border-left: 4px solid var(--cc-primary);
1378
+ border-radius: 6px;
1379
+ padding: 12px 16px;
1380
+ font-size: 0.9em;
1381
+ line-height: 1.55;
1382
+ white-space: pre-wrap;
1383
+ color: #1E293B;
1384
+ margin: 8px 0;
1385
+ }
1386
+
1387
+ /* ----------- Sidebar improvements ----------- */
1388
+ [data-testid="stSidebar"] {
1389
+ background: linear-gradient(180deg, #0F172A 0%, #1E293B 100%);
1390
+ }
1391
+ [data-testid="stSidebar"] * { color: #E2E8F0 !important; }
1392
+ [data-testid="stSidebar"] h1, [data-testid="stSidebar"] h2, [data-testid="stSidebar"] h3 {
1393
+ color: white !important;
1394
+ }
1395
+ [data-testid="stSidebar"] a { color: #A5B4FC !important; }
1396
+ [data-testid="stSidebar"] .stButton > button {
1397
+ background: rgba(255,255,255,0.08);
1398
+ color: white !important;
1399
+ border: 1px solid rgba(255,255,255,0.12);
1400
+ font-weight: 500;
1401
+ }
1402
+ [data-testid="stSidebar"] .stButton > button:hover {
1403
+ background: rgba(99,102,241,0.3);
1404
+ border-color: var(--cc-primary);
1405
+ }
1406
+
1407
+ /* ----------- Inputs ----------- */
1408
+ .stTextInput input, .stTextArea textarea, .stNumberInput input, .stSelectbox > div > div {
1409
+ border-radius: 8px !important;
1410
+ border: 1px solid var(--cc-border) !important;
1411
+ }
1412
+
1413
+ /* ----------- Misc polish ----------- */
1414
+ .cc-divider {
1415
+ height: 1px;
1416
+ background: var(--cc-border);
1417
+ margin: 18px 0;
1418
+ }
1419
+ .cc-pill {
1420
+ display: inline-block;
1421
+ padding: 3px 10px;
1422
+ background: var(--cc-surface);
1423
+ border: 1px solid var(--cc-border);
1424
+ border-radius: 100px;
1425
+ font-size: 0.75rem;
1426
+ color: var(--cc-muted);
1427
+ font-weight: 500;
1428
+ }
1429
+ </style>
1430
+ """
1431
+
1432
+
1433
+ def inject_custom_css() -> None:
1434
+ st.markdown(_CSS, unsafe_allow_html=True)
1435
+
1436
+
1437
+ def render_hero(nexus_count: int, alerts_count: int, chunks_count: int, ollama_ok: bool) -> None:
1438
+ ai_chip = "🟒 AI online" if ollama_ok else "🟑 Fallback mode"
1439
+ st.markdown(
1440
+ f"""
1441
+ <div class="cc-hero">
1442
+ <h1>🧭 Customs Compass</h1>
1443
+ <p>AI compliance copilot for Chinese exporters entering the US market β€”
1444
+ sales tax, customs duties, federal certifications, and CBP enforcement.</p>
1445
+ <div class="cc-hero-stats">
1446
+ <span class="cc-stat">πŸ‡ΊπŸ‡Έ <b>{nexus_count}</b> states + DC</span>
1447
+ <span class="cc-stat">⚠️ <b>{alerts_count}</b> CBP alerts</span>
1448
+ <span class="cc-stat">πŸ“š <b>{chunks_count}</b> RAG chunks</span>
1449
+ <span class="cc-stat">{ai_chip}</span>
1450
+ </div>
1451
+ </div>
1452
+ """,
1453
+ unsafe_allow_html=True,
1454
+ )
1455
+
1456
+
1457
+ # =============================================================================
1458
+ # Section I β€” Landed Cost Calculator
1459
+ # =============================================================================
1460
+
1461
+ def _parse_duty_rate(rate_str: str) -> float:
1462
+ """Parse '3.4%', 'Free', '2.6%' β†’ 0.034, 0.0, 0.026."""
1463
+ if not rate_str:
1464
+ return 0.0
1465
+ rate_str = str(rate_str).strip().lower()
1466
+ if rate_str in ("free", "0", "0%", "n/a", "none"):
1467
+ return 0.0
1468
+ m = re.search(r"(\d+(?:\.\d+)?)", rate_str)
1469
+ if not m:
1470
+ return 0.0
1471
+ val = float(m.group(1))
1472
+ return val / 100.0 if "%" in rate_str or val > 1 else val
1473
+
1474
+
1475
+ def compute_landed_cost(
1476
+ customs_value_usd: float,
1477
+ duty_rate_str: str,
1478
+ apply_section_301: bool,
1479
+ shipping_usd: float,
1480
+ insurance_usd: float,
1481
+ destination_state: str,
1482
+ tax_rates: dict,
1483
+ use_sea_freight: bool = True,
1484
+ ) -> dict:
1485
+ """Compute the full landed cost breakdown for a Chinese export to the US.
1486
+
1487
+ Returns a dict with every line item plus the final total.
1488
+ """
1489
+ duty_pct = _parse_duty_rate(duty_rate_str)
1490
+ base_duty = customs_value_usd * duty_pct
1491
+ s301_duty = customs_value_usd * SECTION_301_RATE if apply_section_301 else 0.0
1492
+
1493
+ mpf = max(MPF_MIN, min(MPF_MAX, customs_value_usd * MPF_RATE))
1494
+ hmf = customs_value_usd * HMF_RATE if use_sea_freight else 0.0
1495
+
1496
+ customs_total = base_duty + s301_duty + mpf + hmf
1497
+
1498
+ cif = customs_value_usd + shipping_usd + insurance_usd
1499
+ state_tax_pct = float(tax_rates.get(destination_state, 0) or 0) / 100.0
1500
+ sales_tax = (cif + customs_total) * state_tax_pct
1501
+
1502
+ total = cif + customs_total + sales_tax
1503
+ markup = (total / customs_value_usd - 1) * 100 if customs_value_usd > 0 else 0.0
1504
+
1505
+ return {
1506
+ "customs_value": customs_value_usd,
1507
+ "base_duty": base_duty,
1508
+ "base_duty_pct": duty_pct,
1509
+ "section_301_duty": s301_duty,
1510
+ "section_301_applied": apply_section_301,
1511
+ "mpf": mpf,
1512
+ "hmf": hmf,
1513
+ "shipping": shipping_usd,
1514
+ "insurance": insurance_usd,
1515
+ "cif": cif,
1516
+ "customs_total": customs_total,
1517
+ "sales_tax": sales_tax,
1518
+ "sales_tax_pct": state_tax_pct,
1519
+ "destination_state": destination_state,
1520
+ "total_landed": total,
1521
+ "markup_pct": markup,
1522
+ }
1523
+
1524
+
1525
+ def render_cost_calculator_tab(hts_df: pd.DataFrame, tax_rates: dict, alerts_df: pd.DataFrame) -> None:
1526
+ st.markdown("### πŸ’° Landed Cost Calculator")
1527
+ st.caption(
1528
+ "Compute the complete US import cost: customs duties + Section 301 + MPF + HMF + shipping + state sales tax."
1529
+ )
1530
+
1531
+ col1, col2 = st.columns([1, 1])
1532
+
1533
+ with col1:
1534
+ st.markdown("#### Product")
1535
+ categories = hts_df["product_category"].tolist() if not hts_df.empty else []
1536
+ category = st.selectbox(
1537
+ "Product category",
1538
+ options=categories,
1539
+ index=0 if categories else None,
1540
+ help="Picks the HTS code & duty rate from hts_duty_codes.csv",
1541
+ )
1542
+ customs_value = st.number_input(
1543
+ "Customs value (FOB, USD)",
1544
+ min_value=0.0,
1545
+ value=10000.0,
1546
+ step=500.0,
1547
+ help="The declared price of goods at the port of export (before shipping).",
1548
+ )
1549
+ units = st.number_input(
1550
+ "Number of units (optional)", min_value=1, value=100, step=10,
1551
+ help="Used to display per-unit landed cost.",
1552
+ )
1553
+
1554
+ with col2:
1555
+ st.markdown("#### Shipping & destination")
1556
+ origin_china = st.toggle(
1557
+ "Origin: China πŸ‡¨πŸ‡³",
1558
+ value=True,
1559
+ help="If ON, applies +25% Section 301 surcharge to electronics-class HTS codes.",
1560
+ )
1561
+ use_sea = st.toggle(
1562
+ "Sea freight (adds Harbor Maintenance Fee)",
1563
+ value=True,
1564
+ help="0.125% HMF applies to sea/water imports; air shipments are exempt.",
1565
+ )
1566
+ shipping = st.number_input("Shipping cost (USD)", min_value=0.0, value=800.0, step=50.0)
1567
+ insurance = st.number_input("Insurance (USD)", min_value=0.0, value=100.0, step=25.0)
1568
+ state_options = sorted(tax_rates.keys()) if tax_rates else ["Texas"]
1569
+ destination = st.selectbox(
1570
+ "Destination state",
1571
+ options=state_options,
1572
+ index=state_options.index("Texas") if "Texas" in state_options else 0,
1573
+ )
1574
+
1575
+ # Look up duty rate for selected category
1576
+ duty_rate_str = "0%"
1577
+ notes = ""
1578
+ fcc = ul_ = fda = ""
1579
+ if category and not hts_df.empty:
1580
+ row = hts_df[hts_df["product_category"] == category]
1581
+ if not row.empty:
1582
+ r = row.iloc[0]
1583
+ duty_rate_str = r["duty_rate"]
1584
+ notes = r["notes"]
1585
+ fcc, ul_, fda = r["fcc_needed"], r["ul_needed"], r["fda_needed"]
1586
+
1587
+ breakdown = compute_landed_cost(
1588
+ customs_value_usd=customs_value,
1589
+ duty_rate_str=duty_rate_str,
1590
+ apply_section_301=origin_china,
1591
+ shipping_usd=shipping,
1592
+ insurance_usd=insurance,
1593
+ destination_state=destination,
1594
+ tax_rates=tax_rates,
1595
+ use_sea_freight=use_sea,
1596
+ )
1597
+
1598
+ st.markdown('<div class="cc-divider"></div>', unsafe_allow_html=True)
1599
+
1600
+ # --- Big total card ---
1601
+ per_unit = breakdown["total_landed"] / units if units > 0 else 0
1602
+ st.markdown(
1603
+ f"""
1604
+ <div class="cc-total-card">
1605
+ <div class="label">Estimated total landed cost</div>
1606
+ <div class="amount">${breakdown['total_landed']:,.2f}</div>
1607
+ <div class="markup">{breakdown['markup_pct']:+.1f}% over FOB Β· ~${per_unit:,.2f} per unit ({units:,} units)</div>
1608
+ </div>
1609
+ """,
1610
+ unsafe_allow_html=True,
1611
+ )
1612
+
1613
+ # --- Breakdown table ---
1614
+ st.markdown("#### Breakdown")
1615
+ rows = [
1616
+ ("Customs value (FOB)", breakdown["customs_value"], ""),
1617
+ ("Shipping", breakdown["shipping"], ""),
1618
+ ("Insurance", breakdown["insurance"], ""),
1619
+ ("β†’ CIF subtotal", breakdown["cif"], ""),
1620
+ (f"Base customs duty ({breakdown['base_duty_pct']*100:.2f}%)", breakdown["base_duty"], f"HTS {category}"),
1621
+ ]
1622
+ if breakdown["section_301_applied"]:
1623
+ rows.append(("Section 301 surcharge (+25%)", breakdown["section_301_duty"], "China-origin electronics"))
1624
+ rows.extend([
1625
+ ("Merchandise Processing Fee (MPF)", breakdown["mpf"], f"0.3464%, min $32.71 max $634.62"),
1626
+ ])
1627
+ if use_sea:
1628
+ rows.append(("Harbor Maintenance Fee (HMF)", breakdown["hmf"], "0.125% (sea freight only)"))
1629
+ rows.extend([
1630
+ ("β†’ Customs total", breakdown["customs_total"], ""),
1631
+ (f"State sales tax β€” {destination} ({breakdown['sales_tax_pct']*100:.2f}%)", breakdown["sales_tax"], ""),
1632
+ ("β†’ TOTAL LANDED COST", breakdown["total_landed"], ""),
1633
+ ])
1634
+
1635
+ df_show = pd.DataFrame(
1636
+ [{"Line item": r[0], "Amount (USD)": f"${r[1]:,.2f}", "Notes": r[2]} for r in rows]
1637
+ )
1638
+ st.dataframe(df_show, use_container_width=True, hide_index=True)
1639
+
1640
+ # --- Compliance flags ---
1641
+ compliance_flags = []
1642
+ if str(fcc).strip().lower() == "yes":
1643
+ compliance_flags.append("πŸ“‘ FCC certification required")
1644
+ if str(ul_).strip().lower() == "yes":
1645
+ compliance_flags.append("⚑ UL listing required")
1646
+ if str(fda).strip().lower() == "yes":
1647
+ compliance_flags.append("🩺 FDA clearance required")
1648
+ if origin_china:
1649
+ compliance_flags.append("⚠️ UFLPA documentation required (supply chain affidavits)")
1650
+
1651
+ if compliance_flags:
1652
+ st.markdown("#### Compliance flags")
1653
+ for f in compliance_flags:
1654
+ st.markdown(f"- {f}")
1655
+
1656
+ if notes:
1657
+ st.caption(f"πŸ“ HTS notes: {notes}")
1658
+
1659
+ st.caption(
1660
+ "πŸ’‘ Estimates are educational. Final duties depend on the exact 10-digit HTS code, "
1661
+ "current Federal Register tariff actions, and CBP classification rulings."
1662
+ )
1663
+
1664
+
1665
+ # =============================================================================
1666
+ # Section J β€” Knowledge Base browser tab
1667
+ # =============================================================================
1668
+
1669
+ def render_knowledge_tab(alerts_df: pd.DataFrame, chunk_index: dict) -> None:
1670
+ st.markdown("### πŸ“š Knowledge Base Browser")
1671
+ st.caption(
1672
+ "Browse the full curated alerts list and the underlying CBP RAG corpus that powers the assistant."
1673
+ )
1674
+
1675
+ sub1, sub2 = st.tabs(["⚠️ CBP Alerts", "πŸ“„ CBP Pages (RAG corpus)"])
1676
+
1677
+ with sub1:
1678
+ if alerts_df.empty:
1679
+ st.info("No alerts loaded.")
1680
+ else:
1681
+ severity_filter = st.multiselect(
1682
+ "Filter by severity",
1683
+ options=["Critical", "High", "Medium", "Info"],
1684
+ default=["Critical", "High", "Medium"],
1685
+ )
1686
+ filtered = alerts_df[alerts_df["severity"].isin(severity_filter)]
1687
+ st.caption(f"Showing {len(filtered)} / {len(alerts_df)} alerts.")
1688
+ for _, row in filtered.iterrows():
1689
+ sev = row["severity"].lower()
1690
+ st.markdown(
1691
+ f"""
1692
+ <div class="cc-alert cc-alert-{sev}">
1693
+ <div class="cc-alert-head">
1694
+ <span class="cc-badge cc-badge-{sev}">{row['severity']}</span>
1695
+ <span>{row['category']} β€” {row['title']}</span>
1696
+ </div>
1697
+ <div class="cc-alert-body">{row['summary']}</div>
1698
+ <div class="cc-alert-action">βœ… <b>Action:</b> {row['action_required']}</div>
1699
+ <div style="margin-top:6px"><a href="{row['source_url']}" target="_blank">πŸ”— Source on cbp.gov</a></div>
1700
+ </div>
1701
+ """,
1702
+ unsafe_allow_html=True,
1703
+ )
1704
+
1705
+ with sub2:
1706
+ chunks = chunk_index.get("chunks") or []
1707
+ st.caption(f"{len(chunks)} substantive CBP chunks indexed ({chunk_index.get('skipped_noise', 0)} noise chunks filtered out).")
1708
+ # Group by parent page
1709
+ by_parent: dict[str, list[dict]] = {}
1710
+ for c in chunks:
1711
+ pid = c.get("parent_id", c.get("chunk_id"))
1712
+ by_parent.setdefault(pid, []).append(c)
1713
+ # Sort by title for browsability
1714
+ sorted_parents = sorted(by_parent.items(), key=lambda kv: (kv[1][0].get("title") or "").lower())
1715
+ query = st.text_input("Filter pages by title", placeholder="e.g. UFLPA, Section 301, IPR")
1716
+ for pid, parent_chunks in sorted_parents:
1717
+ title = parent_chunks[0].get("title", pid)
1718
+ url = parent_chunks[0].get("url", "")
1719
+ if query and query.lower() not in title.lower():
1720
+ continue
1721
+ with st.expander(f"πŸ“„ {title} ({len(parent_chunks)} chunks)"):
1722
+ st.markdown(f"πŸ”— [{url}]({url})")
1723
+ for c in parent_chunks[:3]:
1724
+ import html as _html
1725
+ safe = _html.escape((c.get("text") or "")[:1200])
1726
+ st.markdown(f'<div class="cc-chunk">{safe}…</div>', unsafe_allow_html=True)
1727
+ if len(parent_chunks) > 3:
1728
+ st.caption(f"… and {len(parent_chunks)-3} more chunks (collapsed)")
1729
+
1730
+
1731
+ # =============================================================================
1732
+ # Section K β€” Main Entry Point
1733
  # =============================================================================
1734
 
1735
  def main() -> None:
1736
+ # Inject premium CSS first so everything renders polished
1737
+ inject_custom_css()
1738
+
1739
  # Load data
1740
  try:
1741
  nexus_df = load_nexus_thresholds()
 
1786
 
1787
  if sidebar_state["example_clicked"]:
1788
  st.session_state["prefilled"] = sidebar_state["example_clicked"]
1789
+ # Switch to Analyze tab when clicking an example
1790
+ st.session_state["active_tab"] = "analyze"
1791
  st.rerun()
1792
 
1793
+ # Hero header
1794
+ render_hero(
1795
+ nexus_count=len(nexus_df),
1796
+ alerts_count=len(alerts_df),
1797
+ chunks_count=chunk_index.get("N", 0),
1798
+ ollama_ok=ollama_ok,
1799
+ )
1800
+
1801
+ tab_analyze, tab_cost, tab_kb = st.tabs([
1802
+ "πŸ” Analyze",
1803
+ "πŸ’° Landed Cost Calculator",
1804
+ "πŸ“š Knowledge Base",
1805
+ ])
1806
+
1807
+ # ---- Tab 1: Analyze (Q&A flow) ----
1808
+ with tab_analyze:
1809
+ form = render_main_form(prefilled_question=st.session_state.get("prefilled", ""))
1810
+
1811
+ if form["submitted"]:
1812
+ combined = (form["product_desc"] + "\n" + form["question"]).strip()
1813
+ if not combined:
1814
+ st.warning("Please enter a product description or a question.")
1815
+ else:
1816
+ with st.spinner("Analyzing… (consulting knowledge base and LLM)"):
1817
+ answer, mode, payload = get_answer(
1818
+ question=combined,
1819
+ nexus_df=nexus_df,
1820
+ hts_df=hts_df,
1821
+ tax_rates=tax_rates,
1822
+ news_items=news_items,
1823
+ alerts_df=alerts_df,
1824
+ chunk_index=chunk_index,
1825
+ force_fallback=sidebar_state["force_fallback"],
1826
+ )
1827
+
1828
+ render_response(answer, mode, payload, news_items)
1829
+
1830
+ # ---- Tab 2: Cost Calculator ----
1831
+ with tab_cost:
1832
+ render_cost_calculator_tab(hts_df, tax_rates, alerts_df)
1833
 
1834
+ # ---- Tab 3: Knowledge Base ----
1835
+ with tab_kb:
1836
+ render_knowledge_tab(alerts_df, chunk_index)
1837
 
1838
 
1839
  if __name__ == "__main__":
requirements.txt CHANGED
@@ -2,3 +2,4 @@ streamlit>=1.30
2
  pandas>=2.0
3
  requests>=2.31
4
  beautifulsoup4
 
 
2
  pandas>=2.0
3
  requests>=2.31
4
  beautifulsoup4
5
+ python-dotenv>=1.0