Web TippsUse custom web fonts in Google Sheets charts(08.09.2026 um 17:05 Uhr)
Web TippsIntroducing the new 1Password App for Google Chat(08.09.2026 um 18:02 Uhr)
Web TippsUse custom web fonts in Google Sheets charts(08.09.2026 um 17:05 Uhr)
Web TippsIntroducing the new 1Password App for Google Chat(08.09.2026 um 18:02 Uhr)

🔧 Programmierung 🕛 vor 1 Monat 8 Min Lesezeit
0

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

↗ Quelle (dev.to)
🗣️ Stimme:
📑 Inhaltsübersicht

Every distributor we spoke to has the same quiet bottleneck, and none of them call it a problem. They call it Tuesday.



A request for quote lands in a shared inbox. Sometimes it is a tidy bulleted list. More often it is three lines of text from someone's phone, or a PDF that was scanned at an angle. Someone on the sales desk reads it, works out which catalog part each line actually refers to, checks pricing, and types up a quote. A busy desk does this thirty or forty times a day.



It is slow, it is boring, and it is exactly the kind of work where a tired person on a Friday afternoon quotes the wrong bolt and nobody notices until the shipment arrives.



We spent three weeks building Distill.ai to do that job. This is what we learned, including the parts that went badly.






What we actually built



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




CODE
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:




CODE
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,
}),
});









CODE
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:




CODE
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:




CODE
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:




CODE
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:




CODE
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:




CODE
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.

Vollständiger Original-Bericht
Ausführliche Details, Code-Beispiele & Hersteller-Stellungnahme auf dev.to.
↗ Original-Artikel auf dev.to lesen
Wie bewertest du diesen Beitrag?
1 Klick Feedback
Teilen mit Netzwerk & Team:

Community-Analysen & Experten-Meinungen 0

Verfasse deine eigene Analyse, teile Workarounds oder diskutiere diesen Vorfall im Blog.
Noch keine Community-Analyse verfasst. Markiere einen Textabschnitt oder klicke oben auf Eigene Analyse verfassen“!
Community Pulse: Relevanz-Einschätzung
1 Klick Experten-Votum
🔴 Akute Relevanz 0%
🟡 In Evaluierung 0%
🟢 Keine Auswirkung 0%
Spannende Innovation 0%
Verwandte Story-Cluster & Quellen (Vektor-KI)
Port 8095 Engine
3 Quellen
Use custom web fonts in Google Sheets charts
2 Quellen
Introducing the new 1Password App for Google Chat
1 Quelle
Context-aware access controls are available for Gemini Enterprise in the Admin console
Ähnliche Beiträge
🔍 Verwandte News

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

Thematisch verwandte Begriffe: built, agent, that, turns · 6 Treffer

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...