DEV Community

We built an agent that turns messy RFQ emails into priced quotes, and shipped it on Alibaba Cloud

What we actually built

You paste an email or upload a PDF. From there a seven stage pipeline runs:

parse -> extract -> classify -> match -> price -> policy -> score

Parse cleans the document into text. Extract pulls out the individual line items, quantities, and specs. Classify works out what kind of request this is. Match maps each line to a real catalog SKU. Price applies the pricing rules. Policy runs the business checks. Score attaches a confidence value to every match.

The interesting part is not the happy path. It is what happens when the model is unsure. Any line that scores below a 0.70 match threshold does not get quoted. It gets flagged with a reason and routed to a human review queue. A person confirms or corrects it, and the quote goes out clean. That one decision is the difference between a demo and something a sales desk would actually put its name on.

An agent that is confidently wrong 5% of the time is worse than useless in procurement, because someone has to check all 100% of the output anyway. An agent that says "I got 47 of these 50 lines, here are the 3 I could not resolve" saves real hours.

Why Qwen, and how we wired it up

We used two models from Alibaba Cloud Model Studio:

  • Qwen-Plus for extraction and classification, including tool calling
  • text-embedding-v4 for the 1024 dimension embeddings behind catalog matching

Model Studio exposes an OpenAI-compatible endpoint, which mattered more than we expected. Our provider layer is a thin fetch wrapper, and switching models is a config change rather than a rewrite:

const response = await fetch(`${env.LLM_BASE_URL}/chat/completions`, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    Authorization: `Bearer ${env.LLM_API_KEY}`,
  },
  body: JSON.stringify({
    model: env.LLM_MODEL,
    messages: [{ role: 'user', content: prompt }],
    temperature,
    max_tokens: maxTokens,
  }),
});
LLM_BASE_URL=https://dashscope-intl.aliyuncs.com/compatible-mode/v1
LLM_MODEL=qwen-plus
EMBEDDINGS_MODEL=text-embedding-v4
EMBEDDINGS_DIMENSIONS=1024

The embeddings go into Postgres with pgvector, so catalog matching is a similarity search against real SKU rows rather than keyword guessing. "M8 hex bolt, grade 8.8, zinc plated" and "Bolt, hexagon head, M8x50, 8.8, ZP" are not a string match, but they are close neighbours in vector space.

One deliberate choice: we deployed in Singapore (ap-southeast-1) specifically to sit next to the Model Studio endpoint. On a pipeline that makes several model calls per request, the round trips add up.

The stack

A NestJS modular monolith in TypeScript, split into two processes:

  • API takes the request, validates it, persists it, and enqueues a job
  • Worker consumes from Redis via BullMQ and runs all seven stages

Splitting them matters because parsing a 40 page PDF and calling a model six times is not something you do inside an HTTP request. The API answers immediately and the browser watches progress over Server-Sent Events, so you see each stage light up in real time instead of staring at a spinner.

Everything is containerized: api, worker, client (React and Vite behind Nginx), Postgres with pgvector, and Redis. The whole thing runs on a single Alibaba Cloud ECS instance via docker-compose, with Caddy in front for TLS.

Where it actually went wrong

The pipeline was the fun part. Deployment is where we lost days.

The migration step that could never have worked

Our deploy job ran database migrations inside the production container:

docker compose exec -T api pnpm migration:run

That script resolves to ts-node -r tsconfig-paths/register node_modules/typeorm/cli.js migration:run -d src/database/data-source.ts. Now look at the production Dockerfile. The runner stage installs --prod dependencies only and copies dist/, not src/. So in the production image there is no ts-node, and there is no TypeScript data source. The command was guaranteed to fail the moment it touched a real server, and it had been sitting in the workflow the whole time because nobody had run a real deploy yet.

The fix is to point the TypeORM CLI at the compiled data source, which only needs packages that exist in the runtime image:

docker compose exec -T api \
  node node_modules/typeorm/cli.js migration:run -d dist/database/data-source.js

We checked that dist/database/data-source.js resolves entities and migrations through __dirname-relative globs, and confirmed no @-alias requires survive compilation. All 21 migrations then applied cleanly.

Lesson: your production image is a different computer. Any command in your deploy pipeline that you have only ever run locally is an untested command.

Boot loop over an empty string

The api and worker containers came up and immediately died, over and over:

Invalid environment variables: { SENTRY_DSN: [ 'Invalid url' ] }

Our env schema validates SENTRY_DSN as a URL. We were not using Sentry in this deployment, so the value was an empty string, and an empty string is not a valid URL. The variable was optional in spirit but not in schema. Deleting the line entirely fixed it. Optional means absent, not blank.

A 4 GB box and an out of memory kill

We build images on the instance itself. On a 2 vCPU / 4 GB box, the client build got OOM killed partway through. Adding 4 GB of swap got the builds through. Not elegant, but it was the difference between shipping and not shipping.

Branch protection versus a deadline

Our staging and main branches require a pull request and three passing checks, and none of us can self-merge. Correct policy. Also completely immovable at 11pm with a deadline coming.

So we brought the first release up by hand: git archive, scp to the box, docker compose build, migrate, up -d. Then we wrote the pipeline fix back as a proper reviewed PR, so the next deploy goes through CI like it should.

We do not regret the branch protection. Shipping around your own safety rails once, deliberately, and then closing the gap properly afterwards, is very different from not having the rails.

HTTPS without paying for a domain

We used Caddy with a DuckDNS subdomain. Caddy handles the ACME challenge and certificate renewal on its own, so the entire TLS configuration is this:

distill-ai.duckdns.org {
    reverse_proxy client:8080
}

Two lines and a real Let's Encrypt certificate. Then we locked SSH to a single operator IP and closed the raw API port, so the only public surface is the HTTPS front door.

What we would tell ourselves three weeks ago

  • Deploy on day two, not day nineteen. Every bug above was a deployment bug, not a logic bug. None of them were findable locally. The pipeline worked on our machines the whole time.
  • Confidence scoring is the product, not a feature. We nearly shipped without the review queue. The moment we added it, the whole thing changed from a party trick into something you could show a customer.
  • An OpenAI-compatible endpoint is worth real money in engineering time. We never wrote a Qwen-specific client. We wrote an HTTP client and changed a base URL.
  • "It runs locally" and "it runs in production" are separated by a surprising amount of unglamorous work. Swap space. Empty strings. Missing dev dependencies. None of it is interesting and all of it is required.

Try it

The deployment is live and open, no login required: https://distill-ai.duckdns.org

The seeded catalog is zinc-plated fasteners, so write your RFQ around M6, M8, or M10 bolts, nuts, and washers. Here is one to paste:

Hi team, please quote the following for our new assembly line:

  • 1500 x M10 hex bolts, grade 8.8, zinc plated
  • 1500 x M10 hex nuts, zinc plated
  • 3000 x M10 flat washers, zinc plated
    We need delivery within two weeks. Kindly include pricing and lead time.
    Regards,
    Sarah Bennett, Procurement, Northgate Industrial Ltd.

Watch the trace run, then open the review page. If one of the lines lands in the review queue rather than the quote, that is not a bug. That is the whole point.

Built with NestJS, TypeScript, React, PostgreSQL with pgvector, Redis, BullMQ, Docker, and Caddy, running on Alibaba Cloud ECS with Qwen-Plus and text-embedding-v4 via Alibaba Cloud Model Studio.

Comments

No comments yet. Start the discussion.