Add advisors module: custom logging, PII redaction and token-budget advisors

Tests pin down chain ordering (including ties), BaseAdvisor stream behaviour, redaction order versus memory and logging, the tool loop, and how a refusal surfaces on calls, streams and over HTTP.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Ja4jkzrbQ4LQZBNrb5mkZE
This commit is contained in:
Claude
2026-09-24 16:21:19 +00:00
parent 823f6fac5b
commit 527f4ba7ff
59 changed files with 2805 additions and 0 deletions
+65
View File
@@ -0,0 +1,65 @@
# advisors
Companion code for [Writing Custom Advisors in Spring AI 2.0: Logging, PII Redaction and Token Budgets](https://ankurm.com/spring-ai-2-0-custom-advisors-logging-pii-redaction-token-budgets/), part of the [Spring AI series](../README.md) on ankurm.com.
Three advisors of our own -- a logger, a PII redactor and a token budget -- plus the tests that pin down how the advisor chain orders them, what happens on a stream, and what a caller sees when one of them refuses a request.
There is no live model anywhere. [`RecordingModel`](src/test/java/com/ankurm/advisors/support/RecordingModel.java) records every prompt it is sent and answers with a fixed sentence, which is exactly what an advisor test needs: *what reached the model, and what came back*. No database, no Docker, no API key.
## Versions
| Component | Version |
|---|---|
| Spring Boot | 4.1.1 |
| Spring AI | 2.0.1 (`spring-ai-client-chat` 2.0.1) |
| Reactor Core | 3.8.7 |
| JTokkit (token estimates) | 1.1.0 |
| Java | 25 (LTS) |
## Quickstart
```bash
scripts/run-all.sh # runs all 21 tests and regenerates output/01 .. 21
```
Two consecutive runs produce byte-identical files. To run the app itself: `OPENAI_API_KEY=... mvn spring-boot:run`, then `POST /chat` with an `X-User` header and `{"text": "..."}`.
## What's here
| File | What it shows |
|---|---|
| [`advisor/Orders.java`](src/main/java/com/ankurm/advisors/advisor/Orders.java) | The order numbers, and the layers they sit between (memory +200, tool loop +300, model last) |
| [`advisor/LoggingAdvisor.java`](src/main/java/com/ankurm/advisors/advisor/LoggingAdvisor.java) | Request / response / stream / failure lines; content off by default |
| [`advisor/PiiRedactor.java`](src/main/java/com/ankurm/advisors/advisor/PiiRedactor.java) | Email, Luhn-checked card number and phone patterns; `<EMAIL_1>` placeholders |
| [`advisor/PiiRedactionAdvisor.java`](src/main/java/com/ankurm/advisors/advisor/PiiRedactionAdvisor.java) | Redact on the way in, restore on the way out, on calls and on streams (buffers a split placeholder) |
| [`advisor/TokenBudgetAdvisor.java`](src/main/java/com/ankurm/advisors/advisor/TokenBudgetAdvisor.java) | Per-request and per-user limits, checked before the model is called |
| [`advisor/Texts.java`](src/main/java/com/ankurm/advisors/advisor/Texts.java) | Rewriting request and response text with the `mutate()` copies |
| [`config/AdvisorConfig.java`](src/main/java/com/ankurm/advisors/config/AdvisorConfig.java) | The advisors as beans and one `ChatClient` that uses them with Spring AI's memory advisor |
| [`web/ChatController.java`](src/main/java/com/ankurm/advisors/web/ChatController.java) | `POST /chat`; the user comes from a header only so the tests need no login |
## Output files
Every file is written by the test named in the right column, and every console block in the article is quoted from one of them.
| File | Written by |
|---|---|
| `01-chain-order.txt`, `02-chain-order-ties.txt`, `03-chain-contents.txt` | `ChainOrderTest` |
| `04-base-advisor-stream.txt` | `BaseAdvisorStreamTest` |
| `05-logging-advisor.txt`, `06-logging-failure.txt` | `LoggingAdvisorTest` |
| `07-pii-redaction.txt`, `08-pii-limits.txt`, `09-pii-stream-boundary.txt` | `PiiRedactionTest` |
| `10-pii-order.txt` | `PiiOrderTest` |
| `11-pii-multi-turn.txt` | `PiiMultiTurnTest` |
| `12-token-budget.txt`, `13-token-budget-stream.txt`, `14-token-budget-stream-refusal.txt` | `TokenBudgetTest` |
| `15-tool-loop-order.txt` | `ToolLoopOrderTest` |
| `16-error-propagation.txt` | `ErrorPropagationTest` |
| `17-context.txt` | `ContextAndImmutabilityTest` |
| `18-unit-test-no-model.txt` | `UnitTestingWithoutModelTest` |
| `19-web-429.txt` | `AdvisorWebTest` |
| `20-built-in-orders.txt` | `BuiltInOrdersTest` |
| `21-tool-loop-budget.txt` | `ToolLoopBudgetTest` |
`TokenBudgetTest.aStreamIsRefusedAsAnErrorSignal` makes Spring AI's `MessageAggregator` log an `ERROR ... Aggregation Error` stack trace on the console. That is the refusal being reported, not a test failure.
## Requirements
JDK 25 and Maven.