<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Corpable Notes]]></title><description><![CDATA[Corpable Notes]]></description><link>https://corpablehk.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sun, 25 Oct 2026 15:56:44 GMT</lastBuildDate><atom:link href="https://corpablehk.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Turning Messy B2B Quote-Request Emails into Validated JSON Before Any AI Touches Them]]></title><description><![CDATA[Most teams that add an AI assistant to their sales inbox start at the wrong end. They wire a model to "read the email and write a reply", then spend weeks…]]></description><link>https://corpablehk.hashnode.dev/turning-messy-b2b-quote-request-emails-into-validated-json-before-any-ai-touches-them</link><guid isPermaLink="true">https://corpablehk.hashnode.dev/turning-messy-b2b-quote-request-emails-into-validated-json-before-any-ai-touches-them</guid><category><![CDATA[automation]]></category><category><![CDATA[AI]]></category><category><![CDATA[data validation]]></category><category><![CDATA[Python]]></category><dc:creator><![CDATA[Corpable]]></dc:creator><pubDate>Sun, 11 Oct 2026 02:02:01 GMT</pubDate><content:encoded><![CDATA[<p>Most teams that add an AI assistant to their sales inbox start at the wrong end. They wire a model to "read the email and write a reply", then spend weeks patching weird outputs. In B2B export sales, inbound requests for quotation (RFQs) are messy: half the fields are missing, units are inconsistent, and the same buyer writes in three times with slightly different numbers.</p>
<p>A sturdier design separates two jobs: first <strong>extract and validate</strong> the request into a structured record, then let anything downstream (a human, a template, or an agent) work from that record. This post shows a small, testable intake layer in Python using Pydantic, with the rules we found matter most for small export desks.</p>
<h2>Why extraction should be its own stage</h2>
<p>When extraction and drafting happen in one prompt, you can't tell <em>why</em> a reply went wrong. Did the model misread "5k pcs" or did it make up a lead time? If you split the stages:</p>
<ul>
<li>You can unit-test extraction against real (anonymised) emails.</li>
<li>You can block drafting when required fields are missing, instead of letting a model guess.</li>
<li>You get a clean dataset of what buyers actually ask for, which is useful long after the AI project.</li>
</ul>
<p>The extraction step can be regex, an LLM with structured output, or both. What matters is that its output passes a strict schema before anything else runs.</p>
<h2>Step 1: Define the record you actually need</h2>
<p>Resist the urge to capture everything. For a first-pass quote, most desks need about eight fields:</p>
<pre><code class="language-python">from datetime import date
from enum import Enum
from typing import Optional
from pydantic import BaseModel, Field, field_validator

class Incoterm(str, Enum):
    EXW = "EXW"; FOB = "FOB"; CIF = "CIF"; DAP = "DAP"; DDP = "DDP"

class RFQ(BaseModel):
    buyer_email: str
    buyer_company: Optional[str] = None
    product_ref: str = Field(min_length=2)          # SKU or product name as written
    quantity: int = Field(gt=0)
    unit: str = "pcs"
    incoterm: Optional[Incoterm] = None
    destination_country: Optional[str] = None       # ISO 3166 alpha-2
    target_date: Optional[date] = None
    raw_excerpt: str                                 # the sentence(s) the values came from

    @field_validator("destination_country")
    @classmethod
    def iso2(cls, v):
        if v is not None and (len(v) != 2 or not v.isalpha()):
            raise ValueError("use ISO 3166 alpha-2, e.g. 'DE'")
        return v.upper() if v else v
</code></pre>
<p>Two choices worth copying. <code>raw_excerpt</code> forces the extractor to show its evidence, so a reviewer can check a value in seconds. And optional fields stay <code>None</code> rather than getting a default. A missing Incoterm is information. A defaulted "FOB" is a silent commitment.</p>
<h2>Step 2: Normalise quantities before validating</h2>
<p>Buyers write "5k", "5,000 pieces", "2 x 40HQ" or "MOQ?". Normalise the common cases yourself and send everything else to review rather than to a model's best guess:</p>
<pre><code class="language-python">import re

_QTY = re.compile(r"(?P&lt;n&gt;\d+(?:[.,]\d+)?)\s*(?P&lt;k&gt;k)?\s*(?P&lt;unit&gt;pcs|pieces|units|sets|cartons)?", re.I)

def parse_quantity(text: str) -&gt; tuple[int, str] | None:
    m = _QTY.search(text)
    if not m:
        return None
    n = float(m["n"].replace(",", ""))
    if m["k"]:
        n *= 1000
    unit = (m["unit"] or "pcs").lower()
    unit = {"pieces": "pcs", "units": "pcs"}.get(unit, unit)
    return int(n), unit
</code></pre>
<p>Container-based quantities ("2 x 40HQ") are deliberately not handled here. Converting them depends on carton size and product, and that belongs with a person who knows the packing list.</p>
<h2>Step 3: Validate, then route on what's missing</h2>
<p>Validation shouldn't just pass or fail. It should tell you <em>what to do next</em>:</p>
<pre><code class="language-python">from pydantic import ValidationError

REQUIRED_FOR_QUOTE = {"incoterm", "destination_country"}

def route(candidate: dict) -&gt; tuple[str, RFQ | None, list[str]]:
    try:
        rfq = RFQ(**candidate)
    except ValidationError as e:
        return "reject_to_human", None, [err["loc"][0] for err in e.errors()]
    missing = [f for f in REQUIRED_FOR_QUOTE if getattr(rfq, f) is None]
    if missing:
        return "ask_buyer", rfq, missing      # send a clarifying question, not a price
    return "ready_for_quote", rfq, []
</code></pre>
<p>Three outcomes, three different queues. The <code>ask_buyer</code> path is the one people skip, and it's the most valuable. A short "Could you confirm the delivery terms (FOB or CIF) and destination port?" is safe to send automatically, because it commits you to nothing.</p>
<h2>Step 4: Deduplicate repeat requests</h2>
<p>The same buyer often sends the same RFQ through a marketplace inbox, then by email, then on WhatsApp. Without a dedup key, two people quote the same job at different prices. A simple fingerprint is usually enough:</p>
<pre><code class="language-python">import hashlib

def fingerprint(rfq: RFQ) -&gt; str:
    key = f"{rfq.buyer_email.lower()}|{rfq.product_ref.lower().strip()}|{rfq.quantity // 100}"
    return hashlib.sha256(key.encode()).hexdigest()[:16]
</code></pre>
<p>Bucketing quantity to the nearest hundred catches "5,000" vs "5,050" without merging truly different orders. Store fingerprints with a 14-day window and attach new messages to the existing thread instead of opening a new one.</p>
<h2>Step 5: Test against real emails</h2>
<p>Collect 30 to 50 anonymised RFQs and write expected records for each by hand. That fixture set is your regression suite:</p>
<pre><code class="language-python">import json, pytest

CASES = json.load(open("fixtures/rfqs.json"))

@pytest.mark.parametrize("case", CASES, ids=lambda c: c["id"])
def test_route(case):
    decision, _, missing = route(case["extracted"])
    assert decision == case["expected_decision"]
    assert set(missing) == set(case["expected_missing"])
</code></pre>
<p>When you swap extraction methods (regex to LLM, or one model to another), this suite tells you right away whether routing got better or worse. That beats eyeballing a few replies.</p>
<h2>Where this fits in a real workflow</h2>
<p>On marketplace seller accounts, such as Alibaba.com storefronts run by small Hong Kong export teams, this intake layer usually sits between the platform inbox and whatever drafts replies. If you're evaluating AI reply agents for that setting, it's worth checking how they handle missing fields. Some vendor overviews, like this one on <a href="https://aliad.hk/en/accio">Accio Work for Alibaba.com sellers</a>, describe the agent side, but the validation contract is still yours to own.</p>
<p>Before go-live, write down which fields your desk treats as required, who owns the <code>reject_to_human</code> queue, and how long the dedup window is. A plain <a href="https://aliad.hk/checklist">pre-launch checklist</a> helps keep those decisions out of people's heads and in a shared document.</p>
<h2>Takeaways</h2>
<ul>
<li>Extract into a strict schema first. Draft second.</li>
<li>Keep missing values as <code>None</code>. Defaults are commitments.</li>
<li>Route on <em>what's missing</em>. A clarifying question is the safest message you can automate.</li>
<li>Fingerprint RFQs so a request that arrives through several channels gets one quote.</li>
<li>Keep a fixture suite of real emails and run it every time you change the extractor.</li>
</ul>
<p>None of this needs a large model or a big platform. It's a hundred or so lines of boring, testable code, and it makes whatever AI you add later much easier to trust.</p>
]]></content:encoded></item></channel></rss>