# Adding source links to the AI assistant's answers

The "Ask about Patrick" assistant answers from my blog articles and from my CV, but it never showed where an answer came from. This is how it got source links, and why the part I expected to be easy was not.

## Article links: the Worker builds the URL

`POST /ask` now returns a `source` field next to the answer, built from the top retrieved chunk:

```json
{ "answer": "...", "source": { "type": "article", "slug": "...", "url": "https://blog.koorevaar.com/articles/..." } }
```

- **Code builds the URL, not the model.** The chunk carries a real `sourceSlug`. A small model asked to write a link will eventually invent one, and I had already seen it cite the internal `[2]` labels of its context block.
- **A separate field, not appended text.** The widget renders plain text, so a URL in the answer would not be clickable, and it would leak into the fact box of recent answers.
- **A score floor (0.6)**, and no link for a greeting or a refusal. A missing link costs nothing, a link under "hi" looks broken.

## The CV answers need a section, not an article

Most questions are answered by the CV article, which is exported from the homepage. There is no page to send a visitor to, and the assistant already sits on the homepage, so the useful link is a `#` anchor to the right section.

## Why retrieval could not pick the section

The CV is chunked per paragraph, so a chunk has no trace of its heading. I stamped a `sectionAnchor` on each chunk during the sync and let the Worker return it. It did not work:

- A heading-only chunk, `### "Ask about Patrick" chat assistant`, is the top hit for almost any question containing "Patrick". It scored 0.62 for "What certifications does Patrick have?" and pointed at the AI section.
- The right chunk for "production AKS clusters" scored 0.593 and fell under the 0.6 floor.

I tried voting across the top five chunks (link only when two agree), then letting a profile fact take part in the vote. Both were reasonable and neither fixed the AKS question. I could only replay the retrieval on my own machine and could not see the scores the live Worker computed, so every threshold change was partly a guess.

## What worked: match on the page

The widget scores the question and the answer against the homepage's own items (the 49 cards, skill groups and certifications already marked up for the `about.md` export):

- idf-weighted terms, so rare words count more; a term in a card's title counts double;
- a link only when the best card clearly beats the best card of another section;
- collapsed panels and ties across a section point at the section instead of one card;
- the link names its target and scrolls to it, highlighting the card.

No model call, no sync step, and it cannot go stale because it reads the live page.

The Worker's voting is still there, as the last resort. The widget decides in this order: the Worker's article link, then a clear page match, then the Worker's voted section, otherwise no link.

![Sequence diagram: the Worker returns the answer with an optional article link; the widget shows the article link if there is one, and otherwise searches the homepage](https://images.koorevaar.com/diagrams/assistant-source-links.svg)

The page is English and the assistant answers in Dutch too, so I added a hidden `data-kw` attribute with Dutch terms and synonyms to six sections and six cards. It is not visible, not exported, and the CI check ignores it.

| Question | Link |
|---|---|
| Production AKS clusters | Production AKS at scale |
| What certifications does Patrick have? | Certifications section |
| Welke vaardigheden heeft Patrick? | Skills section |
| "hi", "what is the time?" | none |

Several close candidates (for example "Hoe werkt Patrick met AI?") give no link on purpose.

## Who decided what

Claude Code proposed the Worker-built link, the section stamping and the voting mechanism. But I did not simply accept its first proposal. I noticed that the largest group of answers had no link, required a local anchor instead of a homepage link, and asked for alternative approaches before applying the next fix. That led to adding page search, Dutch keywords, and a voting-plus-client-side-search fallback. I also chose to try the small fix first, using the larger change only if needed.

The thresholds come from about a dozen test questions, not real traffic, so they will move.

---

*Co-authored with Claude.*

If this was useful, [you can buy me a coffee](https://ko-fi.com/p47k0).

New articles: [follow via RSS](https://blog.koorevaar.com/feed.xml).
