TenderBot — Technische Documentatie
Architectuur
TenderBot is opgebouwd uit drie hoofdcomponenten:
┌─────────────────────────────────────────────────────────────┐
│ Browser / API-client │
└──────────────────────────┬──────────────────────────────────┘
│ HTTPS
┌────────▼────────┐
│ Traefik Ingress │ (Kubernetes)
└────────┬────────┘
│
┌────────▼─────────┐
│ FastAPI service │ (uvicorn, Python 3.11+)
│ tenderbot.api │
└─┬──────────────┬─┘
│ │
┌─────▼─────┐ ┌─────▼──────┐
│ SQLite │ │ Groq API │
│ (WAL, PV) │ │ llama-3.3 │
└───────────┘ └────────────┘
- FastAPI service (
tenderbot.api) — HTTP-laag, middleware voor request-logging, lifecycle management voor DB-initialisatie. - Bot-laag (
tenderbot.bot) — sessie-, prompt- en LLM-orchestratie. Lazy-init van de Groq client. - Persistentielaag (
tenderbot.db) — SQLite met WAL-journal, threadsafe viathreading.Lock. Eén proces-gedeelde connectie metcheck_same_thread=False. - Kubernetes — deployment achter Traefik ingress; database op een PersistentVolume (
/opt/tenderbot/tenderbot.db). - Groq — externe inference provider die het
llama-3.3-70b-versatilemodel serveert.
Statische frontend-assets worden geserveerd vanuit tenderbot/static/ op / en /static.
Datamodellen
Alle tabellen worden bij applicatie-startup aangemaakt via init_db(). De database draait in WAL-modus met foreign keys ingeschakeld.
logs — Applicatielogs
| Kolom | Type | Beschrijving |
|---|---|---|
id |
INTEGER PK AUTOINC | Primaire sleutel |
timestamp |
TEXT (ISO-8601 UTC) | Tijdstempel |
level |
TEXT | INFO, WARNING, ERROR, … |
logger |
TEXT, default tenderbot |
Naam van de logger |
message |
TEXT | Logregel |
method |
TEXT (nullable) | HTTP-methode (bij request-log) |
path |
TEXT (nullable) | HTTP-pad |
status_code |
INTEGER (nullable) | HTTP-status |
duration_ms |
REAL (nullable) | Duur van de request |
extra |
TEXT (nullable) | JSON-blob met extra velden |
Indexen: idx_logs_timestamp, idx_logs_level.
audit_trail — Audit-events
| Kolom | Type | Beschrijving |
|---|---|---|
id |
INTEGER PK AUTOINC | Primaire sleutel |
timestamp |
TEXT (ISO-8601 UTC) | Tijdstempel |
action |
TEXT | Action key, bv. session.create, chat.message |
session_id |
TEXT (nullable) | Optionele sessie-referentie |
user_id |
TEXT (nullable) | Optionele gebruiker |
detail |
TEXT (nullable) | JSON-blob met details |
Indexen: idx_audit_session, idx_audit_timestamp.
ai_sessions — Chatsessies
| Kolom | Type | Beschrijving |
|---|---|---|
session_id |
TEXT PK | UUIDv4 |
created_at |
TEXT (ISO-8601 UTC) | Aanmaakmoment |
updated_at |
TEXT (ISO-8601 UTC) | Laatste activiteit |
message_count |
INTEGER, default 0 | Aantal berichten |
context |
TEXT (nullable) | Vrije string of JSON-string |
username |
TEXT (nullable) | Gebruiker die de sessie aanmaakte |
ai_messages — Chatberichten
| Kolom | Type | Beschrijving |
|---|---|---|
id |
INTEGER PK AUTOINC | Primaire sleutel |
session_id |
TEXT, FK → ai_sessions(session_id) ON DELETE CASCADE |
Sessie |
role |
TEXT | user of assistant |
content |
TEXT | Berichttekst |
created_at |
TEXT (ISO-8601 UTC) | Tijdstempel |
Index: idx_messages_session.
Migratiestrategie: init_db() voert idempotente ALTER TABLE ADD COLUMN uit voor context en username op ai_sessions, zodat oudere databases worden bijgewerkt zonder data-verlies.
Context schema
Bij POST /sessions kan een optioneel context-object worden meegegeven. Dit wordt in ai_sessions.context opgeslagen als JSON-string (bij dict/list) of als platte tekst.
Voor elke chat-request leest bot.chat() de context op en geeft deze door aan _format_context() in bot.py. Deze functie:
- Probeert de string als JSON te parsen.
- Bij een dict-object herkent zij de vaste sleutels
beschrijving,aanbestedingstype,sectoreneisen_categorieen. - Voor
eisen_categorieen(lijst van dicts of strings) wordt een genummerde opsomming gegenereerd met optioneletoelichting. - Voegt automatisch de instructie toe: "Stel voor iedere categorie concrete, SMART-geformuleerde eisen op wanneer de gebruiker hierom vraagt."
- Onbekende sleutels worden generiek als
key: value-regels toegevoegd.
Voorbeeld gestructureerde context
{
"beschrijving": "Aanbesteding HR-software voor middelgrote gemeente",
"aanbestedingstype": "Europese openbare procedure",
"sector": "ICT / HR",
"eisen_categorieen": [
{"categorie": "Functioneel", "toelichting": "Salarisadministratie en verlof"},
{"categorie": "Security", "toelichting": "ISO 27001, NEN 7510"},
{"categorie": "Duurzaamheid"}
],
"geschatte_waarde": "€ 850.000"
}
Dit wordt geformatteerd tot:
Opdracht: Aanbesteding HR-software voor middelgrote gemeente
Type: Europese openbare procedure
Sector: ICT / HR
Eisencategorieën voor deze aanbesteding:
1. Functioneel — Salarisadministratie en verlof
2. Security — ISO 27001, NEN 7510
3. Duurzaamheid
Stel voor iedere categorie concrete, SMART-geformuleerde eisen op wanneer de gebruiker hierom vraagt.
geschatte_waarde: € 850.000
Sessiebeheer
Aanmaken
new_session(context, username) in bot.py:
- Genereert een UUIDv4 als
session_id. - Serialiseert
contextnaar JSON (bij dict/list) of laat een string ongewijzigd. - Roept
db.create_session()aan (INSERT OR IGNORE). - Schrijft een
session.createaudit-event.
Bericht opslaan
chat(session_id, user_message):
- Maakt de sessie aan als die nog niet bestaat (defensief).
- Slaat het user-bericht op via
db.add_message(); tegelijk wordtmessage_countopgehoogd enupdated_atbijgewerkt. - Logt een
chat.messageaudit-event met rol en content. - Bouwt de prompt: system prompt + (optionele) geformatteerde context + volledige
ai_messages-historie. - Roept Groq aan, slaat het assistant-bericht op, logt opnieuw een audit-event.
Geschiedenis ophalen
db.get_session_messages() geeft None terug als de sessie niet bestaat (→ 404 in de API-laag), anders een lijst {role, content}-dicts geordend op id.
Verwijderen
db.remove_session() doet DELETE FROM ai_sessions WHERE session_id = ?. De foreign key met ON DELETE CASCADE zorgt dat alle ai_messages automatisch worden opgeruimd. Bij succes wordt een session.delete audit-event geschreven.
Audit trail
De audit_trail-tabel legt vast wie wat wanneer deed. Alle events bevatten een UTC ISO-timestamp.
| Action | Trigger | session_id |
user_id |
detail |
|---|---|---|---|---|
session.create |
bot.new_session() |
✓ | username (optioneel) | {"context": ..., "username": ...} |
session.delete |
bot.delete_session() |
✓ | — | — |
chat.message |
User-bericht in bot.chat() |
✓ | — | {"role": "user", "content": "..."} |
chat.message |
Assistant-respons in bot.chat() |
✓ | — | {"role": "assistant", "content": "..."} |
Daarnaast registreert de log_requests-middleware elke HTTP-request in logs (methode, pad, status, duur in ms).
AI integratie
System prompt
De system prompt (SYSTEM_PROMPT in bot.py) definieert TenderBot als gespecialiseerde Nederlandse aanbestedingsassistent. De prompt bevat:
- Juridisch kader: Aw 2012, ARW 2016, Gids Proportionaliteit, EU-richtlijnen 2014/24/EU en 2014/25/EU, actuele drempelwaarden.
- Procedures: openbaar, niet-openbaar, MCD, innovatiepartnerschap, onderhandelingsprocedure, (meervoudig/enkelvoudig) onderhands.
- PvE-structuur: SMART-criteria, harde eisen vs. wensen, functionele vs. technische eisen.
- Selectie- en gunningscriteria: uitsluitingsgronden, geschiktheidseisen, BPKV/EMVI, LCC/TCO.
- Aanbestedingsdocumenten, duurzaamheid (MVI, SROI, CO₂-Prestatieladder) en praktische hulp.
- Strikte afbakening: vragen buiten het aanbestedingsdomein worden afgewezen met een vast standaardantwoord.
Context-injectie
Voor elk chat-verzoek wordt de sessiecontext (indien aanwezig) door _format_context() omgezet naar leesbare tekst en geappend aan de system prompt onder de kop ## Context voor deze sessie. Dit gebeurt bij elke request, zodat de context altijd vers wordt meegegeven zonder in de berichtenhistorie te zitten.
Promptopbouw
messages = [
{"role": "system", "content": SYSTEM_PROMPT + "\n\n## Context voor deze sessie\n" + formatted_context},
... volledige ai_messages historie ...
]
Groq configuratie
| Parameter | Waarde |
|---|---|
model |
llama-3.3-70b-versatile |
max_tokens |
4096 |
| Auth | GROQ_API_KEY (env var) |
| Client | Lazy-geïnitialiseerd singleton (_get_client()) |
Deploy workflow
TenderBot volgt een gepromoveerde branching-strategie:
feature branch ──► PR ──► accp ──► (deploy-accp) ──► test ──► merge ──► master ──► (deploy-prod)
- Feature ontwikkeling — werk in een feature-branch; PR richting
accp. - Push naar
accp— triggert dedeploy-accppipeline:- Build Docker image, tag met commit-SHA +
accp. - Deploy naar de ACCP-namespace in Kubernetes via Traefik ingress (
tenderbot-accp.example.nl). - Smoke-tests op
/healthen/sessions.
- Build Docker image, tag met commit-SHA +
- Acceptatietest — functionele en regressietests in de ACCP-omgeving; review van wiki/changelog.
- Promotie naar productie — merge
accp→master. - Push naar
master— triggertdeploy-prod:- Image opnieuw getagd als
prodenvX.Y.Z. - Rolling update in de PROD-namespace (
tenderbot.example.nl). - Database-migraties draaien automatisch bij startup via
init_db()(idempotent).
- Image opnieuw getagd als
- Post-deploy — bewaking via de
logs-tabel en Kubernetes liveness/readiness probes op/health.
Het versienummer in api.py (version="1.1.0" na deze release) wordt per release bijgewerkt en weergegeven in Swagger UI.