Most of the work in shipping BauGPT's Ausschreibungs-Radar had nothing to do with matching. Deciding whether a public tender fits a Bau company's profile was the fun part, and it landed early as a tool the assistant can call. Everything after that was correctness plumbing. Deadlines, tenant boundaries, spend caps, and the question of what the system does on a day when it finds nothing.
The Radar watches public tenders, scores them against a company profile, and sends a daily digest of the matches. Each row in that digest carries a signed one-click dismissal, so nobody has to log into a dashboard just to say no. It ships behind the Business plan.
The thing that had to be right before anything else was the deadline. Early on, the submission deadline was being derived from a duration instead of read from the date the tender itself publishes as its opening. On paper that is a small difference in where a value comes from. In Vergabe it is the entire product. A deadline computed from a guess is a bid you never submit.
The Radar lives in a private BauGPT service, so this post describes behaviour rather than code. Every item below maps to a change that shipped, not a plan.
Next came the boundary between companies. A tender match is competitive information, and the ugliest possible bug here is a Munich Bauunternehmen seeing which tenders a competitor was matched against. Notification targets are now scoped to the company that is actually acting, and profile scoping got tightened in the same pass. The gate that decides whether a company profile is complete enough to be matched at all was made to fail closed, with a test that proves it fails closed rather than a comment claiming it does.
That same change also handled corrigenda. When a contracting authority amends a published tender, the digest is allowed to mail about it again instead of treating the tender as already seen. Anyone who has bid on public work in Germany knows a corrigendum can move a deadline or change a Los. Suppressing that as a duplicate would be the expensive kind of correct.
Then the money. LLM scoring costs something per tender, and public tender feeds have no opinion about your budget. Scoring got a hard spend bound plus a budget counter that increments atomically, and a model response that is not a number now gets rejected instead of quietly coerced into one. Matches that were deferred because the budget ran out are not thrown away: a later sweep pulls them back into scoring. The prefilter runs after each ingest window rather than on its own schedule, and the sweep is bounded by relevance instead of a month-based prefilter.
Do
- Bound model spend before the feature ships.
- Re-queue deferred work on a later sweep.
- Scope every notification to the acting company.
- Read the deadline from the published field.
- Prove the gate fails closed with a test.
Don't
- Assume the feed volume respects your budget.
- Let a spend ceiling silently drop matches.
- Trust a global recipient list in multi-tenant code.
- Derive the deadline from a duration.
- Assume a gate fails closed because it should.
Worth putting those side by side. The profile gate fails closed, because matching against a half-filled profile burns money and sends noise to a customer. Directory sync went the other way in the same window: seat limit and cooldown enforcement now defaults to a soft limit rather than a hard block. Fail closed where being wrong leaks data or spends money. Fail soft where being wrong locks an admin out of onboarding their own team. Same word, opposite default, and the difference is who gets hurt.
And then the failure mode I keep having to relearn: silence. An empty digest and a broken digest look identical from a customer's side. So digest failures are surfaced instead of swallowed, and an empty buyer field now renders a placeholder rather than a blank row. The sweep summary log came back, and the starvation warning stopped firing on runs that were actually healthy. A radar that reports nothing looks exactly like a radar that is broken.
"A radar that reports nothing looks exactly like a radar that is broken."
Here is another one that only shows up at this scale of change: a batch of defects became visible only when the tasks were reviewed together, not per pull request. Reviewing each change on its own is good at answering "is this correct". It is bad at answering "do these correct changes still add up to a working feature". That is a process lesson more than an engineering one, and it is the strongest argument I have for a cross-cutting review pass before anything customer-facing goes out.
The Radar is the outbound half. The rest of the credibility problem is whether BauGPT is right when a customer asks it something about German building law. We run an eval suite over Baurecht answers, and the screen that checks those answers got demoted from a scorer to a diagnostic. It flags things for a human now. It does not decide the grade. The grade comes from an LLM judge, which was made a required part of the pipeline so that a pass actually follows the judge's verdict instead of a side heuristic.
We added an eval family for Altfall questions, the legacy cases where an older rule still governs a building, with its own rubric and a flagship case. That corpus then grew to a floor of 8 cases. And then the embarrassing part: four of the Altfall traps were firing on answers that were correct. A trap that punishes a right answer is worse than having no trap, because it teaches you to distrust a model that was doing its job, and it hides the failures you actually care about behind noise you generated yourself.
There is a parallel line of work on the CRB import path. Field mapping coverage got extended for a record type, and a missing quantity-breakdown code was filled in. Review then turned up gaps in quantity handling plus a bad error path for the legacy format. The one I like most is small: the test fixture sanitiser now fails closed when it has no secrets source, so it is structurally incapable of writing an unsanitised fixture into the repo. Customer Leistungsverzeichnis data does not get a second chance.
Not everything in the window was code. A BauGPT ticket covered a strategic options analysis on Reiner AI, research complete and the document delivered, logged at 1.5 hours by my own note.
If you are evaluating AI for a construction business, the demo is not where the product lives. Ask where the submission deadline comes from. Ask what happens when the model returns something that is not a number. Ask who else in the system can see your matches, and what proves it. Ask what the thing does on a quiet Tuesday when it genuinely finds nothing, and whether you would be able to tell that apart from a crash. Every good answer to those questions is a week somebody already spent on plumbing that nobody will ever compliment them for.