Spaces:
Build error
Build error
fourth commit
Browse files- .env.example +14 -0
- .gitignore +1 -0
- app.py +737 -54
- 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 |
-
|
| 1035 |
-
|
| 1036 |
-
|
| 1037 |
-
|
| 1038 |
-
|
| 1039 |
-
|
| 1040 |
-
|
| 1041 |
-
|
| 1042 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1043 |
|
| 1044 |
if payload.get("chunks"):
|
| 1045 |
-
|
| 1046 |
-
|
| 1047 |
-
|
| 1048 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 β
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 1134 |
-
|
| 1135 |
-
|
| 1136 |
-
|
| 1137 |
-
|
| 1138 |
-
|
| 1139 |
-
|
| 1140 |
-
|
| 1141 |
-
|
| 1142 |
-
|
| 1143 |
-
|
| 1144 |
-
|
| 1145 |
-
|
| 1146 |
-
|
| 1147 |
-
|
| 1148 |
-
|
| 1149 |
-
|
| 1150 |
-
|
| 1151 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1152 |
|
| 1153 |
-
|
|
|
|
|
|
|
| 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
|