CoolFace
Apppublic

Soulay/customs-compass

sourceHugging Facemitupdated 4mo agoView on Hugging Face
0likes
app.py3132 linesDownload Raw Back to root
1"""Customs Compass - AI assistant for US sales tax, customs duties, and product compliance.2 3Single-file Streamlit application for Chinese SMEs (hardware, batteries, robotics,4electronics) exporting to the United States.5"""6 7from __future__ import annotations8 9import json10import os11import re12from html.parser import HTMLParser13from pathlib import Path14from typing import Optional15 16import pandas as pd17import requests18import streamlit as st19 20try:21    from dotenv import load_dotenv22    load_dotenv(Path(__file__).parent / ".env")23except ImportError:24    pass25 26 27# =============================================================================28# Section A — Configuration29# =============================================================================30 31st.set_page_config(32    page_title="Customs Compass — AI Trade Compliance",33    page_icon="🧭",34    layout="wide",35    initial_sidebar_state="expanded",36)37 38OLLAMA_URL = os.getenv("OLLAMA_URL", "http://localhost:11434")39OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "llama3.2:3b")40OLLAMA_TIMEOUT = int(os.getenv("OLLAMA_TIMEOUT", "180"))  # 3 min — cold model load can be slow on CPU41OLLAMA_PROBE_TIMEOUT = 242 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.45ORBITAI_API_KEY = os.getenv("ORBITAI_API_KEY", "")46ORBITAI_BASE_URL = os.getenv("ORBITAI_BASE_URL", "https://api.orbitai.global/v1")47ORBITAI_MODEL = os.getenv("ORBITAI_MODEL", "gpt-5.4")48ORBITAI_TIMEOUT = 6049 50CBP_NEWSROOM_URL = "https://www.cbp.gov/newsroom"51CBP_TRADE_URL = "https://www.cbp.gov/trade"52NEWS_FETCH_TIMEOUT = 553NEWS_CACHE_TTL = 3600  # 1 hour54 55USER_AGENT = "CustomsCompass/1.0 (Educational)"56 57DATA_DIR = Path(__file__).parent58 59# US customs fees (FY2026 rates)60MPF_RATE = 0.003464   # Merchandise Processing Fee: 0.3464% ad valorem61MPF_MIN = 32.71       # USD62MPF_MAX = 634.62      # USD63HMF_RATE = 0.00125    # Harbor Maintenance Fee: 0.125% (sea freight only)64 65# Section 301 List-4A surcharge on most Chinese electronics66SECTION_301_RATE = 0.25  # 25% additional ad valorem67 68SYSTEM_PROMPT = (69    "You are Customs Compass, an AI assistant specialized in US sales tax, "70    "nexus thresholds, customs duties, and product compliance. Rules: "71    "1) Use only the provided CSV data. "72    "2) If answer not in data, say 'I don't have enough information.' "73    "3) Ask clarifying questions if product category is unclear. "74    "4) Always cite source (e.g., 'nexus_thresholds.csv'). "75    "5) Answer in [English] then [中文]. "76    "6) Provide checklist + risk flags + sources."77)78 79STATE_ABBREVIATIONS = {80    "AL": "Alabama", "AK": "Alaska", "AZ": "Arizona", "AR": "Arkansas",81    "CA": "California", "CO": "Colorado", "CT": "Connecticut", "DE": "Delaware",82    "FL": "Florida", "GA": "Georgia", "HI": "Hawaii", "ID": "Idaho",83    "IL": "Illinois", "IN": "Indiana", "IA": "Iowa", "KS": "Kansas",84    "KY": "Kentucky", "LA": "Louisiana", "ME": "Maine", "MD": "Maryland",85    "MA": "Massachusetts", "MI": "Michigan", "MN": "Minnesota", "MS": "Mississippi",86    "MO": "Missouri", "MT": "Montana", "NE": "Nebraska", "NV": "Nevada",87    "NH": "New Hampshire", "NJ": "New Jersey", "NM": "New Mexico", "NY": "New York",88    "NC": "North Carolina", "ND": "North Dakota", "OH": "Ohio", "OK": "Oklahoma",89    "OR": "Oregon", "PA": "Pennsylvania", "RI": "Rhode Island", "SC": "South Carolina",90    "SD": "South Dakota", "TN": "Tennessee", "TX": "Texas", "UT": "Utah",91    "VT": "Vermont", "VA": "Virginia", "WA": "Washington", "WV": "West Virginia",92    "WI": "Wisconsin", "WY": "Wyoming", "DC": "District of Columbia",93}94 95# Keywords mapped to HTS categories. The right side must match product_category in hts_duty_codes.csv.96PRODUCT_KEYWORDS = {97    "battery_with_charger": ["power bank", "power banks", "charger", "rechargeable battery pack"],98    "battery_only": ["battery", "batteries", "lithium", "li-ion", "lipo", "lifepo4", "cell"],99    "robotics_with_radio": ["robot", "robotics", "robotic arm", "agv", "amr"],100    "consumer_electronics": ["consumer electronics", "gadget", "electronics"],101    "medical_device": ["medical", "thermometer", "blood pressure", "pulse oximeter", "diagnostic"],102    "industrial_machinery": ["industrial machine", "cnc", "lathe", "press", "factory equipment"],103    "power_tools": ["power tool", "drill", "saw", "grinder", "impact driver"],104    "led_lighting": ["led", "lighting", "lamp", "bulb", "light fixture"],105    "drones": ["drone", "drones", "uav", "quadcopter"],106    "solar_panels": ["solar panel", "solar panels", "pv module", "photovoltaic"],107    "smart_home_devices": ["smart home", "smart plug", "smart bulb", "smart switch", "iot device"],108    "ev_charger": ["ev charger", "ev charging", "electric vehicle charger", "level 2 charger"],109    "wearables": ["smartwatch", "smart watch", "fitness tracker", "wearable"],110    "audio_equipment": ["speaker", "speakers", "headphone", "headphones", "earbuds", "audio equipment"],111}112 113NO_TAX_STATES = {"Oregon", "Delaware", "Montana", "New Hampshire", "Alaska"}114 115 116# =============================================================================117# Section B — Data Loading (cached)118# =============================================================================119 120NEXUS_COLS = ["state", "threshold_usd", "transaction_rule", "notes"]121HTS_COLS = ["product_category", "hts_code", "duty_rate", "fcc_needed", "ul_needed", "fda_needed", "notes"]122CBP_ALERTS_COLS = ["category", "title", "summary", "relevant_products", "country_focus", "severity", "action_required", "source_url"]123 124# Common English stopwords — filtered from RAG queries to avoid noise.125_STOPWORDS = frozenset({126    "a", "an", "and", "are", "as", "at", "be", "by", "for", "from", "has", "have",127    "he", "in", "is", "it", "its", "of", "on", "or", "that", "the", "to", "was",128    "were", "will", "with", "this", "these", "those", "we", "us", "our", "you",129    "your", "they", "them", "their", "i", "me", "my", "do", "does", "did", "if",130    "but", "not", "no", "any", "some", "all", "can", "could", "should", "would",131    "may", "might", "must", "shall", "what", "when", "where", "who", "how", "why",132    "which", "about", "into", "than", "then", "there", "here", "also", "more",133    "less", "very", "much", "many", "such", "so", "up", "down", "out", "over",134    "under", "again", "further", "once", "been", "being", "had", "having", "am",135    "now", "just", "only", "own", "same", "too", "ll", "re", "ve", "d", "s", "t",136})137 138 139@st.cache_data(show_spinner=False)140def load_nexus_thresholds() -> pd.DataFrame:141    path = DATA_DIR / "nexus_thresholds.csv"142    if not path.exists():143        return pd.DataFrame(columns=NEXUS_COLS)144    df = pd.read_csv(path).fillna("")145    missing = [c for c in NEXUS_COLS if c not in df.columns]146    if missing:147        raise ValueError(f"nexus_thresholds.csv missing columns: {missing}")148    df["threshold_usd"] = pd.to_numeric(df["threshold_usd"], errors="coerce").fillna(0)149    return df150 151 152@st.cache_data(show_spinner=False)153def load_hts_duty_codes() -> pd.DataFrame:154    path = DATA_DIR / "hts_duty_codes.csv"155    if not path.exists():156        return pd.DataFrame(columns=HTS_COLS)157    df = pd.read_csv(path).fillna("")158    missing = [c for c in HTS_COLS if c not in df.columns]159    if missing:160        raise ValueError(f"hts_duty_codes.csv missing columns: {missing}")161    return df162 163 164@st.cache_data(show_spinner=False)165def load_tax_rates() -> dict:166    path = DATA_DIR / "tax_rates_by_state.json"167    if not path.exists():168        return {}169    return json.loads(path.read_text(encoding="utf-8"))170 171 172@st.cache_data(show_spinner=False)173def load_cbp_alerts() -> pd.DataFrame:174    """Static CBP enforcement & tariff alerts (Section 301, UFLPA, AD/CVD, etc.)."""175    path = DATA_DIR / "cbp_alerts.csv"176    if not path.exists():177        return pd.DataFrame(columns=CBP_ALERTS_COLS)178    df = pd.read_csv(path).fillna("")179    missing = [c for c in CBP_ALERTS_COLS if c not in df.columns]180    if missing:181        raise ValueError(f"cbp_alerts.csv missing columns: {missing}")182    return df183 184 185# ---------- CBP RAG (chunks from cbp_chunks.jsonl) -----------------------186 187import math188from collections import Counter189 190_TOKEN_RE = re.compile(r"[a-z0-9]+")191 192# Page-title patterns for low-value index/listing pages we want to exclude193# from the RAG corpus. These pages are just lists of references (FR numbers,194# bulletin titles, etc.) and don't contain substantive guidance text.195_NOISE_TITLE_PATTERNS = (196    "Federal Register Notices",197    "Customs Bulletin and Decisions",198    "Notices of Action",199    "Quota Bulletins",200    "CBP Trade-Related",201)202 203 204def _is_noise_chunk(rec: dict) -> bool:205    """Heuristic: skip chunks from low-information index/listing pages.206 207    A chunk is considered noise if:208      - its page title matches one of the index-page patterns, OR209      - the text is dominated by Federal Register references (e.g. "85 FR 15714")210        — more than 4 such references signals a list page.211    """212    title = rec.get("title", "") or ""213    if any(pat in title for pat in _NOISE_TITLE_PATTERNS):214        return True215    text = rec.get("text", "") or ""216    fr_refs = re.findall(r"\b\d{2,3}\s+FR\s+\d{4,6}\b", text)217    if len(fr_refs) >= 4:218        return True219    return False220 221 222def _tokenize(text: str) -> list[str]:223    """Lowercase, extract alphanumeric tokens, drop stopwords & short tokens."""224    return [t for t in _TOKEN_RE.findall(text.lower()) if len(t) > 2 and t not in _STOPWORDS]225 226 227@st.cache_resource(show_spinner=False)228def load_cbp_chunks_index() -> dict:229    """Load cbp_chunks.jsonl + precompute TF (per chunk) and IDF (global).230 231    Returned dict has:232      - chunks: list[dict]       (raw chunk records)233      - tfs: list[Counter]       (per-chunk term frequencies)234      - idf: dict[str, float]    (term -> inverse doc frequency)235      - N: int                   (total chunks)236      - avg_len: float           (avg token count per chunk, for BM25 length norm)237      - lens: list[int]          (per-chunk token count)238    """239    path = DATA_DIR / "cbp_chunks.jsonl"240    if not path.exists():241        return {"chunks": [], "tfs": [], "idf": {}, "N": 0, "avg_len": 0.0, "lens": []}242 243    chunks: list[dict] = []244    tfs: list[Counter] = []245    title_tokens_list: list[set[str]] = []246    lens: list[int] = []247    doc_freq: Counter = Counter()248 249    skipped_noise = 0250    with path.open(encoding="utf-8") as fh:251        for line in fh:252            line = line.strip()253            if not line:254                continue255            try:256                rec = json.loads(line)257            except json.JSONDecodeError:258                continue259            if _is_noise_chunk(rec):260                skipped_noise += 1261                continue262            text = rec.get("text", "")263            tokens = _tokenize(text)264            title_tokens = set(_tokenize(rec.get("title", "")))265            tf = Counter(tokens)266            chunks.append(rec)267            tfs.append(tf)268            title_tokens_list.append(title_tokens)269            lens.append(len(tokens))270            for term in tf.keys():271                doc_freq[term] += 1272 273    N = len(chunks)274    avg_len = (sum(lens) / N) if N else 0.0275    idf = {276        term: math.log(1 + (N - df + 0.5) / (df + 0.5))277        for term, df in doc_freq.items()278    }279    return {280        "chunks": chunks,281        "tfs": tfs,282        "title_tokens": title_tokens_list,283        "idf": idf,284        "N": N,285        "avg_len": avg_len,286        "lens": lens,287        "skipped_noise": skipped_noise,288    }289 290 291def search_cbp_chunks(292    query: str,293    index: dict,294    products: list[str],295    states: list[str],296    top_k: int = 3,297    k1: float = 1.5,298    b: float = 0.75,299) -> list[dict]:300    """BM25 retrieval over CBP chunks, deduplicated to one chunk per parent page."""301    chunks = index["chunks"]302    if not chunks:303        return []304 305    # Build query terms: tokenize question + add product category words + state names306    q_tokens = _tokenize(query)307    for p in products:308        q_tokens.extend(_tokenize(p.replace("_", " ")))309    for s in states:310        q_tokens.extend(_tokenize(s))311    if not q_tokens:312        return []313 314    q_set = set(q_tokens)315    tfs = index["tfs"]316    idf = index["idf"]317    lens = index["lens"]318    avg_len = index["avg_len"] or 1.0319 320    title_tokens_list = index.get("title_tokens", [])321    title_boost = 2.5  # multiply IDF when query term appears in the page title322 323    scored: list[tuple[float, int]] = []324    for i, tf in enumerate(tfs):325        score = 0.0326        dl = lens[i]327        norm = 1 - b + b * (dl / avg_len)328        title_set = title_tokens_list[i] if i < len(title_tokens_list) else set()329        for term in q_set:330            f = tf.get(term, 0)331            if not f:332                continue333            term_idf = idf.get(term, 0.0)334            if term in title_set:335                term_idf *= title_boost336            score += term_idf * (f * (k1 + 1)) / (f + k1 * norm)337        if score > 0:338            scored.append((score, i))339 340    scored.sort(key=lambda t: -t[0])341 342    seen_parents: set[str] = set()343    results: list[dict] = []344    for score, i in scored:345        chunk = chunks[i]346        parent = chunk.get("parent_id") or chunk.get("chunk_id")347        if parent in seen_parents:348            continue349        seen_parents.add(parent)350        # Keep the full chunk text for inline display, plus a shorter excerpt351        # for prompt injection (LLM context budget).352        full_text = (chunk.get("text") or "").strip().replace("\n", " ")353        # Collapse repeated whitespace354        full_text = re.sub(r"\s+", " ", full_text)355        if len(full_text) > 700:356            excerpt = full_text[:700].rsplit(" ", 1)[0] + "…"357        else:358            excerpt = full_text359        results.append({360            "title": chunk.get("title", ""),361            "url": chunk.get("url", ""),362            "section": chunk.get("section", ""),363            "page_type": chunk.get("page_type", ""),364            "published_date": chunk.get("published_date", ""),365            "excerpt": excerpt,       # short — used in prompt366            "full_text": full_text,   # full — shown in UI367            "score": round(score, 2),368            "chunk_id": chunk.get("chunk_id", ""),369        })370        if len(results) >= top_k:371            break372 373    return results374 375 376def find_relevant_alerts(377    alerts_df: pd.DataFrame,378    products: list[str],379    question: str,380) -> pd.DataFrame:381    """Return alerts matching detected products or 'all'-scoped Critical alerts."""382    if alerts_df.empty:383        return alerts_df384    question_lower = question.lower()385    mentions_china = "china" in question_lower or "chinese" in question_lower or "中国" in question386 387    def row_matches(row: pd.Series) -> bool:388        rel = str(row.get("relevant_products", "")).lower()389        # Always show Critical alerts that scope to "all"390        if row.get("severity") == "Critical" and "all" in rel:391            return True392        # Match by product overlap393        if products:394            rel_set = {p.strip() for p in rel.split(",") if p.strip()}395            if rel_set & set(products):396                return True397            if "all" in rel_set:398                return True399        # If user mentions China and alert is China-focused, include high-severity ones400        if mentions_china and str(row.get("country_focus", "")).lower() == "china":401            if row.get("severity") in ("Critical", "High"):402                return True403        return False404 405    mask = alerts_df.apply(row_matches, axis=1)406    matched = alerts_df[mask]407    # Sort: Critical → High → Medium → Info408    severity_order = {"Critical": 0, "High": 1, "Medium": 2, "Info": 3}409    matched = matched.assign(410        _sev_rank=matched["severity"].map(lambda s: severity_order.get(s, 99))411    ).sort_values("_sev_rank").drop(columns=["_sev_rank"])412    return matched.head(5)413 414 415# =============================================================================416# Section C — CBP News Fetcher417# =============================================================================418 419class _CBPNewsParser(HTMLParser):420    """Extracts anchor links and text from cbp.gov HTML.421 422    The CBP newsroom uses a Drupal site; news article links generally live under423    paths containing 'newsroom' or 'news-release'. We collect every <a> and filter424    afterwards rather than relying on a brittle CSS selector.425    """426 427    def __init__(self) -> None:428        super().__init__()429        self.links: list[dict] = []430        self._current_href: Optional[str] = None431        self._current_text: list[str] = []432 433    def handle_starttag(self, tag: str, attrs: list[tuple[str, Optional[str]]]) -> None:434        if tag == "a":435            for k, v in attrs:436                if k == "href" and v:437                    self._current_href = v438                    self._current_text = []439                    return440 441    def handle_endtag(self, tag: str) -> None:442        if tag == "a" and self._current_href is not None:443            text = " ".join(self._current_text).strip()444            if text and len(text) > 15:  # filter noise (e.g., "Home", "Login")445                self.links.append({"href": self._current_href, "text": text})446            self._current_href = None447            self._current_text = []448 449    def handle_data(self, data: str) -> None:450        if self._current_href is not None:451            self._current_text.append(data.strip())452 453 454@st.cache_data(ttl=NEWS_CACHE_TTL, show_spinner=False)455def fetch_cbp_news() -> list[dict]:456    """Fetch the latest CBP newsroom headlines. Returns [] on any failure."""457    try:458        resp = requests.get(459            CBP_NEWSROOM_URL,460            headers={"User-Agent": USER_AGENT},461            timeout=NEWS_FETCH_TIMEOUT,462        )463        if resp.status_code != 200:464            return []465        parser = _CBPNewsParser()466        parser.feed(resp.text)467    except (requests.RequestException, ValueError):468        return []469 470    items: list[dict] = []471    seen_titles: set[str] = set()472    for link in parser.links:473        href = link["href"]474        text = link["text"]475        # Filter to CBP article-like URLs476        if not any(token in href for token in ("/newsroom/", "/news-release", "/spotlights/")):477            continue478        if text in seen_titles:479            continue480        seen_titles.add(text)481        # Normalize URL482        if href.startswith("/"):483            href = "https://www.cbp.gov" + href484        items.append({"title": text, "url": href})485        if len(items) >= 20:486            break487    return items488 489 490def filter_news_by_query(491    news_items: list[dict],492    question: str,493    products: list[str],494    states: list[str],495) -> list[dict]:496    """Return news items relevant to the user query (max 5)."""497    if not news_items:498        return []499    haystack_terms = set()500    haystack_terms.update(s.lower() for s in states)501    for p in products:502        haystack_terms.update(p.replace("_", " ").lower().split())503    for word in re.findall(r"[a-zA-Z]{4,}", question.lower()):504        haystack_terms.add(word)505 506    scored: list[tuple[int, dict]] = []507    for item in news_items:508        title_lower = item["title"].lower()509        score = sum(1 for term in haystack_terms if term in title_lower)510        if score > 0:511            scored.append((score, item))512    scored.sort(key=lambda t: -t[0])513    return [item for _, item in scored[:5]]514 515 516# =============================================================================517# Section D — Retrieval Helpers518# =============================================================================519 520def extract_states(text: str) -> list[str]:521    found: set[str] = set()522    text_lower = text.lower()523    for full in STATE_ABBREVIATIONS.values():524        if full.lower() in text_lower:525            found.add(full)526    for abbr, full in STATE_ABBREVIATIONS.items():527        if re.search(rf"\b{abbr}\b", text):528            found.add(full)529    return sorted(found)530 531 532def extract_product_categories(text: str, hts_df: pd.DataFrame) -> list[str]:533    text_lower = text.lower()534    found: list[str] = []535    available = set(hts_df["product_category"].tolist()) if not hts_df.empty else set()536    for category, keywords in PRODUCT_KEYWORDS.items():537        if category not in available:538            continue539        if any(kw in text_lower for kw in keywords):540            if category not in found:541                found.append(category)542    return found543 544 545def extract_sales_amount(text: str) -> Optional[float]:546    """Parse '$200k', '200,000', '$1.5M', '500000' from text. Returns USD amount."""547    cleaned = text.replace(",", "")548    pattern = r"\$?\s*(\d+(?:\.\d+)?)\s*([kKmM])?"549    best: Optional[float] = None550    for match in re.finditer(pattern, cleaned):551        num_str, suffix = match.group(1), match.group(2)552        try:553            val = float(num_str)554        except ValueError:555            continue556        if suffix in ("k", "K"):557            val *= 1_000558        elif suffix in ("m", "M"):559            val *= 1_000_000560        # Heuristic: only accept values that look like sales figures561        if val < 1_000:562            continue563        if best is None or val > best:564            best = val565    return best566 567 568def build_context(569    question: str,570    nexus_df: pd.DataFrame,571    hts_df: pd.DataFrame,572    tax_rates: dict,573    news_items: list[dict],574    alerts_df: Optional[pd.DataFrame] = None,575    chunk_index: Optional[dict] = None,576) -> tuple[str, dict]:577    """Assemble the retrieval context block plus a structured payload."""578    states = extract_states(question)579    products = extract_product_categories(question, hts_df)580    sales = extract_sales_amount(question)581 582    relevant_news = filter_news_by_query(news_items, question, products, states)583    relevant_alerts = (584        find_relevant_alerts(alerts_df, products, question)585        if alerts_df is not None and not alerts_df.empty586        else pd.DataFrame(columns=CBP_ALERTS_COLS)587    )588    relevant_chunks = (589        search_cbp_chunks(question, chunk_index, products, states, top_k=3)590        if chunk_index and chunk_index.get("N")591        else []592    )593 594    lines: list[str] = ["## Retrieved Knowledge Base Data", ""]595 596    if states:597        lines.append("### Nexus thresholds (source: nexus_thresholds.csv)")598        nexus_subset = nexus_df[nexus_df["state"].isin(states)]599        for _, row in nexus_subset.iterrows():600            lines.append(601                f"- **{row['state']}**: threshold ${int(row['threshold_usd']):,} | "602                f"transactions: {row['transaction_rule']} | notes: {row['notes']}"603            )604        lines.append("")605        lines.append("### State sales tax rates (source: tax_rates_by_state.json)")606        for s in states:607            rate = tax_rates.get(s)608            if rate is not None:609                lines.append(f"- **{s}**: {rate}%")610        lines.append("")611 612    if products:613        lines.append("### HTS / compliance (source: hts_duty_codes.csv)")614        hts_subset = hts_df[hts_df["product_category"].isin(products)]615        for _, row in hts_subset.iterrows():616            lines.append(617                f"- **{row['product_category']}**: HTS {row['hts_code']} | "618                f"duty {row['duty_rate']} | FCC: {row['fcc_needed']} | "619                f"UL: {row['ul_needed']} | FDA: {row['fda_needed']} | {row['notes']}"620            )621        lines.append("")622 623    if sales is not None:624        lines.append(f"### Detected sales amount: ${sales:,.0f} USD")625        lines.append("")626 627    if not relevant_alerts.empty:628        lines.append("### CBP enforcement & tariff alerts (source: cbp_alerts.csv)")629        for _, row in relevant_alerts.iterrows():630            lines.append(631                f"- **[{row['severity']}] {row['category']} — {row['title']}**: "632                f"{row['summary']} _Action:_ {row['action_required']} "633                f"({row['source_url']})"634            )635        lines.append("")636 637    if relevant_chunks:638        lines.append("### CBP knowledge base excerpts (source: cbp_chunks.jsonl)")639        for c in relevant_chunks:640            lines.append(f"- **{c['title']}** ({c['url']})")641            lines.append(f"  > {c['excerpt']}")642        lines.append("")643 644    if relevant_news:645        lines.append("### Recent CBP news (source: cbp.gov/newsroom)")646        for item in relevant_news:647            lines.append(f"- {item['title']} — {item['url']}")648        lines.append("")649 650    if not states and not products and not relevant_news and relevant_alerts.empty and not relevant_chunks:651        lines.append("_No matching state, product, alert, news, or CBP excerpt found._")652 653    payload = {654        "states": states,655        "products": products,656        "sales_usd": sales,657        "news": relevant_news,658        "alerts": relevant_alerts.to_dict(orient="records"),659        "chunks": relevant_chunks,660    }661    return "\n".join(lines), payload662 663 664# =============================================================================665# Section E — Risk Assessment666# =============================================================================667 668def assess_nexus_risk(sales_usd: Optional[float], threshold_usd: float) -> str:669    if sales_usd is None or threshold_usd <= 0:670        return "Unknown"671    ratio = sales_usd / threshold_usd672    if ratio >= 1.0:673        return "High"674    if ratio >= 0.7:675        return "Medium"676    return "Low"677 678 679def assess_compliance_risk(product_row: pd.Series) -> list[str]:680    flags: list[str] = []681    if str(product_row.get("fcc_needed", "")).strip().lower() == "yes":682        flags.append("FCC certification required")683    if str(product_row.get("ul_needed", "")).strip().lower() == "yes":684        flags.append("UL certification required")685    if str(product_row.get("fda_needed", "")).strip().lower() == "yes":686        flags.append("FDA clearance required")687    return flags688 689 690# =============================================================================691# Section F — LLM Integration692# =============================================================================693 694@st.cache_data(ttl=60, show_spinner=False)695def is_ollama_available() -> bool:696    try:697        resp = requests.get(f"{OLLAMA_URL}/api/tags", timeout=OLLAMA_PROBE_TIMEOUT)698        return resp.status_code == 200699    except requests.RequestException:700        return False701 702 703def call_ollama(system_prompt: str, user_prompt: str) -> str:704    payload = {705        "model": OLLAMA_MODEL,706        "messages": [707            {"role": "system", "content": system_prompt},708            {"role": "user", "content": user_prompt},709        ],710        "stream": False,711        "keep_alive": "10m",  # keep model loaded in RAM between calls712        "options": {713            "temperature": 0.2,714            "num_predict": 600,   # cap response length so it returns quickly715            "num_ctx": 4096,      # context window716        },717    }718    resp = requests.post(719        f"{OLLAMA_URL}/api/chat",720        json=payload,721        timeout=OLLAMA_TIMEOUT,722    )723    resp.raise_for_status()724    data = resp.json()725    return data.get("message", {}).get("content", "").strip()726 727 728# --- OrbitAI client (OpenAI-compatible) ---------------------------------------729# Used for premium agent features that need a stronger model than llama3.2:3b730# (market intelligence, localization, GTM roadmap generation, document analysis).731 732def is_orbitai_configured() -> bool:733    return bool(ORBITAI_API_KEY) and ORBITAI_API_KEY.startswith("sk-")734 735 736def call_orbitai(737    system_prompt: str,738    user_prompt: str,739    model: Optional[str] = None,740    temperature: float = 0.4,741) -> str:742    """Call OrbitAI's OpenAI-compatible chat completions endpoint.743 744    Raises requests.RequestException on network errors. Callers should catch745    and fall back gracefully (typically to Ollama or a deterministic template).746    """747    if not is_orbitai_configured():748        raise RuntimeError("ORBITAI_API_KEY is not set; cannot call OrbitAI.")749    payload = {750        "model": model or ORBITAI_MODEL,751        "messages": [752            {"role": "system", "content": system_prompt},753            {"role": "user", "content": user_prompt},754        ],755        "temperature": temperature,756        "stream": False,757    }758    headers = {759        "Authorization": f"Bearer {ORBITAI_API_KEY}",760        "Content-Type": "application/json",761        "User-Agent": USER_AGENT,762    }763    resp = requests.post(764        f"{ORBITAI_BASE_URL.rstrip('/')}/chat/completions",765        json=payload,766        headers=headers,767        timeout=ORBITAI_TIMEOUT,768    )769    resp.raise_for_status()770    data = resp.json()771    choices = data.get("choices") or []772    if not choices:773        return ""774    return (choices[0].get("message") or {}).get("content", "").strip()775 776 777# --- Fallback engine -----------------------------------------------------------778 779RISK_BADGE = {780    "Low": "🟢 Low",781    "Medium": "🟡 Medium",782    "High": "🔴 High",783    "Unknown": "⚪ Unknown",784}785 786RISK_BADGE_CN = {787    "Low": "🟢 低",788    "Medium": "🟡 中",789    "High": "🔴 高",790    "Unknown": "⚪ 未知",791}792 793 794def call_fallback(795    question: str,796    payload: dict,797    nexus_df: pd.DataFrame,798    hts_df: pd.DataFrame,799    tax_rates: dict,800) -> str:801    """Build a deterministic bilingual response using only CSV lookups (no LLM)."""802    states = payload["states"]803    products = payload["products"]804    sales = payload["sales_usd"]805 806    en_lines: list[str] = ["## [English]"]807    cn_lines: list[str] = ["## [中文]"]808    sources: set[str] = set()809    overall_risk = "Low"810 811    if not states and not products:812        en_lines.append("I don't have enough information. Please mention a US state and/or a product category (e.g., 'lithium batteries to Texas').")813        cn_lines.append("信息不足。请提供一个美国州名和/或产品类别(例如:销往德克萨斯州的锂电池)。")814        return "\n".join(en_lines + [""] + cn_lines)815 816    # ---- Nexus / sales tax ----817    if states:818        en_lines.append("### Sales tax & nexus checklist")819        cn_lines.append("### 销售税与经济关联检查清单")820        for state in states:821            row = nexus_df[nexus_df["state"] == state]822            if row.empty:823                en_lines.append(f"- **{state}**: I don't have enough information.")824                cn_lines.append(f"- **{state}**:信息不足。")825                continue826            sources.add("nexus_thresholds.csv")827            threshold = float(row.iloc[0]["threshold_usd"])828            tx_rule = row.iloc[0]["transaction_rule"]829            rate = tax_rates.get(state)830            risk = assess_nexus_risk(sales, threshold)831 832            if state in NO_TAX_STATES or threshold == 0:833                en_lines.append(834                    f"- **{state}**: No state sales tax obligation. Risk: {RISK_BADGE['Low']}."835                )836                cn_lines.append(837                    f"- **{state}**:无州销售税义务。风险:{RISK_BADGE_CN['Low']}。"838                )839            else:840                threshold_str = f"${int(threshold):,}"841                if sales is not None:842                    if risk == "High":843                        en_lines.append(844                            f"- **{state}**: Sales (${sales:,.0f}) exceed the {threshold_str} threshold — "845                            f"you MUST register and collect sales tax (rate: {rate}%). Risk: {RISK_BADGE['High']}."846                        )847                        cn_lines.append(848                            f"- **{state}**:销售额(${sales:,.0f})超过 {threshold_str} 门槛 — "849                            f"必须注册并征收销售税(税率:{rate}%)。风险:{RISK_BADGE_CN['High']}。"850                        )851                        overall_risk = "High"852                    elif risk == "Medium":853                        en_lines.append(854                            f"- **{state}**: Sales (${sales:,.0f}) approaching the {threshold_str} threshold. "855                            f"Monitor closely. Rate when triggered: {rate}%. Risk: {RISK_BADGE['Medium']}."856                        )857                        cn_lines.append(858                            f"- **{state}**:销售额(${sales:,.0f})接近 {threshold_str} 门槛。"859                            f"请密切监控。触发后税率:{rate}%。风险:{RISK_BADGE_CN['Medium']}。"860                        )861                        if overall_risk != "High":862                            overall_risk = "Medium"863                    else:864                        en_lines.append(865                            f"- **{state}**: Sales (${sales:,.0f}) below the {threshold_str} threshold — "866                            f"no collection required yet. Risk: {RISK_BADGE['Low']}."867                        )868                        cn_lines.append(869                            f"- **{state}**:销售额(${sales:,.0f})低于 {threshold_str} 门槛 — "870                            f"暂无需征收。风险:{RISK_BADGE_CN['Low']}。"871                        )872                else:873                    en_lines.append(874                        f"- **{state}**: Threshold {threshold_str}, transaction rule: {tx_rule or 'none'}, "875                        f"rate: {rate}%. Provide your annual sales to assess risk."876                    )877                    cn_lines.append(878                        f"- **{state}**:门槛 {threshold_str},交易规则:{tx_rule or '无'},"879                        f"税率:{rate}%。请提供年销售额以评估风险。"880                    )881                    if overall_risk == "Low":882                        overall_risk = "Unknown"883 884    # ---- Product compliance ----885    if products:886        en_lines.append("")887        cn_lines.append("")888        en_lines.append("### Customs duty & federal compliance checklist")889        cn_lines.append("### 关税与联邦合规检查清单")890        for category in products:891            row = hts_df[hts_df["product_category"] == category]892            if row.empty:893                continue894            sources.add("hts_duty_codes.csv")895            r = row.iloc[0]896            flags = assess_compliance_risk(r)897            flags_str = ", ".join(flags) if flags else "no special certification flagged"898            en_lines.append(899                f"- **{category}**: HTS code `{r['hts_code']}`, duty rate **{r['duty_rate']}**. "900                f"Required: {flags_str}. {r['notes']}"901            )902            cn_lines.append(903                f"- **{category}**:HTS 代码 `{r['hts_code']}`,关税税率 **{r['duty_rate']}**。"904                f"所需认证:{flags_str}。{r['notes']}"905            )906            if flags and overall_risk == "Low":907                overall_risk = "Medium"908 909    # ---- CBP Alerts (static enforcement / tariff data) ----910    if payload.get("alerts"):911        sources.add("cbp_alerts.csv")912        en_lines.append("")913        cn_lines.append("")914        en_lines.append("### ⚠️ CBP enforcement & tariff alerts")915        cn_lines.append("### ⚠️ CBP 执法与关税警报")916        sev_badge = {"Critical": "🔴 Critical", "High": "🟠 High", "Medium": "🟡 Medium", "Info": "🟢 Info"}917        sev_badge_cn = {"Critical": "🔴 紧急", "High": "🟠 高", "Medium": "🟡 中", "Info": "🟢 信息"}918        for alert in payload["alerts"]:919            sev = alert.get("severity", "Info")920            en_lines.append(921                f"- {sev_badge.get(sev, sev)} **{alert['category']} — {alert['title']}**: "922                f"{alert['summary']} _Action:_ {alert['action_required']} "923                f"[source]({alert['source_url']})"924            )925            cn_lines.append(926                f"- {sev_badge_cn.get(sev, sev)} **{alert['category']} — {alert['title']}**:"927                f"{alert['summary']} _建议行动:_ {alert['action_required']} "928                f"[来源]({alert['source_url']})"929            )930            if sev == "Critical":931                overall_risk = "High"932            elif sev == "High" and overall_risk != "High":933                overall_risk = "Medium" if overall_risk == "Low" else overall_risk934 935    # ---- CBP knowledge base excerpts (RAG from JSONL) ----936    if payload.get("chunks"):937        sources.add("cbp_chunks.jsonl")938        en_lines.append("")939        cn_lines.append("")940        en_lines.append("### 📚 CBP knowledge base excerpts")941        cn_lines.append("### 📚 CBP 知识库摘录")942        for c in payload["chunks"]:943            en_lines.append(f"- **[{c['title']}]({c['url']})** (relevance {c['score']})")944            en_lines.append(f"  > {c['excerpt']}")945            cn_lines.append(f"- **[{c['title']}]({c['url']})** (相关度 {c['score']})")946            cn_lines.append(f"  > {c['excerpt']}")947 948    # ---- News ----949    if payload.get("news"):950        sources.add("cbp.gov/newsroom")951        en_lines.append("")952        cn_lines.append("")953        en_lines.append("### Recent CBP news (informational)")954        cn_lines.append("### 近期 CBP 新闻(仅供参考)")955        for item in payload["news"]:956            en_lines.append(f"- [{item['title']}]({item['url']})")957            cn_lines.append(f"- [{item['title']}]({item['url']})")958 959    # ---- Risk summary + sources ----960    en_lines.append("")961    en_lines.append(f"**Overall risk:** {RISK_BADGE[overall_risk]}")962    en_lines.append(f"**Sources:** {', '.join(sorted(sources)) if sources else 'none'}")963 964    cn_lines.append("")965    cn_lines.append(f"**总体风险:** {RISK_BADGE_CN[overall_risk]}")966    cn_lines.append(f"**来源:** {', '.join(sorted(sources)) if sources else '无'}")967 968    return "\n".join(en_lines + [""] + cn_lines)969 970 971def get_answer(972    question: str,973    nexus_df: pd.DataFrame,974    hts_df: pd.DataFrame,975    tax_rates: dict,976    news_items: list[dict],977    alerts_df: Optional[pd.DataFrame] = None,978    chunk_index: Optional[dict] = None,979    force_fallback: bool = False,980) -> tuple[str, str, dict]:981    """Returns (answer_markdown, mode_used, payload)."""982    context, payload = build_context(983        question, nexus_df, hts_df, tax_rates, news_items, alerts_df, chunk_index984    )985 986    use_llm = (not force_fallback) and is_ollama_available()987 988    if use_llm:989        user_prompt = (990            f"User question: {question}\n\n"991            f"{context}\n\n"992            "Using ONLY the data above, produce a structured bilingual answer with:\n"993            "1. [English] section: a checklist, risk flag (Low/Medium/High), and sources.\n"994            "2. [中文] section: the same content translated to Chinese.\n"995            "If the data doesn't cover the question, say 'I don't have enough information.'"996        )997        try:998            answer = call_ollama(SYSTEM_PROMPT, user_prompt)999            if answer:1000                return answer, "🤖 Ollama (llama3.2:3b)", payload1001        except requests.RequestException:1002            pass  # fall through to template fallback1003 1004    answer = call_fallback(question, payload, nexus_df, hts_df, tax_rates)1005    return answer, "📋 Rule-based fallback", payload1006 1007 1008# =============================================================================1009# Section G — UI Rendering1010# =============================================================================1011 1012EXAMPLE_QUERIES = [1013    "We sell power banks to Texas, $200k annual sales. Do we need to collect sales tax?",1014    "Our startup ships lithium batteries to California and New York. What certifications do we need?",1015    "Medical thermometer exports to Florida with $150k revenue — what are our obligations?",1016]1017 1018 1019def render_sidebar(ollama_ok: bool, news_count: int, news_enabled: bool, alerts_count: int = 0, chunks_count: int = 0) -> dict:1020    with st.sidebar:1021        st.markdown("# 🧭 Customs Compass")1022        st.caption("AI compliance assistant for US exports")1023 1024        st.markdown("### System status")1025        if ollama_ok:1026            st.success(f"🟢 Ollama online ({OLLAMA_MODEL})")1027        else:1028            st.warning("🟡 Ollama offline — fallback mode")1029            st.caption("Start Ollama and run: `ollama pull llama3.2:3b`")1030 1031        if news_enabled:1032            if news_count > 0:1033                st.success(f"🟢 CBP news ({news_count} items)")1034            else:1035                st.warning("🟡 CBP news unavailable")1036        else:1037            st.info("📴 CBP news disabled")1038 1039        news_toggle = st.checkbox("Enable live CBP news", value=news_enabled)1040        refresh_news = st.button("🔄 Refresh news now", use_container_width=True)1041        force_fallback = st.checkbox("Force fallback mode (skip LLM)", value=False)1042 1043        st.markdown("---")1044        st.markdown("### Knowledge base")1045        st.markdown("- 📄 `nexus_thresholds.csv` (51 states)")1046        st.markdown("- 📄 `hts_duty_codes.csv` (14 categories)")1047        st.markdown("- 📄 `tax_rates_by_state.json`")1048        st.markdown(f"- ⚠️ `cbp_alerts.csv` ({alerts_count} curated alerts)")1049        st.markdown(f"- 📚 `cbp_chunks.jsonl` ({chunks_count} RAG chunks)")1050        st.markdown(f"- 🌐 [CBP Newsroom]({CBP_NEWSROOM_URL})")1051        st.markdown(f"- 🌐 [CBP Trade]({CBP_TRADE_URL})")1052 1053        st.markdown("---")1054        st.markdown("### Example queries")1055        example_clicked: Optional[str] = None1056        for i, ex in enumerate(EXAMPLE_QUERIES):1057            if st.button(f"💬 Example {i + 1}", key=f"ex_{i}", use_container_width=True):1058                example_clicked = ex1059            with st.expander(f"Preview {i + 1}"):1060                st.caption(ex)1061 1062        st.markdown("---")1063        st.caption("⚠️ Educational tool. Not legal or tax advice.")1064 1065        return {1066            "news_enabled": news_toggle,1067            "refresh_news": refresh_news,1068            "force_fallback": force_fallback,1069            "example_clicked": example_clicked,1070        }1071 1072 1073def render_main_form(prefilled_question: str = "") -> dict:1074    st.markdown("## Ask Customs Compass")1075    st.caption("Describe your product and/or ask a compliance question. The assistant answers in English and 中文.")1076 1077    col1, col2 = st.columns([1, 1])1078    with col1:1079        product_desc = st.text_area(1080            "Product description (optional)",1081            placeholder="e.g., Lithium-ion power bank, 20000 mAh, with USB-C charging",1082            height=120,1083            key="product_desc",1084        )1085    with col2:1086        question = st.text_area(1087            "Your question",1088            value=prefilled_question,1089            placeholder="e.g., We sell to Texas and California, $300k sales. Do we need to collect sales tax?",1090            height=120,1091            key="question",1092        )1093 1094    submitted = st.button("🔍 Analyze (分析)", type="primary", use_container_width=True)1095    return {"product_desc": product_desc, "question": question, "submitted": submitted}1096 1097 1098def render_response(answer: str, mode: str, payload: dict, news_items: list[dict]) -> None:1099    st.markdown("---")1100    st.markdown(f"#### Response — {mode}")1101    st.markdown(answer)1102 1103    with st.expander("📊 Retrieved data used"):1104        st.json({1105            "states_detected": payload["states"],1106            "products_detected": payload["products"],1107            "sales_usd_detected": payload["sales_usd"],1108        })1109 1110    if payload.get("alerts"):1111        st.markdown(f"#### ⚠️ CBP alerts triggered ({len(payload['alerts'])})")1112        import html as _html1113        for alert in payload["alerts"]:1114            sev = alert["severity"].lower()1115            st.markdown(1116                f"""1117<div class="cc-alert cc-alert-{sev}">1118  <div class="cc-alert-head">1119    <span class="cc-badge cc-badge-{sev}">{alert['severity']}</span>1120    <span>{alert['category']} — {alert['title']}</span>1121  </div>1122  <div class="cc-alert-body">{_html.escape(alert['summary'])}</div>1123  <div class="cc-alert-action">✅ <b>Action:</b> {_html.escape(alert['action_required'])}</div>1124  <div style="margin-top:6px"><a href="{alert['source_url']}" target="_blank">🔗 Source on cbp.gov</a></div>1125</div>1126                """,1127                unsafe_allow_html=True,1128            )1129 1130    if payload.get("chunks"):1131        st.markdown(f"#### 📚 CBP knowledge base excerpts ({len(payload['chunks'])})")1132        st.caption(1133            "Full text from the most relevant CBP pages — no need to click out. The link goes to the source page."1134        )1135        import html as _html1136        for c in payload["chunks"]:1137            published = c.get("published_date", "")1138            meta = f"{c['section']} · {c['page_type']} · relevance {c['score']}"1139            if published:1140                meta += f" · {published}"1141            full = c.get("full_text") or c.get("excerpt", "")1142            safe = _html.escape(full)1143            st.markdown(1144                f"""1145<div class="cc-card">1146  <div class="cc-card-title">{_html.escape(c['title'])}</div>1147  <div class="cc-card-meta">{meta}</div>1148  <div class="cc-chunk">{safe}</div>1149  <div style="margin-top:10px"><a href="{c['url']}" target="_blank">🔗 View original page on cbp.gov</a></div>1150</div>1151                """,1152                unsafe_allow_html=True,1153            )1154 1155    if payload.get("news"):1156        with st.expander(f"📰 Relevant CBP news ({len(payload['news'])})"):1157            for item in payload["news"]:1158                st.markdown(f"- [{item['title']}]({item['url']})")1159 1160    st.caption("⚠️ This is an educational tool. Always consult a licensed tax or trade professional before making compliance decisions.")1161 1162 1163# =============================================================================1164# Section H — Premium Visual Polish (custom CSS + hero)1165# =============================================================================1166 1167_CSS = """1168<style>1169/* ----------- Fonts & base ----------- */1170@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap');1171 1172html, body, [class*="css"], .stApp {1173    font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;1174}1175 1176code, pre, .stCode {1177    font-family: 'JetBrains Mono', monospace !important;1178}1179 1180/* ----------- Hide default Streamlit chrome ----------- */1181#MainMenu {visibility: hidden;}1182footer {visibility: hidden;}1183header[data-testid="stHeader"] {background: transparent;}1184 1185/* ----------- Color tokens ----------- */1186:root {1187    --cc-primary: #6366F1;1188    --cc-primary-dark: #4F46E5;1189    --cc-accent: #EC4899;1190    --cc-surface: #F8FAFC;1191    --cc-border: #E2E8F0;1192    --cc-text: #0F172A;1193    --cc-muted: #64748B;1194    --cc-success: #10B981;1195    --cc-warning: #F59E0B;1196    --cc-danger: #EF4444;1197    --cc-critical: #DC2626;1198}1199 1200/* ----------- Hero header ----------- */

Showing the first 1,200 of 3132 lines. Download the file for the rest.