[{"content":"Assignment: Document how I built a small RAG chatbot on my portfolio.\nWhy RAG on a portfolio site? # A plain LLM does not know my projects, CV details, or how I want visitors to be answered. Retrieval Augmented Generation (RAG) fixes that by:\nStoring my own text in small chunks Turning chunks and the user’s question into embeddings (vectors) Finding the most relevant chunks for each question Sending those chunks to the model as context before it replies The visitor still chats in natural language, but answers can be grounded in material I control.\nWhat I built (high level) # Browser (Hugo site) → floating chat UI + conversation history (localStorage) → POST /chat with message history Node API (server/) → embed user question → retrieve top chunks from rag-data/*.md → call OpenAI Chat Completions with CONTEXT + messages → return { \u0026#34;reply\u0026#34;: \u0026#34;...\u0026#34; } The API key never lives in the frontend — only in server/.env on the machine that runs the API.\nFrontend: chat as part of the site # On the portfolio I added:\nA hero input on the homepage (“Ask me anything”) that opens the same chat when you send a message A fixed chat button (bottom-right) that opens a centered modal (assistant-style UI) Multiple conversations saved in the browser (localStorage), with a sidebar to switch or start a New chat The widget calls the backend only when ragApiUrl is set in Hugo config (config/_default/params.toml under [chat]). Without a URL, it falls back to a short static message so the site still works offline.\nKnowledge base: server/rag-data/ # RAG needs source documents. I keep them as Markdown files in server/rag-data/, for example portfolio.md, with sections about:\nWho I am and what I focus on (backend, data, web) How the bot should behave (concise, no invented facts) Pointers to projects and contact When I update my background or projects, I edit these files (or add new .md files). That is the source of truth the bot should prefer over guessing.\nBackend RAG pipeline (server/rag.js) # At server startup the RAG module:\nReads all .md files in rag-data/ Splits text into chunks (configurable size and overlap) Calls OpenAI /v1/embeddings (text-embedding-3-small by default) Keeps chunks + vectors in memory for the running process On each chat request:\nThe latest user message is embedded Cosine similarity ranks chunks against the question The top K chunks (default 5) are injected into the system prompt under a CONTEXT block The full conversation is sent to chat completions The system prompt tells the model to use CONTEXT for facts and to say it is unsure rather than inventing employers, grades, or project details.\nHow I connect the API key (same key for RAG + chat) # RAG uses OpenAI embeddings; answering uses chat completions. I use one OPENAI_API_KEY for both — configured only on the server.\nStep by step (what I did):\nCreate key — At platform.openai.com/api-keys I create a secret key and save it in a password manager. I never paste it into Hugo or into this blog post. Local env file — In server/ I run cp .env.example .env and set OPENAI_API_KEY=sk-.... Optional: OPENAI_MODEL=gpt-4o-mini, RAG_TOP_K=5. Ignore secrets in Git — server/.env is in .gitignore; only .env.example is committed (placeholders, no real key). Start API — npm install then npm start in server/. The first run embeds all files in server/rag-data/; that uses the same key as later chat requests. Link the website — In config/_default/params.toml under [chat] I set ragApiUrl = \u0026quot;http://localhost:8788/chat\u0026quot; (production URL when deployed). Test — I open /health on the API, then send a message in the portfolio chat and confirm the reply is grounded in my rag-data text. The chat widget in the browser only receives data-chat-api-url from Hugo. Every call to OpenAI goes server → OpenAI, never browser → OpenAI.\nIf the key is wrong or missing, I get a clear error in the chat or a 503 from the API — not a silent failure.\nWhat worked well # Simple stack: no separate vector database for a small knowledge set Easy to extend: add a new .md file and restart the API to re-index Clear separation: static site + small API service Limitations # Index is rebuilt when the server starts — not a live multi-tenant vector DB Quality depends on writing good knowledge files and chunk size Embeddings + chat both cost API usage; long chats increase token use RAG reduces hallucinations but does not remove them entirely For the full integration story (endpoint contract, prompts, error handling), see An AI-driven application — calling an external LLM API.\nReflection # This assignment matched the course idea of RAG: retrieve first, then generate. For a portfolio, a lightweight in-process index was enough. A larger product might use Dify, Pinecone, or another hosted pipeline — the pattern stays the same.\n","date":"12 April 2026","externalUrl":null,"permalink":"/llm-course-exam/rag-chatbot/","section":"LLM Course (Exam)","summary":"","title":"Building a small RAG chatbot on my portfolio","type":"llm-course-exam"},{"content":"Assignment: Write about an automated workflow that updates a RAG bot on Dify so it always reflects the content on my portfolio.\nWhat the assignment is asking for # The goal is not a one-off upload of documents. It is a pipeline:\nPortfolio content changes → workflow detects or is triggered → knowledge base is refreshed → chatbot answers from up-to-date material On Dify, that usually means:\nA knowledge dataset connected to your bot Documents ingested (often from files or URLs) A workflow or integration that re-syncs when the site changes — for example after deploy, on a schedule, or via webhook Without automation, the bot slowly becomes wrong every time you edit projects or bio text.\nHow I think about “sync” for my own site # My portfolio is a Hugo static site. The assistant I ship uses a custom Node API (server/) with Markdown in server/rag-data/ instead of Dify for production chat. The workflow idea is the same as on Dify:\nStep Dify-style My portfolio API Source of truth Site pages / exports rag-data/*.md (+ site content I copy in) Trigger Workflow / webhook / schedule Server restart after deploy, or manual edit + restart Process Chunk + embed + store in dataset rag.js chunks + OpenAI embeddings at startup Consume Dify app / widget POST /chat from the Hugo chat widget So the automation problem is: when portfolio facts change, how do we refresh what the bot knows without manual copy-paste every time?\nWorkflow I use today (practical) # Edit portfolio content — projects in content/projects/, bio on contact/home, etc. Update knowledge files — mirror important facts into server/rag-data/ (e.g. new project summary, skills, FAQ). Redeploy or restart the chat API — on start, rag.js rebuilds the embedding index and logs how many chunks were indexed. The server reads OPENAI_API_KEY from server/.env again on each restart (I never put the key in the workflow script or in Git). Verify with a few questions in the chat UI (“What projects do you have?”, “How can I contact you?”). API key setup (OpenAI account → .env → ragApiUrl in Hugo) is described in detail in my AI-driven application post.\nThat is a manual but repeatable workflow. It is honest for a student portfolio and keeps the RAG layer understandable.\nToward fuller automation (Dify or custom) # These are the next steps I would document in a Dify setup or extend on my server:\nOption A — Dify + portfolio (course tool) # Export or crawl portfolio URLs after each hugo deploy. Trigger a Dify knowledge sync (API or built-in workflow). Point the Dify chat app at the updated dataset. Embed or link the Dify widget if you do not use a custom backend. Option B — Custom API + CI # On Git push / deploy, a script generates rag-data/*.md from Hugo content (templates or a small extractor). CI calls POST /reindex on the chat API (endpoint to add) or restarts the service. No separate Dify host — one pipeline from repo to embeddings. git push → build site → generate rag-data → restart API / reindex Either option satisfies the spirit of the assignment: the bot’s knowledge tracks the portfolio instead of drifting.\nRisks and design choices # Stale chunks: If you only update the website but not rag-data, RAG will confidently cite old text. Automation must touch the same store the retriever reads. Over-syncing: Re-embedding everything on every tiny edit costs time and API money; hash-based “only re-index if content changed” is a good production pattern (also covered in course material). Two bots: Running both Dify and a custom API is fine for learning, but visitors should use one clear entry point on the live site. Reflection # The assignment frames Dify as the product for workflow automation. I implemented the same architectural idea on my own stack so the live portfolio chat and my exam documentation stay aligned. The important learning outcome is the pipeline mindset: treat knowledge as data that must be versioned, triggered, and refreshed — not as a one-time PDF upload.\nIf I add Dify later, I would use it for orchestration and keep this post updated with screenshots of the actual workflow nodes and triggers.\n","date":"30 April 2026","externalUrl":null,"permalink":"/llm-course-exam/rag-workflow-automation/","section":"LLM Course (Exam)","summary":"","title":"RAG workflow automation — keeping the bot aligned with my portfolio","type":"llm-course-exam"},{"content":"Assignment: Write about code agents and my first experiences using them.\nWhat is a code agent (in practice)? # In the course, a code agent is more than a chat box. It can:\nRead and edit files in the project Run terminal commands (build, install, test) Iterate across several steps toward a goal Examples include Cursor Agent, Claude Code, Codex-style tools, and similar IDE integrations.\nThe developer still owns architecture and correctness. The agent is a fast assistant — not a substitute for understanding what ships to production.\nWhere I used it on this portfolio # I used a code agent heavily while building:\nHugo + Blowfish layout tweaks (header, footer, hero, contact page) The floating chat widget (modal UI, localStorage conversations, typing state) The Node chat API (server/index.js, server/rag.js) CSS for glass-style UI and responsive chat layout .gitignore and docs so secrets and build output are not committed Typical prompts looked like: “Make the chat fill the center of the screen”, “Add multiple saved chats”, “Wire POST /chat to OpenAI with RAG from markdown files”.\nWhat went well # Speed on boilerplate. Repetitive Hugo partials, CSS for the chat shell, and fetch/error handling in JavaScript were much faster than typing everything from scratch.\nExploration. When I was unsure how Blowfish loads scripts, the agent could search the theme and suggest extend-head.html for a custom bundle — a pattern I might have missed on my own.\nConsistency. After the first chat styles existed, follow-up requests (“bigger modal”, “typing indicator”) stayed visually aligned because the agent edited the same custom.css conventions.\nWhat I had to watch out for # I still need the theory (Naur). Generated code can compile and still break the design — wrong z-index, duplicate listeners, or API keys in the wrong layer. I reviewed every change to the security boundary: browser → my API → OpenAI, never key in Hugo.\nWhen we wired the LLM, I made sure the posts document the real setup: OpenAI key in server/.env, ragApiUrl in Hugo only, and .env in .gitignore. I describe that flow in AI-driven application as if fully deployed — that is the intended production setup even when I am not running the API every day during development.\nOver-scoping. The agent sometimes refactors unrelated files. I kept diffs focused and rejected “cleanup” that was not part of the task.\nFalse confidence. A working hugo build does not mean RAG is correct. I verified embeddings, rag-data content, and real chat questions manually.\nCourse vs portfolio. The exam assignment about assessing student reports with JSON is a different app than this chatbot. The agent helped on both ideas, but I separated portfolio assistant from assignment assessor in my head so requirements did not mix.\nHow I collaborate with the agent # Describe the outcome — user-visible behaviour first (“FAB opens modal, history persists”). Point to constraints — Blowfish theme, no secrets in frontend, match existing CSS. Review the diff — especially server/, params.toml, and anything touching auth or env. Run commands myself — hugo, npm start, open the browser — to confirm. That matches spec-driven thinking from the course: clear intent, accept criteria, then implementation.\nReflection # Code agents changed how fast I could ship UI and integration glue. They did not remove the need to understand RAG, HTTP, or where API keys live. For a datamatiker portfolio, the best result is showing both: a working feature and the ability to explain why it is structured that way — which is exactly what these exam blog posts are for.\nMy takeaway: use the agent as a pair programmer, keep architecture and security in your own hands, and always run the app once more after the agent says it is done.\n","date":"30 April 2026","externalUrl":null,"permalink":"/llm-course-exam/code-agent/","section":"LLM Course (Exam)","summary":"","title":"Using a code agent on my portfolio — first experiences","type":"llm-course-exam"},{"content":"Semesterprojekt (solo): AI-drevet kundeservice til Kirppu — loppesupermarked med standleje og ca. 34 butikker i Danmark. Det er et rigtigt kundeprojekt, ikke kun en øvelse: målet er at hjælpe sælgere og besøgende hurtigere med priser, booking, nærmeste butik og app-support, uden at chatbotten gætter sig til fakta.\nHvad og hvorfor # Kirppus information ligger spredt på hjemmesiden: priser hentes per butik via formular, butiksdata ligger i HTML, og ord som bod, stand og standleje betyder det samme for mennesker — men ikke nødvendigvis for en generisk chatmodel. Hvis en bot opfinder en pris eller skriver «Kirppu Lemvig», når der ingen butik er der, taber man tillid med det samme.\nJeg byggede derfor to ting, der hænger sammen: en vedligeholdelsesvenlig videns-pipeline (scraping, struktureret markdown, upload til Dify med RAG) og en intern demo af kirppu.dk, hvor den færdige chat er indlejret som en del af oplevelsen. Demo’en viser, at chatten ikke er en fremmed blå widget, men føles som Kirppu.\nBehovet — set fra demo og produktion # Primær bruger er den potentielle eller nuværende sælger, der vil forstå standleje, finde nærmeste filial og booke. Værdien for Kirppu er selvbetjening på dansk, færre gentagne henvendelser og korrekte booking-links — forudsat at svarene kun kommer fra verificeret indhold på kirppu.dk, ikke fra modellens generelle viden.\nI demo-perspektivet var det lige så vigtigt at tilliden til UI’et matchede tilliden til botten: hvis forsiden lignede en generisk webshop med forkert farve, troede ingen på chatten heller.\nHvad jeg byggede # Videnslaget følger en klar kæde: kirppu.dk scrapes med Python (WordPress API, HTML på find-min-butik, POST til prissider per butik) → mange små markdown-filer (én butik, én prisliste, guides) → afstandsberegning for 605 danske bynavne via DAWA og Haversine → upload til Dify Knowledge med embeddings og en lang system-prompt (dify_instructions.txt), der fungerer som en «kontrakt»: kun tal og URL fra hentet viden, ellers et fast FALLBACK til telefon og mail.\nDemo-siden er en React/Vite-app, der loader Kirppus egne stylesheets fra kirppu.dk og bruger samme HTML-klasser som det rigtige site. Brugeren kan browse forsiden, marketplace med stande og varer, og butikker — med mock-data (30 produkter, 8 stande, 4 butikker). Chatten er ikke en React-komponent: den er Difys officielle embed (embed.min.js) med Kirppu-grøn tema, eget logo på den flydende knap og et stort chatvindue.\nBegge dele adresserer samme problem fra forskellige vinkler: botten skal kunne svare rigtigt, og omgivelserne skal føles rigtige.\nUdfordringer undervejs # Design først, virkelighed bagefter. Min første mock brugte en «rød primary accent» fra en generisk prompt — ikke Kirppus grønne #31aa47 og orange CTA’er. Først da jeg sammenlignede med kirppu.dk og hentede deres CSS, blev demo’en troværdig. Det er et godt eksempel på, at AI accelererer scaffolding, men domæne og brand kræver menneskelig verifikation.\nFra API til embed. Jeg startede med en egen React-chat og API-nøgle i frontend. Det gav fin kontrol over header og reload-knap, men var en sikkerheds- og vedligeholdelsesbyrde. Skiftet til Dify embed fjernede nøglen fra browseren og overlod samtaler og RAG til Dify Studio — til gengæld kom cross-origin iframe-begrænsninger.\nShell-wrapper mod «Powered by Dify». Man kan ikke fjerne footeren via difyChatbotConfig. Et forsøg med clip-path gav en grå bjælke over chat-teksten. Løsningen blev en wrapper (#dify-chatbot-shell) med overflow: hidden og en iframe, der er 60 px højere end shell’en, så footeren klippes væk uden artefakter.\nData og prompts. Priser findes kun ved at POST’e per butik; Dify crashede engang på alle spørgsmål, fordi [slug] i instructions blev tolket som Jinja-variabler. Hallucinationer som «priser i Lemvig» løste jeg med regler om nærmeste butik og strenge FALLBACK-tekster. Stavefejl i bynavne krævede både prompt og en dedikeret guide-fil — ren embedding-søgning rammer sjældent «espegærder» → Esbjerg.\nPlatformbegrænsninger. Dify søger ofte kun på den seneste brugerbesked, ikke hele tråden — så «hvad koster en bod?» efterfulgt af «i Helsingør» kan give skæve chunks. Det delvist løses i instructions, men fuld kontrol kræver enten bedre flows (spørg altid om by) eller højere plan/features.\nModelstørrelse, instructions og tokenforbrug. En vigtig erfaring var, at en mindre LLM havde svært ved at følge lange, detaljerede instructions og derfor oftere faldt tilbage på generel træningsviden. Da jeg skiftede til en stærkere model, blev adfærden bedre, men tokenforbruget steg markant, hvilket er en reel kundebekymring. Jeg testede også en idé om at skrive instructions på kinesisk for at reducere tokens, men det gav ikke den ønskede effekt i praksis. Min nuværende retning er i stedet: kortere og mere præcise instructions kombineret med mere detaljeret og dækkende data i vector-databasen, så modellen behøver mindre «styring» i prompten.\nStavefejl før retrieval. Lige nu klarer retrieval typisk små stavefejl, fordi ord stadig ligger tæt i embedding-rummet, men ved 2–3 fejl i samme ord falder træfsikkerheden hurtigt. Derfor er et konkret næste udviklingstrin at indføre stavekorrektion/normalisering før chunk-søgning (fx fuzzy matching eller leksikon-baseret rettelse), fordi Kirppus kundedata viser, at mange spørgsmål bliver stavet meget upræcist.\nAI to steder # Rolle Værktøj Hvad det gjorde Slutprodukt Dify (RAG + LLM) Danske svar ud fra knowledge-chunks Udvikling (kode) Cursor Scraper, scripts, embed-integration, fejlsøgning Konsultation og læring Claude og ChatGPT Snak om arkitektur, RAG/chunking, Dify-fælder, GDPR og «hvordan griber jeg X an?» — ikke primær kode-editor, men sparring til forståelse undervejs Indhold Whisper (dansk) Video om prismærker → søgbar guide i RAG Design-asset Gemini (billede) Logo på chat-knappen Under udviklingen arbejdede jeg spec-drevet i Cursor: stor initial prompt, implementering, verifikation mod kirppu.dk, omspec (API → embed), og dokumentation så embed-tricks kan vedligeholdes. Claude og ChatGPT brugte jeg parallelt som konsulenter — fx da jeg skulle forstå Jinja i Dify-prompts, vælge chunk-strategi, eller diskutere tradeoffs mellem embed og egen API. Det hjalp mig at lære hurtigere, men beslutningerne og det endelige tjek mod kirppu.dk lå stadig hos mig.\nHvad jeg lærte # En pålidelig kundechatbot er i praksis et data- og grænse-projekt med en chatflade ovenpå — ikke bare et modelvalg. Cursor sparede timer på implementering; Claude og ChatGPT gav mig et ekstra lag af forklaring og refleksion, når jeg sad fast på koncepter — men AI i produktet skulle begrænses til det, scraperen og instructions tillader. Domæner med faste priser, geo og booking-URL’er tåler ikke «kreativ» LLM.\nTeknisk lærte jeg, at integration og brand er halvdelen af arbejdet: postMessage for at skjule expand-knap i iframe, Vite-proxy til same-origin i dev, og ærlighed om, hvad mock-data på demo-siden ikke kan (live lager, rigtig checkout).\nSamtidig lærte jeg, at den billigste model ikke nødvendigvis er billigst i praksis: hvis prompten bliver lang, svarene upræcise og man skal genkalde flere gange, kan totalforbruget stadig blive højt. Derfor er målet at gøre instructions kortere og skarpere, mens kvaliteten flyttes over i bedre knowledge-data og retrieval.\nNæste skridt mod rigtig produktion # Pilot på kirppu.dk med embed og Kirppu-godkendt tema. Proces for opdatering — re-scrape, re-upload prisfiler, publicér app i Dify (kun «gem» er ikke nok). Testpakke med 50+ rigtige spørgsmål, faktatjek mod officielle tekster, mobil og FALLBACK-rate. Menneskelig eskalering ved booking, klager og tvivl. Analytics — hvilke spørgsmål ender i FALLBACK? Evt. reverse-proxy i produktion, hvis citation-UI skal styles som i dev. Token-optimering: kortere, mere præcise instructions + mere detaljerede chunks i knowledge. Stavefejlshåndtering før retrieval: normalisering/fuzzy-korrektion inden vector search. Største risiko forbliver et forkert pris- eller booking-svar — én gang er nok til at underminere tilliden.\nAfslutning # Projektet startede som «lav en chatbot», men blev til infrastruktur for viden, en troværdig demo og en masse små beslutninger om, hvad AI må og ikke må. Fra rød accent til grøn, fra API-nøgle i browseren til shell-wrapper om embed’et: det meste af læringen lå i detaljerne, ikke i at vælge den nyeste model.\nHvis du vil dykke ned i embed-teknikken, ligger der separat teknisk dokumentation i projektmappen; her er pointen den samme: AI hjælper os at bygge hurtigt — produktværdien kommer af korrekt viden, tydelige grænser og en brugerflade, man tør stole på.\n","date":"27 May 2026","externalUrl":null,"permalink":"/llm-course-exam/kirppu-chatbot-semesterprojekt/","section":"LLM Course (Exam)","summary":"","title":"Kirppu og AI-kundeservice: fra spredt viden til chatbot og demo","type":"llm-course-exam"},{"content":"Assignment (5+6): Document an AI-driven application that calls an external API with an LLM.\nThis post describes the portfolio assistant: not a generic chat demo, but a real integration embedded in my Hugo site with backend, prompts, RAG, and structured error handling.\nApplication purpose # Visitors can ask questions about me, my work, and how to get in touch. The app:\nAccepts multi-turn chat in the browser Sends conversation history to my backend The backend calls OpenAI (external LLM API) Optionally augments prompts with retrieved context (RAG) Returns a text reply displayed in the chat UI That is an AI-driven feature inside a larger product (the portfolio), which matches the course definition of an LLM as a software component accessed over HTTP.\nArchitecture # ┌─────────────────┐ POST /chat ┌──────────────────┐ │ Hugo (static) │ JSON: messages, │ Node (Express) │ │ chat widget │ conversationId │ server/ │ └────────┬────────┘ ──────────────────────►└────────┬─────────┘ │ │ │ localStorage: conversations │ retrieveContext() │ │ system + messages │ ▼ │ ┌──────────────────┐ │ │ OpenAI API │ │ │ embeddings + │ │ │ chat completions│ └◄──────── { \u0026#34;reply\u0026#34;: \u0026#34;...\u0026#34; } ─────└──────────────────┘ Rule from the course: the API key stays on the backend only (OPENAI_API_KEY in server/.env).\nHow I set up the OpenAI API key # There is no separate “RAG API key”. I use one OpenAI API key on the server for both embeddings (retrieval) and chat completions (answers). The browser never sees that key.\n1. Create the key at OpenAI # I log in at platform.openai.com. I open API keys (under my account / dashboard). I click Create new secret key, give it a name (e.g. portfolio-chat), and copy the value once — it is only shown at creation time. I keep billing/usage limits in mind on the same account; both embedding and chat calls count toward usage. If the course or a future employer uses Azure OpenAI or another provider, the idea is the same: a secret on the server, and optionally OPENAI_BASE_URL in .env for a compatible endpoint.\n2. Store the key only in server/.env # I do not put the key in Hugo, JavaScript, or Git.\nIn the repo I go to the server/ folder. I copy the template: cp .env.example .env I edit .env and set: OPENAI_API_KEY=sk-proj-...your-key-here... OPENAI_MODEL=gpt-4o-mini PORT=8788 Optional variables I can add later:\nOPENAI_EMBEDDING_MODEL=text-embedding-3-small — for RAG vectors SYSTEM_PROMPT=... — override the assistant’s system message CORS_ORIGIN=https://portfolio.kudskprogramming.dk — restrict browser origins in production The file server/.env is listed in .gitignore, so it is not pushed to GitHub. Only server/.env.example (without a real key) is committed as documentation for myself and for anyone cloning the repo.\n3. Start the backend # From server/:\nnpm install npm start The API listens on port 8788 by default. On startup I watch the terminal: if RAG is enabled, I see a log line that chunks from rag-data/ were indexed. If the key is missing, POST /chat returns 503 with a message to configure .env — that is intentional so I notice misconfiguration early.\nI sanity-check with:\nGET http://localhost:8788/health A healthy response includes \u0026quot;modelConfigured\u0026quot;: true when OPENAI_API_KEY is set.\n4. Point the Hugo site at the API # In config/_default/params.toml I configure the public URL (no secret here):\n[chat] ragApiUrl = \u0026#34;http://localhost:8788/chat\u0026#34; showContactLinkWithRag = false For production I change this to my deployed API, for example:\nragApiUrl = \u0026#34;https://api.kudskprogramming.dk/chat\u0026#34; Hugo writes that value into the chat widget as data-chat-api-url. When I build the site (hugo or deploy), the static HTML knows where to send chat requests — but still not how to authenticate to OpenAI; that happens only inside Node using .env.\n5. Run the portfolio and test # I keep npm start running in server/. I run the site locally (hugo server or open the built public/ site). I open the homepage, click the chat button, and send a short question. The UI shows a typing indicator, then an assistant reply. In the network tab I see POST to /chat on my API, not to api.openai.com from the browser. If I stop the API or use a wrong key, the widget shows an error message in the chat instead of failing silently — that matches how I want visitors to experience outages.\nWhat I deliberately avoid # Mistake What I do instead Key in params.toml or front matter Key only in server/.env Key in jk-chat-widget.js Frontend only knows ragApiUrl Committing .env .gitignore + example file without secrets Sharing keys in blog posts or Moodle Describe the steps, never paste the real key My API endpoint # POST /chat\nRequest body (simplified):\n{ \u0026#34;conversationId\u0026#34;: \u0026#34;uuid-from-browser\u0026#34;, \u0026#34;messages\u0026#34;: [ { \u0026#34;role\u0026#34;: \u0026#34;user\u0026#34;, \u0026#34;content\u0026#34;: \u0026#34;What do you work with?\u0026#34; }, { \u0026#34;role\u0026#34;: \u0026#34;assistant\u0026#34;, \u0026#34;content\u0026#34;: \u0026#34;...\u0026#34; } ] } Response on success:\n{ \u0026#34;reply\u0026#34;: \u0026#34;Assistant message text\u0026#34; } Response on failure:\n{ \u0026#34;error\u0026#34;: \u0026#34;Human-readable error message\u0026#34; } The frontend uses fetch, shows a typing indicator, disables input while waiting, and surfaces API errors as assistant messages so the user is not stuck on a blank screen.\nPrompts # System prompt (role and rules) # Defined in code with an optional override via SYSTEM_PROMPT in .env. Default behaviour:\nAssistant for Jonathan Kudsk’s portfolio Prefer CONTEXT from RAG when answering factual questions Do not invent CV or project facts Suggest the contact form for serious inquiries User / assistant messages # The conversation history from the browser is forwarded as OpenAI messages (user and assistant turns). The latest user message also drives RAG retrieval (embedding + top chunks).\nThis follows the course split:\nSystem = role, limits, output behaviour User/assistant turns = dialogue and task data External LLM calls # The backend uses two OpenAI endpoints:\nCall Purpose POST /v1/embeddings RAG: chunk index + query embedding POST /v1/chat/completions Generate the reply (gpt-4o-mini by default) Model and base URL are configurable (OPENAI_MODEL, OPENAI_BASE_URL) for other providers that expose a compatible API.\nConfiguration on the static site # The Hugo side only needs the backend URL (see How I set up the OpenAI API key above). If ragApiUrl is left empty, the chat still opens but uses a short offline fallback text instead of calling the LLM — useful while I work on the site without spending API credits.\nError handling (course checklist) # Implemented at a basic but real level:\nMissing API key → 503 with clear message to configure .env OpenAI errors → parsed and returned as { \u0026quot;error\u0026quot;: \u0026quot;...\u0026quot; } Network / timeout → frontend abort after ~55s, user sees retry guidance Invalid JSON from model provider → 502 with short detail Empty reply → treated as error Not yet implemented (possible extensions): retries with backoff, job queue for long tasks, structured JSON output like the assignment assessor exercise.\nTesting # Manual tests I run:\nGET /health on the API — key configured, RAG enabled Open portfolio, send a question — reply uses portfolio tone Ask something not in rag-data — model should hesitate or say it is unsure Stop the API — frontend shows error path, not a crash New chat + switch chats — history in localStorage persists per conversation Relation to the larger exam project # The course also describes a separate exercise: assess a student report with a rubric and return structured JSON (overallAssessment, criteriaFeedback, etc.). That is a different endpoint and prompt design (POST /api/assess).\nThis portfolio app focuses on visitor chat + RAG. The integration patterns are the same: backend-owned key, system/user prompts, validate and handle failures.\nReflection # Building this app reinforced that “AI-driven” means engineering around the model: boundaries, secrets, retrieval, UX while waiting, and honest limits when context is missing. The LLM is one service in the stack — the product is the full path from button click to grounded answer.\nSee also: RAG chatbot post for retrieval details and the same key setup from a RAG-focused angle.\nMore detail on env variables lives in server/README.md in the repository.\n","date":"18 May 2026","externalUrl":null,"permalink":"/llm-course-exam/ai-driven-application/","section":"LLM Course (Exam)","summary":"","title":"An AI-driven application — calling an external LLM API","type":"llm-course-exam"},{"content":"","date":"27 May 2026","externalUrl":null,"permalink":"/tags/aida/","section":"Tags","summary":"","title":"AIDA","type":"tags"},{"content":"","date":"27 May 2026","externalUrl":null,"permalink":"/tags/dify/","section":"Tags","summary":"","title":"Dify","type":"tags"},{"content":"","date":"27 May 2026","externalUrl":null,"permalink":"/","section":"Home","summary":"","title":"Home","type":"page"},{"content":"","date":"27 May 2026","externalUrl":null,"permalink":"/tags/kirppu/","section":"Tags","summary":"","title":"Kirppu","type":"tags"},{"content":"Course deliverables for AI-drevne applikationer are documented here as blog posts. Each post matches an assignment on the learning platform and explains what was built, how it works, and what I learned.\nPosts are ordered by assignment number:\nSmall RAG chatbot on this portfolio RAG workflow and keeping knowledge in sync Using a code agent on this project AI-driven application with an external LLM API Kirppu AI-kundeservice (semesterprojekt, dansk) The live feature is the assistant chat on the homepage (floating button) when the chat API is running and configured in site params.\nAPI setup: The posts describe how I use an OpenAI API key in server/.env, connect Hugo via ragApiUrl, and keep secrets out of Git — see especially the AI-driven application post.\n","date":"27 May 2026","externalUrl":null,"permalink":"/llm-course-exam/","section":"LLM Course (Exam)","summary":"","title":"LLM Course (Exam)","type":"llm-course-exam"},{"content":"","date":"27 May 2026","externalUrl":null,"permalink":"/tags/rag/","section":"Tags","summary":"","title":"RAG","type":"tags"},{"content":"","date":"27 May 2026","externalUrl":null,"permalink":"/tags/semesterprojekt/","section":"Tags","summary":"","title":"Semesterprojekt","type":"tags"},{"content":"","date":"27 May 2026","externalUrl":null,"permalink":"/tags/","section":"Tags","summary":"","title":"Tags","type":"tags"},{"content":"","date":"18 May 2026","externalUrl":null,"permalink":"/tags/api/","section":"Tags","summary":"","title":"API","type":"tags"},{"content":"","date":"18 May 2026","externalUrl":null,"permalink":"/tags/integration/","section":"Tags","summary":"","title":"Integration","type":"tags"},{"content":"","date":"18 May 2026","externalUrl":null,"permalink":"/tags/llm/","section":"Tags","summary":"","title":"LLM","type":"tags"},{"content":"This project is a custom website platform for Krarup Hansen \u0026amp; Co., built to present architectural work in a structured and accessible way. It combines static content and JSON-based project data with a Java/Javalin server, making the site easier to maintain while keeping performance and routing under control.\nWhat the project includes # A Java 17 + Javalin backend that serves pages, handles custom routes, and maps image/static assets from bundled resources. A data-driven project system based on projects.json, including category filtering, search, sorting, project detail pages, and featured project sections. SEO-focused implementation across pages, including canonical links, Open Graph tags, structured data (JSON-LD), plus sitemap.xml and robots.txt. Result # The outcome is a maintainable production website with a clear content structure and improved discoverability, allowing the studio to present a large portfolio in a usable and search-friendly format.\n","date":"9 May 2026","externalUrl":"https://www.kh-co.dk","permalink":"/projects/krarup-hansen-co-website/","section":"Projects","summary":"A Java-powered portfolio site for an architecture studio with structured project data, interactive browsing, and production-ready SEO setup.","title":"Krarup Hansen \u0026 Co. Website","type":"projects"},{"content":"Here you can find selected projects and external links.\n","date":"9 May 2026","externalUrl":null,"permalink":"/projects/","section":"Projects","summary":"","title":"Projects","type":"projects"},{"content":"","date":"30 April 2026","externalUrl":null,"permalink":"/tags/code-agent/","section":"Tags","summary":"","title":"Code Agent","type":"tags"},{"content":"","date":"30 April 2026","externalUrl":null,"permalink":"/tags/cursor/","section":"Tags","summary":"","title":"Cursor","type":"tags"},{"content":"","date":"30 April 2026","externalUrl":null,"permalink":"/tags/workflow/","section":"Tags","summary":"","title":"Workflow","type":"tags"},{"content":"","date":"12 April 2026","externalUrl":null,"permalink":"/tags/portfolio/","section":"Tags","summary":"","title":"Portfolio","type":"tags"},{"content":"This project investigates whether Lionel Messi\u0026rsquo;s 2022 World Cup Player of the Tournament award can be explained by offensive performance data alone. It compares Messi and Kylian Mbappé using per-90 normalization, engineered features, weighted scoring, clustering, and model validation.\nThere is no public deployment for this work; the full notebook, Streamlit app, pipeline, and data live in the GitHub repository.\nWhat the project includes # A reproducible Python pipeline that filters players, merges World Cup CSV datasets, engineers V4 offensive features, and calculates weighted player scores. Analytical notebooks using EDA, KMeans clustering, Random Forest regression/classification, feature importance, and robustness checks with ARI and Monte Carlo weight perturbation. A Streamlit dashboard for exploring top players, score decomposition, cluster membership, and direct Messi vs. Mbappé comparisons. Result # The analysis produced a structured BI workflow showing how offensive data can support or challenge football narratives, with Mbappé ranking highly on the project\u0026rsquo;s offensive metrics while Messi and Mbappé remain comparable elite profiles in the clustering analysis.\n","date":"23 March 2026","externalUrl":"https://github.com/JonathanKudsk/BI-Exam-VM2022","permalink":"/projects/data-vs-narrative-messi-vs-mbappe/","section":"Projects","summary":"BI and ML analysis of Messi vs. Mbappé at the 2022 World Cup.","title":"Data vs. Narrative: WC 2022","type":"projects"},{"content":"Cook \u0026amp; Recipe is a Vite + React single-page frontend for a recipe domain backed by a REST API. It gives users a clean way to browse recipe data, open detailed ingredient views, and authenticate into protected areas. The app also includes role-aware navigation and an admin-only section for user management.\nWhat the project includes # Recipe listing with category filters, detail modal views, and expandable nutrition info per ingredient. Authentication flow with login/registration, JWT handling, route guards, and role-based navigation. Admin page for user management, including role assignment/removal and user deletion with UI feedback. Result # The project delivers a structured, production-style frontend that combines API integration, protected routes, and modular UI components into a coherent recipe management experience.\n","date":"13 January 2026","externalUrl":"https://recipeapi.kudskprogramming.dk","permalink":"/projects/cook-and-recipe-frontend/","section":"Projects","summary":"Recipe app frontend with category filtering, auth flow, and admin user controls.","title":"Cook \u0026 Recipe Frontend","type":"projects"},{"content":"This project is a backend service built with Javalin, Hibernate, and PostgreSQL to support recipe and ingredient management. It separates read and write access with role-based authorization and includes structured error handling for common API failure cases. The codebase is organized with DTOs, DAOs, route groups, and security modules to keep responsibilities clear.\nWhat the project includes # CRUD endpoints for recipes and ingredients, including filtering and recipe-ingredient relationship management. JWT authentication and role-based route protection, with public read endpoints and protected write operations. Automated test coverage for both API integration and DAO layers, using RestAssured and Testcontainers. Result # The project delivers a deployable, documented REST API with clear domain modeling, secure endpoint access, and CI/CD packaging via Maven and Docker for consistent delivery.\n","date":"15 December 2025","externalUrl":"https://recipeapi.kudskprogramming.dk/api/routes","permalink":"/projects/recipe-management-api/","section":"Projects","summary":"A production-oriented recipe API with CRUD endpoints, JWT-based security, and tested data access flows.","title":"Recipe Management API","type":"projects"},{"content":"","externalUrl":null,"permalink":"/authors/","section":"Authors","summary":"","title":"Authors","type":"authors"},{"content":"","externalUrl":null,"permalink":"/categories/","section":"Categories","summary":"","title":"Categories","type":"categories"},{"content":" About me # I\u0026rsquo;m Jonathan — a datamatiker student at Erhvervsakademi København (4th semester), with a strong pull toward backends, data, and web work that still makes sense six months after ship day.\nI like understanding systems end-to-end—where an API stops being “fine” and starts being reliable, how data should flow before it hurts, and why something behaves differently in prod than on your laptop at 16:55 on a Friday.\nHow I work # I learn fastest hands-on: projects, deadlines, and real constraints beat slides alone. I\u0026rsquo;m comfortable owning a feature from rough idea to something teammates can build on—especially when that means clear boundaries, sensible defaults, and tests where they actually earn their keep.\nAlongside my studies I work Fridays at an architecture studio (IT support + helping ship their company website). It\u0026rsquo;s been a good reminder that clarity—for users and colleagues—counts as much as clever code.\nOutside the keyboard # Side projects, stubborn bugs, and the occasional humbling moment when the bug was mine. I recharge by building small things, breaking them on purpose, and putting them back together a little cleaner.\nYou’ll find me on GitHub and LinkedIn above—say hi there, or drop a line through the form when it’s wired to my backend.\n","externalUrl":null,"permalink":"/contact/","section":"Home","summary":"","title":"Contact","type":"page"},{"content":"","externalUrl":null,"permalink":"/series/","section":"Series","summary":"","title":"Series","type":"series"},{"content":"I am a datamatiker student at Erhvervsakademi København (4th semester), focused on backend systems, databases, and data analysis. I enjoy structured problem solving and building stable solutions end-to-end — from data models and APIs to deployment.\nTechnical skills # Languages \u0026amp; platforms\nJava — backend and full-stack style work in IntelliJ (APIs, application structure, algorithms). Python — data analysis and notebooks (Spyder, Jupyter Notebook), pipelines and analytical workflows. Web — HTML, CSS, JavaScript, React (VS Code). SQL \u0026amp; databases — PostgreSQL, SQL, SQLite, schema-driven design and data handling. Backend \u0026amp; integration\nREST-style APIs, service-oriented structuring, and integration with frontends. Experience with Jetty Server, Docker, and deployment on Digital Ocean. Practical database work including modeling, querying, and persistence-focused features. Data \u0026amp; analysis\nExploratory analysis, feature-oriented thinking, and analytical notebooks (coursework and projects aligned with BI/ML workflows). Tools \u0026amp; workflow # Git / GitHub, Kanban-style planning, structured iteration. IntelliJ, VS Code, Jupyter, Spyder. AI tools: I use Cursor, GitHub Copilot, ChatGPT, and Claude as part of a normal developer workflow — boilerplate, exploration, explaining errors, and brainstorming approaches — not as a substitute for understanding the code. I treat suggestions critically, test behaviour, and ship only what I can explain and maintain. Languages (spoken) # Danish — fluent English — fluent Spanish — basic (limited conversational ability; not fluent) Experience highlights # IT support \u0026amp; web development — Krarup Hansen \u0026amp; Co. Arkitekter M.A.A. (Oct 2025–present, Fridays)\nHands-on IT support for non-technical colleagues: troubleshooting, clear explanations, and stakeholder-friendly communication. Building and evolving the studio website with HTML, CSS, and JavaScript; deployed March 2026. This combination reflects how I like to work: technical depth where it matters, and clarity when collaborating with people who are not developers.\n","externalUrl":null,"permalink":"/skills/","section":"Skills","summary":"","title":"Skills","type":"skills"}]