Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c1a6b9730f | |||
| a3b2e7dd27 | |||
| 5f616d3d99 | |||
| a3f40d1408 | |||
| 5b13ec7713 | |||
| 7940a8fc66 | |||
| 5cfa580997 | |||
| aee991bb7b | |||
| e0d7c289a8 | |||
| c5d8f44bf2 | |||
| 88fe9c9695 | |||
| 6e0167cff1 | |||
| 62497556a5 | |||
| dba64df917 | |||
| 0f3bdf209d | |||
| 13fb87fb8b | |||
| 9ffcf816ea | |||
| 3d2150d2a6 | |||
| 4f427659bb | |||
| be1f31778a | |||
| 2d867cefca | |||
| 3f9e80047c |
@@ -20,6 +20,7 @@ All player-facing text is Portuguese (pt-BR).
|
|||||||
| `enquete` | `/enquete Pergunta \| A \| B` — clickable voting with a live tally on the boss bar. |
|
| `enquete` | `/enquete Pergunta \| A \| B` — clickable voting with a live tally on the boss bar. |
|
||||||
| `ranking` | `/ranking mineracao` and friends. Covers **offline players too**. |
|
| `ranking` | `/ranking mineracao` and friends. Covers **offline players too**. |
|
||||||
| `marcos` | Announces round milestones — 100 km walked, 24 hours played — the first time someone crosses one. |
|
| `marcos` | Announces round milestones — 100 km walked, 24 hours played — the first time someone crosses one. |
|
||||||
|
| `ia` | `/ia <pergunta>` asks an OpenAI-compatible model in chat. Optional wiki grounding, per-player memory, operator corrections. |
|
||||||
|
|
||||||
Toggle any of them: `/canalhandia modulo <nome> <on|off>`
|
Toggle any of them: `/canalhandia modulo <nome> <on|off>`
|
||||||
|
|
||||||
@@ -184,6 +185,10 @@ survive a restart.
|
|||||||
| `canalhandia.forcar` | op | trigger curiosities and guess rounds |
|
| `canalhandia.forcar` | op | trigger curiosities and guess rounds |
|
||||||
| `canalhandia.admin` | op | change modules and all settings |
|
| `canalhandia.admin` | op | change modules and all settings |
|
||||||
| `canalhandia.isento` | nobody | never be the subject |
|
| `canalhandia.isento` | nobody | never be the subject |
|
||||||
|
| `canalhandia.ia` | op | `/ia` (public question, broadcast to chat) |
|
||||||
|
| `canalhandia.ia.privado` | op | `/iap` (private question, answer only to the asker) |
|
||||||
|
| `canalhandia.ia.corrigir` | op | `/ia corrigir <resposta>` — register a correction for the last answer |
|
||||||
|
| `canalhandia.ia.perfil` | op | `/ia perfil <economico\|preciso>` — switch profile live |
|
||||||
|
|
||||||
Permissions are **declared explicitly** in `plugin.yml`. An undeclared Bukkit
|
Permissions are **declared explicitly** in `plugin.yml`. An undeclared Bukkit
|
||||||
permission falls back to op-only, which would silently stop normal players from
|
permission falls back to op-only, which would silently stop normal players from
|
||||||
@@ -191,6 +196,79 @@ reacting.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## IA (`/ia`)
|
||||||
|
|
||||||
|
Chat Q&A backed by an OpenAI-compatible endpoint (default MiniMax). Gated to
|
||||||
|
operator + LuckPerms-permitted players; both `/ia` and `/iap` default to op and
|
||||||
|
are granted independently, so an operator can let someone ask privately without
|
||||||
|
letting them broadcast.
|
||||||
|
|
||||||
|
**The model can only ever produce chat text.** No tool/function definitions are
|
||||||
|
sent in the request, the reply is passed to `sendMessage` and nowhere else, and
|
||||||
|
`AiText.sanitise` strips leading slashes so a reply cannot be mistaken for a
|
||||||
|
command. A player asking it to "run `/op me`" gets a string back, not an
|
||||||
|
executed command.
|
||||||
|
|
||||||
|
### Commands
|
||||||
|
|
||||||
|
| Command | What it does |
|
||||||
|
|---|---|
|
||||||
|
| `/ia <pergunta>` | Asks the model. Public by default — the question and answer broadcast. Needs `canalhandia.ia`. |
|
||||||
|
| `/iap <pergunta>` | Asks privately — the answer goes only to the asker. Needs `canalhandia.ia.privado`. |
|
||||||
|
| `/ia perfil <economico\|preciso>` | Switches profile live. `ECONOMICO` skips the wiki (fast); `PRECISO` consults the Minecraft Wiki (slower, grounded). Needs `canalhandia.ia.perfil`. |
|
||||||
|
| `/ia corrigir <resposta correta>` | Records a correction for the last answered question. Future similar questions get it as context — the cheap alternative to fine-tuning. Needs `canalhandia.ia.corrigir`. |
|
||||||
|
| `/ia feedback ruim` | Flags the last answer wrong (in-memory counter shown in `/canalhandia status`). |
|
||||||
|
| `/errado` | The `[ERRADO]` reaction to the last message — the typed-twin of the reaction button, for Bedrock players. |
|
||||||
|
|
||||||
|
Subcommands only hijack when their second token is one they act on (a known
|
||||||
|
profile key, or `ruim`), so `/ia perfil do servidor` falls through and is asked.
|
||||||
|
`corrigir` stays greedy — a correction always reads the rest of the line.
|
||||||
|
|
||||||
|
### Profile
|
||||||
|
|
||||||
|
`ECONOMICO` skips the wiki round trip — fast, ungrounded. `PRECISO` runs a
|
||||||
|
forced tool call to pick a wiki term, looks it up on pt.minecraft.wiki, and
|
||||||
|
injects the article as context. Switch live with `/ia perfil`; the choice is
|
||||||
|
read per-question, so it takes effect immediately.
|
||||||
|
|
||||||
|
### Operator corrections
|
||||||
|
|
||||||
|
`/ia corrigir <resposta>` appends to `plugins/Canalhandia/correcoes.yml`. When a
|
||||||
|
new question shares at least one significant word (length > 4) with a recorded
|
||||||
|
correction, the correction is injected as system context. Pure string matching,
|
||||||
|
no model round trip.
|
||||||
|
|
||||||
|
### Memory and context
|
||||||
|
|
||||||
|
- **Per-player memory**: the last `memoria-perguntas` exchanges within
|
||||||
|
`memoria-minutos` are replayed, for follow-ups like "e no nether?". Forgotten
|
||||||
|
on quit. Baked at construction; not hot-swappable.
|
||||||
|
- **Server context**: the `contexto:` list in `config.yml` is facts the model
|
||||||
|
would never know (server name, Bedrock prefix, installed mods). Sent on every
|
||||||
|
question.
|
||||||
|
- **Recipes**: `RecipeBook` snapshots `Bukkit.recipeIterator()` at enable (main
|
||||||
|
thread) and answers recipe questions from that snapshot — `explaintext` drops
|
||||||
|
tables, so the wiki cannot supply them.
|
||||||
|
|
||||||
|
### Keys and limits
|
||||||
|
|
||||||
|
The API key never lives in `config.yml` (committed to git). Read from the
|
||||||
|
`MINIMAX_API_KEY` env var, or `plugins/Canalhandia/minimax.key` (one line,
|
||||||
|
printable ASCII only — control chars are stripped so a stray newline can't
|
||||||
|
land the key in a server-log header exception).
|
||||||
|
|
||||||
|
Per-player cooldown (`cooldown-segundos`), a server-wide daily cap
|
||||||
|
(`limite-diario`), and a one-question-at-a-time guard per player keep the
|
||||||
|
token spend bounded. `canalhandia.admin` skips the cooldown.
|
||||||
|
|
||||||
|
The four values `url`, `wiki-caracteres`, `memoria-perguntas` and
|
||||||
|
`memoria-minutos` are baked at construction. Everything else — `modelo`,
|
||||||
|
`max-tokens`, `temperatura`, `instrucoes`, `perfil`, `contexto`, the limits —
|
||||||
|
is read live, so operators can hot-swap them with `/canalhandia reload` or the
|
||||||
|
`/ia perfil` command without a restart.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
Requires **JDK 25**. Paper 26.2's API ships Java 25 class files, and JDK 21 fails
|
Requires **JDK 25**. Paper 26.2's API ships Java 25 class files, and JDK 21 fails
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,218 @@
|
|||||||
|
# Design — grounding and feedback for the `/ia` module
|
||||||
|
|
||||||
|
**Date:** 2026-08-05
|
||||||
|
**Status:** approved
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
`/ia` shipped earlier today and was used five times by real players. Every
|
||||||
|
substantive answer contained an error:
|
||||||
|
|
||||||
|
| asked | answered | reality |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| how to get a camel | spawns in desert villages "and badlands" | badlands is wrong; never said a saddle is involved |
|
||||||
|
| crossing the nether roof | "boat on soul sand, water on top, push the boat" | invented; not a technique |
|
||||||
|
| best mob farm spot | "Y 0–64, mobs spawnam mais *concentrated there*" | dubious, and English leaked into the Portuguese |
|
||||||
|
|
||||||
|
The model states Minecraft mechanics confidently and incorrectly. No amount of
|
||||||
|
prompt tuning fixes that, because the model does not know what it does not
|
||||||
|
know. It needs sources.
|
||||||
|
|
||||||
|
Two further defects were found while measuring:
|
||||||
|
|
||||||
|
- **Empty answers.** M2.7 emits hidden reasoning that counts against
|
||||||
|
`max_tokens`. At the deployed value of 300 it returned empty content twice in
|
||||||
|
testing. Players see "Não consegui resposta agora" and assume the feature is
|
||||||
|
broken.
|
||||||
|
- **Foreign-token leakage.** Replies contained `搭档` (Chinese) and
|
||||||
|
`contiennent` (French) mid-sentence.
|
||||||
|
|
||||||
|
## What was measured
|
||||||
|
|
||||||
|
Everything below is measured against the live MiniMax key, not assumed.
|
||||||
|
|
||||||
|
### Retrieval source
|
||||||
|
|
||||||
|
`pt.minecraft.wiki` answers in Portuguese, needs no API key, and responds in
|
||||||
|
~0.3s. Its camel article contains the exact fact the model missed. It returns
|
||||||
|
**HTTP 403 to a default user agent** — an identifying `User-Agent` is required.
|
||||||
|
|
||||||
|
### How to choose the search term
|
||||||
|
|
||||||
|
MediaWiki cannot parse conversational Portuguese, so the raw question is
|
||||||
|
useless as a query. Four methods, same questions:
|
||||||
|
|
||||||
|
| method | usable terms |
|
||||||
|
| --- | --- |
|
||||||
|
| raw question | 0 / 6 |
|
||||||
|
| local stopword strip | 1 / 6 |
|
||||||
|
| free-text extraction call | 2 / 7 |
|
||||||
|
| **tool call with forced `tool_choice`** | **5 / 5** |
|
||||||
|
|
||||||
|
Free-text extraction fails for the same reason answers came back empty: hidden
|
||||||
|
reasoning eats a small `max_tokens` budget. A tool call returns a *structured*
|
||||||
|
argument, which survives. Optional tool choice is not enough — with
|
||||||
|
`tool_choice: auto` the model skipped the search on the hardest question, the
|
||||||
|
one it had already answered wrong twice. Forcing the call fixes that.
|
||||||
|
|
||||||
|
### How much of the article to send
|
||||||
|
|
||||||
|
`exintro` returns only the lead paragraph. Grounded on intros alone, both
|
||||||
|
models honestly answered "não tenho certeza" to questions they had previously
|
||||||
|
answered correctly — truthful but useless. Sending the **full article text**
|
||||||
|
(~7k chars) produced correct answers for camels, mob farms and creeper drops.
|
||||||
|
|
||||||
|
### Which model
|
||||||
|
|
||||||
|
M2.7 and M3, both grounded on full articles:
|
||||||
|
|
||||||
|
| | tokens | outcome |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **M2.7** | 6647 | correct on camel, mob farm, creeper |
|
||||||
|
| M3 | 7749 | refused the mob farm question M2.7 answered well |
|
||||||
|
|
||||||
|
M2.7 is chosen. M3 costs ~17% more and refuses more. Ungrounded, M3 also
|
||||||
|
invented "poeira de guncotton" as a creeper drop.
|
||||||
|
|
||||||
|
### The gap wiki text cannot close
|
||||||
|
|
||||||
|
`explaintext` strips tables, and brewing and crafting recipes live in tables.
|
||||||
|
The fire-resistance article is only 1086 characters for this reason, and the
|
||||||
|
recipe question failed even fully grounded.
|
||||||
|
|
||||||
|
Crafting and brewing close differently, and an earlier draft of this document
|
||||||
|
was wrong to treat them as one problem.
|
||||||
|
|
||||||
|
**Crafting comes from the running server.** `Bukkit.recipeIterator()` is
|
||||||
|
authoritative for this exact version, free and instant. It is read through
|
||||||
|
`getChoiceMap()` / `getChoiceList()`; the older `getIngredientMap()` /
|
||||||
|
`getIngredientList()` are deprecated and collapse a choice to one arbitrary
|
||||||
|
stack, which prints "oak planks" where the recipe really accepts any plank.
|
||||||
|
|
||||||
|
**Item names are translated through the wiki.** `Material` names are English
|
||||||
|
and players ask in Portuguese, and the two do not meet on their own: scanning
|
||||||
|
every material name for a substring of the question was measured over twenty
|
||||||
|
realistic pt-BR questions and resolved **1 of 20** — and that one, "tridente"
|
||||||
|
containing "trident", by coincidence rather than translation. So the question's
|
||||||
|
subject is searched on the pt wiki and the article's `prop=langlinks&lllang=en`
|
||||||
|
gives the English title, which uppercases onto the enum: "Espada de Diamante"
|
||||||
|
→ "Diamond Sword" → `DIAMOND_SWORD`. The same twenty questions now resolve
|
||||||
|
**17 of 20**. Articles without an English link yield no grounding rather than
|
||||||
|
a guess.
|
||||||
|
|
||||||
|
The match is exact, plus a small hand-checked alias table ("Redstone Repeater"
|
||||||
|
→ `REPEATER`, "Book and Quill" → `WRITABLE_BOOK`). There is deliberately no
|
||||||
|
fuzzy fallback. Resolving to the longest material name contained in the title
|
||||||
|
was tried and returned confidently wrong items: "Redstone Repeater" tied on
|
||||||
|
length and gave `REDSTONE`, so a player asking about repeaters got the recipe
|
||||||
|
for redstone dust; "Minecart with Chest" gave `MINECART`; "Rabbit's Foot" gave
|
||||||
|
`RABBIT`, the raw meat. A suffix rule fails identically — "axe" is a suffix of
|
||||||
|
"pickaxe". Colour and material families (`Bed`, `Wool`) therefore resolve to
|
||||||
|
null, which is correct: a plausible recipe for the wrong item is the failure
|
||||||
|
this feature exists to remove, and it is worse than no answer.
|
||||||
|
|
||||||
|
**Brewing is not exposed by Bukkit at all, so potions use a hardcoded table.**
|
||||||
|
Checked against the 26.2 API: there is no brewing `Recipe` implementation;
|
||||||
|
`PotionBrewer` has `addPotionMix` / `removePotionMix` / `resetPotionMixes` but
|
||||||
|
no getter and no iterator; and vanilla brewing is hardcoded in `PotionBrewing`
|
||||||
|
rather than registered as a recipe, so `recipeIterator()` never yields it. The
|
||||||
|
server genuinely cannot supply this. `RecipeBook.BREWING` therefore lists the
|
||||||
|
~17 base potions in pt-BR by hand. This is the part that actually answers the
|
||||||
|
fire-resistance question that motivated the feature — `recipeIterator()` alone
|
||||||
|
would never have fixed it.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- **No restart now.** The jar is built and deployed dormant; it goes live at
|
||||||
|
the next restart.
|
||||||
|
- **Cost is not a constraint.** The MiniMax Plus plan allows 4.5–12M tokens per
|
||||||
|
5 hours. At ~2000 tokens per grounded question that is ~1800 questions per
|
||||||
|
window, which a five-player server will never approach. `limite-diario`
|
||||||
|
stays, but as chat-spam protection, not spend control.
|
||||||
|
- **Latency is the real budget.** Answers already take 4–6s. Every added
|
||||||
|
context token makes chat feel slower.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
/ia <pergunta>
|
||||||
|
│
|
||||||
|
├─ permission · module · cooldown · daily cap (existing)
|
||||||
|
│
|
||||||
|
├─ context assembly
|
||||||
|
│ contexto.yml always server facts, hand written
|
||||||
|
│ correcoes.yml on keyword match operator corrections
|
||||||
|
│ recipe lookup recipe questions server registry + wiki langlinks;
|
||||||
|
│ potions from a hardcoded table
|
||||||
|
│ wiki article PRECISO only forced tool call → pt.minecraft.wiki
|
||||||
|
│ last 3 exchanges same player, 10 min window
|
||||||
|
│
|
||||||
|
├─ MiniMax-M2.7, max_tokens 1200
|
||||||
|
│
|
||||||
|
└─ sanitise → chat, with reaction buttons
|
||||||
|
```
|
||||||
|
|
||||||
|
Both HTTP calls run on the async thread that already exists; only delivery hops
|
||||||
|
back to the main thread.
|
||||||
|
|
||||||
|
**Caching.** Wiki articles cached in memory by title, 200 entries, 6h TTL.
|
||||||
|
Five people asking about creepers costs one fetch. Lost on restart, which is
|
||||||
|
fine.
|
||||||
|
|
||||||
|
**Profiles.** `ia.perfil: ECONOMICO | PRECISO`, switched live with
|
||||||
|
`/ia perfil <nome>`, no restart. ECONOMICO skips the wiki round trip. Its
|
||||||
|
purpose is latency, not cost.
|
||||||
|
|
||||||
|
## Safety
|
||||||
|
|
||||||
|
The boundary is structural, not prompt-level, and it does not weaken by adding
|
||||||
|
a tool:
|
||||||
|
|
||||||
|
- **Tools may only read.** Never write, never execute, never touch game state.
|
||||||
|
`buscar_wiki` performs one HTTPS GET against a hardcoded host with a
|
||||||
|
URL-encoded term. The model chooses an *argument*, never an operation.
|
||||||
|
- The reply goes to `sendMessage` and nowhere else. It never reaches the
|
||||||
|
command dispatcher.
|
||||||
|
- `sanitise()` strips `§` codes, markdown, emoji and leading slashes.
|
||||||
|
|
||||||
|
A player asking the model to run `ls` or `/give` gets a sentence back. There is
|
||||||
|
no function that could do otherwise.
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
| failure | behaviour |
|
||||||
|
| --- | --- |
|
||||||
|
| empty content | retry once at a higher token ceiling, then apologise |
|
||||||
|
| reply contains a non-Latin script (CJK, Cyrillic, Arabic, …) | discard, retry once |
|
||||||
|
| wiki 403 / timeout / no hit | answer without the article, and say the wiki was not consulted |
|
||||||
|
| MiniMax non-zero `base_resp` | log and apologise; HTTP 200 does not mean success |
|
||||||
|
| tool call absent despite forcing | fall back to answering ungrounded |
|
||||||
|
|
||||||
|
Known gap: the foreign-script check works by alphabet, so it only catches
|
||||||
|
non-Latin scripts. Latin-script leakage — the observed French `contiennent` in
|
||||||
|
an otherwise Portuguese answer — passes straight through, and this design does
|
||||||
|
not close that. Catching it would need dictionary or language-identification
|
||||||
|
work that is out of scope here.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Privacy per question.** `/ia` public, `/iap` visible only to the asker.
|
||||||
|
Operators can force the module private in config.
|
||||||
|
- **Reactions** on answers, reusing the existing `Reactions` class so Bedrock
|
||||||
|
gets typed equivalents.
|
||||||
|
- **Correction loop.** The asker marks an answer wrong; an operator writes the
|
||||||
|
right answer with `/ia corrigir`; it is stored in `correcoes.yml` and
|
||||||
|
injected on similar future questions. No fine-tuning, no extra API cost.
|
||||||
|
- **New permissions.** `canalhandia.ia.privado`, `canalhandia.ia.corrigir`,
|
||||||
|
`canalhandia.ia.perfil`.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Unit: `sanitise()` against markdown, emoji, `§`, leading slashes, CJK.
|
||||||
|
- Unit: term extraction from a stubbed tool response; recipe lookup for a known
|
||||||
|
item.
|
||||||
|
- Integration, against the live key: the five questions in this document, with
|
||||||
|
the camel, mob farm and creeper answers checked for the specific facts they
|
||||||
|
previously got wrong.
|
||||||
|
- Live: after the next restart, confirm `/canalhandia status` reports the
|
||||||
|
profile and that a real question is grounded.
|
||||||
@@ -22,6 +22,21 @@
|
|||||||
</repository>
|
</repository>
|
||||||
</repositories>
|
</repositories>
|
||||||
|
|
||||||
|
<dependencyManagement>
|
||||||
|
<dependencies>
|
||||||
|
<!-- The BOM keeps the jupiter engine and the platform launcher on matching
|
||||||
|
versions. Left to transitive resolution they drift apart, and a mismatched
|
||||||
|
launcher fails to discover tests rather than failing the build. -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.junit</groupId>
|
||||||
|
<artifactId>junit-bom</artifactId>
|
||||||
|
<version>5.11.3</version>
|
||||||
|
<type>pom</type>
|
||||||
|
<scope>import</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</dependencyManagement>
|
||||||
|
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.papermc.paper</groupId>
|
<groupId>io.papermc.paper</groupId>
|
||||||
@@ -29,6 +44,12 @@
|
|||||||
<version>26.2.build.92-stable</version>
|
<version>26.2.build.92-stable</version>
|
||||||
<scope>provided</scope>
|
<scope>provided</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.junit.jupiter</groupId>
|
||||||
|
<artifactId>junit-jupiter</artifactId>
|
||||||
|
<!-- Version comes from junit-bom above. -->
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
@@ -39,5 +60,23 @@
|
|||||||
<filtering>true</filtering>
|
<filtering>true</filtering>
|
||||||
</resource>
|
</resource>
|
||||||
</resources>
|
</resources>
|
||||||
|
<plugins>
|
||||||
|
<!-- Pinned because the version Maven bundles by default decides whether JUnit 5
|
||||||
|
tests are discovered at all. An older default would skip the whole suite and
|
||||||
|
still report BUILD SUCCESS, which is the one failure this project can't see. -->
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-surefire-plugin</artifactId>
|
||||||
|
<version>3.5.4</version>
|
||||||
|
<configuration>
|
||||||
|
<!-- The pin above guards against a surefire version that quietly stops
|
||||||
|
discovering tests. It does not guard against a test class being
|
||||||
|
misplaced or misnamed, which ends the same way: a green build that
|
||||||
|
ran nothing. Failing on an empty suite closes that gap. A disabled
|
||||||
|
class is still reported as skipped, so it is not covered here. -->
|
||||||
|
<failIfNoTests>true</failIfNoTests>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
</build>
|
</build>
|
||||||
</project>
|
</project>
|
||||||
|
|||||||
@@ -1,23 +1,14 @@
|
|||||||
package dev.marcospaulo.canalhandia;
|
package dev.marcospaulo.canalhandia;
|
||||||
|
|
||||||
import com.google.gson.JsonArray;
|
|
||||||
import com.google.gson.JsonObject;
|
|
||||||
import com.google.gson.JsonParser;
|
|
||||||
import net.kyori.adventure.text.Component;
|
import net.kyori.adventure.text.Component;
|
||||||
import net.kyori.adventure.text.format.NamedTextColor;
|
import net.kyori.adventure.text.format.NamedTextColor;
|
||||||
import net.kyori.adventure.text.format.TextDecoration;
|
import net.kyori.adventure.text.format.TextDecoration;
|
||||||
import org.bukkit.Bukkit;
|
import org.bukkit.Bukkit;
|
||||||
import org.bukkit.entity.Player;
|
import org.bukkit.entity.Player;
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.net.URI;
|
|
||||||
import java.net.http.HttpClient;
|
|
||||||
import java.net.http.HttpRequest;
|
|
||||||
import java.net.http.HttpResponse;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.time.Duration;
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -32,12 +23,17 @@ import java.util.UUID;
|
|||||||
* dispatcher, never written to disk, and no tool/function definitions are sent
|
* dispatcher, never written to disk, and no tool/function definitions are sent
|
||||||
* in the request, so there is nothing for the model to call. A player asking it
|
* in the request, so there is nothing for the model to call. A player asking it
|
||||||
* to "run /op me" gets a string back, not an executed command. As a second
|
* to "run /op me" gets a string back, not an executed command. As a second
|
||||||
* layer, {@link #sanitise} strips leading slashes so a reply cannot even be
|
* layer, {@link AiText#sanitise} strips leading slashes so a reply cannot even be
|
||||||
* mistaken for a command someone should paste.
|
* mistaken for a command someone should paste.
|
||||||
*
|
*
|
||||||
* <p>The API key never lives in config.yml, because config.yml is committed to
|
* <p>The API key never lives in config.yml, because config.yml is committed to
|
||||||
* git. It comes from the {@code MINIMAX_API_KEY} environment variable, or from
|
* git. It comes from the {@code MINIMAX_API_KEY} environment variable, or from
|
||||||
* {@code plugins/Canalhandia/minimax.key}, which is gitignored.
|
* {@code plugins/Canalhandia/minimax.key}, which is gitignored.
|
||||||
|
*
|
||||||
|
* <p>This class is an <em>orchestrator</em>: it assembles context (server facts,
|
||||||
|
* operator corrections, recipes, conversation memory, a wiki article) into a
|
||||||
|
* list of {@link MiniMax.Turn}s and delegates the HTTP to {@link MiniMax}. It
|
||||||
|
* holds no {@code HttpClient} of its own.
|
||||||
*/
|
*/
|
||||||
final class Ai {
|
final class Ai {
|
||||||
|
|
||||||
@@ -45,7 +41,10 @@ final class Ai {
|
|||||||
private static final String KEY_ENV = "MINIMAX_API_KEY";
|
private static final String KEY_ENV = "MINIMAX_API_KEY";
|
||||||
|
|
||||||
private final Canalhandia plugin;
|
private final Canalhandia plugin;
|
||||||
private final HttpClient http;
|
private final MiniMax api;
|
||||||
|
private final Wiki wiki;
|
||||||
|
private final Conversations conversations;
|
||||||
|
private final Corrections corrections;
|
||||||
/** Per-player cooldown, so one person cannot spend the whole budget. */
|
/** Per-player cooldown, so one person cannot spend the whole budget. */
|
||||||
private final Map<UUID, Long> lastAsk = new HashMap<>();
|
private final Map<UUID, Long> lastAsk = new HashMap<>();
|
||||||
/** In-flight guard: one question per player at a time. */
|
/** In-flight guard: one question per player at a time. */
|
||||||
@@ -54,11 +53,69 @@ final class Ai {
|
|||||||
private LocalDate day = LocalDate.now();
|
private LocalDate day = LocalDate.now();
|
||||||
private int askedToday;
|
private int askedToday;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The most recent answered question, so {@code /ia corrigir} (Task 11) can
|
||||||
|
* correct it without the operator retyping anything.
|
||||||
|
*/
|
||||||
|
private Answered lastAnswer;
|
||||||
|
|
||||||
|
record Answered(UUID asker, String question, String answer) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many answers players have flagged wrong with {@code /ia feedback ruim}.
|
||||||
|
* Shown in {@code /canalhandia status}. Simple in-memory counter — a restart
|
||||||
|
* resets it, like {@link #askedToday}.
|
||||||
|
*/
|
||||||
|
private int feedbackWrong;
|
||||||
|
|
||||||
|
/** Package-private so Task 11's {@code /ia corrigir} can read what to correct. */
|
||||||
|
Answered lastAnswer() {
|
||||||
|
return lastAnswer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Package-private so Task 11 can wire quit-forget. */
|
||||||
|
Conversations conversations() {
|
||||||
|
return conversations;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Package-private so Task 11 can wire {@code /ia corrigir}. */
|
||||||
|
Corrections corrections() {
|
||||||
|
return corrections;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A player marked the last answer wrong with {@code /ia feedback ruim}. */
|
||||||
|
boolean flagLastAnswerWrong() {
|
||||||
|
if (lastAnswer == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
feedbackWrong++;
|
||||||
|
plugin.getLogger().info("IA: resposta marcada como errada — pergunta: " + lastAnswer.question());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How many answers players have flagged wrong. Shown in /canalhandia status. */
|
||||||
|
int feedbackWrong() {
|
||||||
|
return feedbackWrong;
|
||||||
|
}
|
||||||
|
|
||||||
Ai(Canalhandia plugin) {
|
Ai(Canalhandia plugin) {
|
||||||
this.plugin = plugin;
|
this.plugin = plugin;
|
||||||
this.http = HttpClient.newBuilder()
|
Settings settings = plugin.settings();
|
||||||
.connectTimeout(Duration.ofSeconds(10))
|
Fetcher fetcher = new HttpFetcher(settings.aiTimeoutSeconds());
|
||||||
.build();
|
this.api = new MiniMax(fetcher, settings.aiUrl(), plugin.getLogger()::warning);
|
||||||
|
// 3-arg ctor + logger (carry-forward #1): the 2-arg ctor is silent in
|
||||||
|
// production, so every wiki failure here is logged.
|
||||||
|
this.wiki = new Wiki(fetcher, settings.aiWikiChars(), plugin.getLogger()::warning);
|
||||||
|
this.conversations = new Conversations(settings.aiMemoryExchanges(), settings.aiMemoryMinutes());
|
||||||
|
this.corrections = new Corrections(new java.io.File(plugin.getDataFolder(), "correcoes.yml"));
|
||||||
|
// Note: aiUrl(), aiWikiChars(), aiMemoryExchanges() and aiMemoryMinutes()
|
||||||
|
// are baked here at construction (passed to the MiniMax/Wiki/Conversations
|
||||||
|
// constructors and not re-read). Everything else — aiProfile(), aiModel(),
|
||||||
|
// aiMaxTokens(), aiTemperature(), aiInstructions(), aiServerContext() — is
|
||||||
|
// read live in the async body, so operators can hot-swap them. Profiles in
|
||||||
|
// particular switch live without a restart; the baked four are not meant
|
||||||
|
// to be hot-swapped.
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True if a key is configured. Without one the module stays quiet. */
|
/** True if a key is configured. Without one the module stays quiet. */
|
||||||
@@ -66,18 +123,66 @@ final class Ai {
|
|||||||
return apiKey() != null;
|
return apiKey() != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The first non-blank line of a key file or variable, with any control
|
||||||
|
* character removed. Null if there is nothing usable.
|
||||||
|
*
|
||||||
|
* <p>{@code trim()} alone leaves an <em>interior</em> newline — a key file
|
||||||
|
* with the key on line 1 and a comment on line 2 survives it. Such a key
|
||||||
|
* cannot go in an HTTP header, and the JDK's rejection of it quotes the
|
||||||
|
* whole header value, key included, into the exception message, which then
|
||||||
|
* reaches the server log. Cleaning at the source means that never happens;
|
||||||
|
* {@link HttpFetcher} checks again as a backstop.
|
||||||
|
*
|
||||||
|
* <p>Takes the first line rather than deleting the newline and joining, so
|
||||||
|
* a trailing comment line cannot be silently welded onto the key to make a
|
||||||
|
* different, wrong one — that would turn a readable failure into a puzzling
|
||||||
|
* authentication error.
|
||||||
|
*/
|
||||||
|
static String cleanKey(String raw) {
|
||||||
|
if (raw == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (String line : raw.split("\\R")) {
|
||||||
|
StringBuilder out = new StringBuilder(line.length());
|
||||||
|
for (int i = 0; i < line.length(); i++) {
|
||||||
|
char c = line.charAt(i);
|
||||||
|
// Printable ASCII only, which is all a bearer token contains.
|
||||||
|
// A deny-list of low controls is not enough: the JDK also
|
||||||
|
// rejects every char above U+00FF, so a U+FEFF byte order mark
|
||||||
|
// — what Notepad and PowerShell Out-File put at the head of a
|
||||||
|
// file — would survive and produce a header the JDK quotes
|
||||||
|
// back, key included. See HttpFetcher.checkBearer for the
|
||||||
|
// invariant this half must satisfy.
|
||||||
|
if (c > 0x20 && c < 0x7F) {
|
||||||
|
out.append(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String key = out.toString();
|
||||||
|
if (!key.isEmpty()) {
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Logs a warning with any occurrence of the key removed. */
|
||||||
|
private void warnWithout(String key, String message) {
|
||||||
|
plugin.getLogger().warning(
|
||||||
|
key == null || key.isEmpty() ? message : message.replace(key, "***"));
|
||||||
|
}
|
||||||
|
|
||||||
private String apiKey() {
|
private String apiKey() {
|
||||||
String fromEnv = System.getenv(KEY_ENV);
|
String fromEnv = System.getenv(KEY_ENV);
|
||||||
if (fromEnv != null && !fromEnv.isBlank()) {
|
if (fromEnv != null && !fromEnv.isBlank()) {
|
||||||
return fromEnv.trim();
|
return cleanKey(fromEnv);
|
||||||
}
|
}
|
||||||
Path file = plugin.getDataFolder().toPath().resolve(KEY_FILE);
|
Path file = plugin.getDataFolder().toPath().resolve(KEY_FILE);
|
||||||
try {
|
try {
|
||||||
if (Files.isReadable(file)) {
|
if (Files.isReadable(file)) {
|
||||||
String key = Files.readString(file, StandardCharsets.UTF_8).trim();
|
return cleanKey(Files.readString(file, StandardCharsets.UTF_8));
|
||||||
return key.isEmpty() ? null : key;
|
|
||||||
}
|
}
|
||||||
} catch (IOException e) {
|
} catch (java.io.IOException e) {
|
||||||
plugin.getLogger().warning("Não consegui ler " + KEY_FILE + ": " + e.getMessage());
|
plugin.getLogger().warning("Não consegui ler " + KEY_FILE + ": " + e.getMessage());
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -90,6 +195,17 @@ final class Ai {
|
|||||||
* reply is posted back on it.
|
* reply is posted back on it.
|
||||||
*/
|
*/
|
||||||
void ask(Player asker, String question) {
|
void ask(Player asker, String question) {
|
||||||
|
ask(asker, question, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asks the model on behalf of a player and delivers the answer to chat.
|
||||||
|
*
|
||||||
|
* @param isPrivate true for {@code /iap} (Task 11): the question and answer
|
||||||
|
* go only to the asker even when the module is public. A private
|
||||||
|
* question never broadcasts the question either.
|
||||||
|
*/
|
||||||
|
void ask(Player asker, String question, boolean isPrivate) {
|
||||||
Settings settings = plugin.settings();
|
Settings settings = plugin.settings();
|
||||||
|
|
||||||
String key = apiKey();
|
String key = apiKey();
|
||||||
@@ -121,7 +237,7 @@ final class Ai {
|
|||||||
pending.put(asker.getUniqueId(), true);
|
pending.put(asker.getUniqueId(), true);
|
||||||
askedToday++;
|
askedToday++;
|
||||||
|
|
||||||
if (settings.aiPublic()) {
|
if (!isPrivate && settings.aiPublic()) {
|
||||||
Bukkit.broadcast(Msg.tag("IA", NamedTextColor.LIGHT_PURPLE)
|
Bukkit.broadcast(Msg.tag("IA", NamedTextColor.LIGHT_PURPLE)
|
||||||
.append(Component.text(asker.getName() + " perguntou: ", NamedTextColor.GRAY)
|
.append(Component.text(asker.getName() + " perguntou: ", NamedTextColor.GRAY)
|
||||||
.decoration(TextDecoration.BOLD, false))
|
.decoration(TextDecoration.BOLD, false))
|
||||||
@@ -132,23 +248,94 @@ final class Ai {
|
|||||||
|
|
||||||
String prompt = question;
|
String prompt = question;
|
||||||
UUID id = asker.getUniqueId();
|
UUID id = asker.getUniqueId();
|
||||||
|
final boolean isPriv = isPrivate;
|
||||||
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
|
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
|
||||||
String answer;
|
String answer = null;
|
||||||
try {
|
try {
|
||||||
answer = call(key, prompt, settings);
|
java.util.List<MiniMax.Turn> messages = compose(asker, prompt, settings);
|
||||||
|
|
||||||
|
if (settings.aiProfile() == AiProfile.PRECISO) {
|
||||||
|
String term = api.searchTerm(key, settings.aiModel(), prompt);
|
||||||
|
Wiki.Article article = term == null ? null : wiki.lookup(term);
|
||||||
|
if (article != null) {
|
||||||
|
messages.add(messages.size() - 1, new MiniMax.Turn("system",
|
||||||
|
"Artigo da Minecraft Wiki pt-BR — '" + article.title() + "':\n"
|
||||||
|
+ article.text()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
answer = api.answer(key, settings.aiModel(), messages,
|
||||||
|
settings.aiMaxTokens(), settings.aiTemperature());
|
||||||
|
|
||||||
|
// Hidden reasoning can swallow the budget, and the model
|
||||||
|
// occasionally drops a foreign word mid-sentence. Both are
|
||||||
|
// worth one retry before giving up (carry-forwards #3 and #7).
|
||||||
|
if (answer == null || AiText.hasForeignScript(answer)) {
|
||||||
|
// Cap before doubling: an absurd ia.max-tokens near
|
||||||
|
// Integer.MAX_VALUE would overflow to a negative budget
|
||||||
|
// and be sent to the API. The default (1200) is unaffected.
|
||||||
|
int retryTokens = Math.min(settings.aiMaxTokens(), Integer.MAX_VALUE / 2) * 2;
|
||||||
|
answer = api.answer(key, settings.aiModel(), messages, retryTokens, 0.1);
|
||||||
|
}
|
||||||
|
if (answer != null && AiText.hasForeignScript(answer)) {
|
||||||
|
plugin.getLogger().warning("Resposta descartada por idioma estrangeiro.");
|
||||||
|
answer = null;
|
||||||
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
plugin.getLogger().warning("Falha na chamada à IA: " + e);
|
// Redacts the key: the JDK's header validator quotes the whole
|
||||||
|
// Authorization value back into the exception message, so a key
|
||||||
|
// with a stray newline would otherwise reach the log verbatim.
|
||||||
|
// cleanKey makes that impossible; this is the backstop.
|
||||||
|
warnWithout(key, "Falha na chamada à IA: " + e);
|
||||||
answer = null;
|
answer = null;
|
||||||
}
|
}
|
||||||
String finalAnswer = answer;
|
String finalAnswer = answer;
|
||||||
Bukkit.getScheduler().runTask(plugin, () -> {
|
Bukkit.getScheduler().runTask(plugin, () -> {
|
||||||
pending.remove(id);
|
pending.remove(id);
|
||||||
deliver(id, finalAnswer, settings);
|
deliver(id, prompt, finalAnswer, settings, isPriv);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private void deliver(UUID askerId, String answer, Settings settings) {
|
/**
|
||||||
|
* Builds the messages for one question.
|
||||||
|
*
|
||||||
|
* <p>Order matters: system instructions, server context, operator
|
||||||
|
* corrections, recipes (which the wiki cannot supply — {@code explaintext}
|
||||||
|
* drops tables), the conversation history, then the question itself.
|
||||||
|
*/
|
||||||
|
private java.util.List<MiniMax.Turn> compose(Player asker, String question, Settings settings) {
|
||||||
|
java.util.List<MiniMax.Turn> messages = new java.util.ArrayList<>();
|
||||||
|
messages.add(new MiniMax.Turn("system", settings.aiInstructions()));
|
||||||
|
|
||||||
|
String serverContext = settings.aiServerContext();
|
||||||
|
if (!serverContext.isBlank()) {
|
||||||
|
messages.add(new MiniMax.Turn("system", "Sobre este servidor: " + serverContext));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (Corrections.Entry entry : Corrections.matching(corrections.all(), question)) {
|
||||||
|
messages.add(new MiniMax.Turn("system",
|
||||||
|
"Correção registrada por um operador. Pergunta parecida: \""
|
||||||
|
+ entry.question() + "\" Resposta correta: " + entry.answer()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recipes never appear in wiki text: explaintext drops tables. The wiki
|
||||||
|
// is consulted here only for the englishTitle translation; the recipe
|
||||||
|
// data comes from the Bukkit snapshot taken at enable (RecipeBook.preload).
|
||||||
|
if (RecipeBook.isRecipeQuestion(question)) {
|
||||||
|
String recipes = RecipeBook.describe(question, wiki);
|
||||||
|
if (recipes != null) {
|
||||||
|
messages.add(new MiniMax.Turn("system", recipes));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.addAll(conversations.history(asker.getUniqueId()));
|
||||||
|
messages.add(new MiniMax.Turn("user", question));
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void deliver(UUID askerId, String question, String answer,
|
||||||
|
Settings settings, boolean isPrivate) {
|
||||||
Player asker = Bukkit.getPlayer(askerId);
|
Player asker = Bukkit.getPlayer(askerId);
|
||||||
if (answer == null || answer.isBlank()) {
|
if (answer == null || answer.isBlank()) {
|
||||||
if (asker != null) {
|
if (asker != null) {
|
||||||
@@ -156,85 +343,27 @@ final class Ai {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
String clean = AiText.sanitise(answer, settings.aiMaxAnswer());
|
||||||
|
// Only remember if the asker is still online: a PlayerQuitEvent forgets
|
||||||
|
// the player's history (carry-forward #6), and re-adding here after the
|
||||||
|
// quit would resurrect it. lastAnswer stays regardless, so /ia corrigir
|
||||||
|
// can still correct the last answer even after the asker left.
|
||||||
|
if (asker != null) {
|
||||||
|
conversations.remember(askerId, question, clean);
|
||||||
|
}
|
||||||
|
lastAnswer = new Answered(askerId, question, clean);
|
||||||
|
|
||||||
Component message = Msg.tag("IA", NamedTextColor.LIGHT_PURPLE)
|
Component message = Msg.tag("IA", NamedTextColor.LIGHT_PURPLE)
|
||||||
.append(Component.text(sanitise(answer, settings.aiMaxAnswer()), NamedTextColor.WHITE)
|
.append(Component.text(clean, NamedTextColor.WHITE)
|
||||||
.decoration(TextDecoration.BOLD, false));
|
.decoration(TextDecoration.BOLD, false));
|
||||||
if (settings.aiPublic()) {
|
if (isPrivate || !settings.aiPublic()) {
|
||||||
Bukkit.broadcast(message);
|
if (asker != null) {
|
||||||
} else if (asker != null) {
|
|
||||||
asker.sendMessage(message);
|
asker.sendMessage(message);
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
Bukkit.broadcast(message);
|
||||||
// --- HTTP ---------------------------------------------------------------
|
plugin.openAiReactions(askerId);
|
||||||
|
|
||||||
private String call(String key, String question, Settings settings) throws Exception {
|
|
||||||
JsonArray messages = new JsonArray();
|
|
||||||
messages.add(message("system", settings.aiInstructions()));
|
|
||||||
messages.add(message("user", question));
|
|
||||||
|
|
||||||
JsonObject body = new JsonObject();
|
|
||||||
body.addProperty("model", settings.aiModel());
|
|
||||||
body.add("messages", messages);
|
|
||||||
body.addProperty("max_tokens", settings.aiMaxTokens());
|
|
||||||
body.addProperty("temperature", settings.aiTemperature());
|
|
||||||
// No "tools" and no "tool_choice": the model is given nothing it could call.
|
|
||||||
|
|
||||||
HttpRequest request = HttpRequest.newBuilder(URI.create(settings.aiUrl()))
|
|
||||||
.timeout(Duration.ofSeconds(settings.aiTimeoutSeconds()))
|
|
||||||
.header("Content-Type", "application/json")
|
|
||||||
.header("Authorization", "Bearer " + key)
|
|
||||||
.POST(HttpRequest.BodyPublishers.ofString(body.toString(), StandardCharsets.UTF_8))
|
|
||||||
.build();
|
|
||||||
|
|
||||||
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
|
|
||||||
if (response.statusCode() / 100 != 2) {
|
|
||||||
plugin.getLogger().warning("IA respondeu HTTP " + response.statusCode() + ": "
|
|
||||||
+ trim(response.body()));
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return extract(response.body());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static JsonObject message(String role, String content) {
|
|
||||||
JsonObject object = new JsonObject();
|
|
||||||
object.addProperty("role", role);
|
|
||||||
object.addProperty("content", content);
|
|
||||||
return object;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pulls the reply text out of an OpenAI-shaped response.
|
|
||||||
*
|
|
||||||
* <p>MiniMax returns HTTP 200 even for application-level failures, putting
|
|
||||||
* the real outcome in {@code base_resp.status_code}, so that is checked too.
|
|
||||||
*/
|
|
||||||
private String extract(String json) {
|
|
||||||
JsonObject root = JsonParser.parseString(json).getAsJsonObject();
|
|
||||||
if (root.has("base_resp")) {
|
|
||||||
JsonObject base = root.getAsJsonObject("base_resp");
|
|
||||||
int status = base.has("status_code") ? base.get("status_code").getAsInt() : 0;
|
|
||||||
if (status != 0) {
|
|
||||||
plugin.getLogger().warning("IA recusou: base_resp " + base);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!root.has("choices")) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
JsonArray choices = root.getAsJsonArray("choices");
|
|
||||||
if (choices.isEmpty()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
JsonObject first = choices.get(0).getAsJsonObject();
|
|
||||||
if (!first.has("message")) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
JsonObject message = first.getAsJsonObject("message");
|
|
||||||
// Reasoning models also return "reasoning_content"; only "content" is shown.
|
|
||||||
return message.has("content") && !message.get("content").isJsonNull()
|
|
||||||
? message.get("content").getAsString()
|
|
||||||
: null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- limits and cleanup -------------------------------------------------
|
// --- limits and cleanup -------------------------------------------------
|
||||||
@@ -263,39 +392,4 @@ final class Ai {
|
|||||||
return askedToday;
|
return askedToday;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Makes a model reply safe and readable in chat.
|
|
||||||
*
|
|
||||||
* <p>Strips colour codes so the reply cannot forge server messages, folds
|
|
||||||
* newlines so one answer stays one chat entry, and removes leading slashes
|
|
||||||
* so nothing that comes back reads as a command to run.
|
|
||||||
*
|
|
||||||
* <p>It also strips markdown and emoji, which the models emit freely.
|
|
||||||
* Minecraft chat renders neither: {@code **negrito**} arrives as literal
|
|
||||||
* asterisks, and emoji show up as empty boxes on Bedrock.
|
|
||||||
*/
|
|
||||||
static String sanitise(String raw, int max) {
|
|
||||||
String text = raw.replace('§', ' ')
|
|
||||||
.replaceAll("[\\r\\n]+", " ")
|
|
||||||
// Markdown emphasis and code fences: chat shows the characters, not the effect.
|
|
||||||
.replaceAll("\\*{1,3}([^*]+)\\*{1,3}", "$1")
|
|
||||||
.replaceAll("`{1,3}([^`]+)`{1,3}", "$1")
|
|
||||||
.replaceAll("^#{1,6}\\s+", "")
|
|
||||||
// Emoji live outside the BMP, plus the symbol blocks and the
|
|
||||||
// variation selector. Accented pt-BR letters are far below this.
|
|
||||||
.replaceAll("[\\x{1F000}-\\x{1FAFF}\\x{2190}-\\x{2BFF}\\x{FE0F}\\x{20E3}]", "")
|
|
||||||
.replaceAll("\\s{2,}", " ")
|
|
||||||
.trim();
|
|
||||||
while (text.startsWith("/")) {
|
|
||||||
text = text.substring(1).trim();
|
|
||||||
}
|
|
||||||
if (text.length() > max) {
|
|
||||||
text = text.substring(0, max).trim() + "…";
|
|
||||||
}
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String trim(String text) {
|
|
||||||
return text.length() > 300 ? text.substring(0, 300) + "…" : text;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package dev.marcospaulo.canalhandia;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How much work to do per question.
|
||||||
|
*
|
||||||
|
* <p>This trades latency, not money: the plan's token allowance is far beyond
|
||||||
|
* what a small server can spend, but every added context token makes chat feel
|
||||||
|
* slower.
|
||||||
|
*/
|
||||||
|
enum AiProfile {
|
||||||
|
|
||||||
|
/** Skip the wiki round trip. Fast, ungrounded. */
|
||||||
|
ECONOMICO,
|
||||||
|
/** Consult the wiki. Slower, accurate. */
|
||||||
|
PRECISO;
|
||||||
|
|
||||||
|
static AiProfile byKey(String key) {
|
||||||
|
for (AiProfile profile : values()) {
|
||||||
|
if (profile.name().equalsIgnoreCase(key)) {
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return PRECISO;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if {@code key} names a profile. Use before {@link #byKey} to tell a
|
||||||
|
* real key from the {@code PRECISO} fallback, so {@code /ia perfil blah}
|
||||||
|
* can be rejected (or fall through to a question) rather than silently
|
||||||
|
* switching to PRECISO. */
|
||||||
|
static boolean isValid(String key) {
|
||||||
|
for (AiProfile profile : values()) {
|
||||||
|
if (profile.name().equalsIgnoreCase(key)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package dev.marcospaulo.canalhandia;
|
||||||
|
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Text guards for model replies.
|
||||||
|
*
|
||||||
|
* <p>Pure functions, deliberately free of Bukkit, so they can be tested without
|
||||||
|
* a server.
|
||||||
|
*/
|
||||||
|
final class AiText {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Non-Latin scripts that should never appear in a Portuguese answer. The
|
||||||
|
* model has been observed dropping single Chinese words mid-sentence.
|
||||||
|
*/
|
||||||
|
private static final Pattern FOREIGN = Pattern.compile(
|
||||||
|
"[\\p{IsHan}\\p{IsHiragana}\\p{IsKatakana}\\p{IsHangul}\\p{IsCyrillic}\\p{IsArabic}]");
|
||||||
|
|
||||||
|
private AiText() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if the text contains a character from a non-Latin script.
|
||||||
|
*
|
||||||
|
* <p>This detects leakage by alphabet, so it catches only what a different
|
||||||
|
* alphabet makes visible. A foreign word written in Latin script is
|
||||||
|
* <b>not</b> caught: the French {@code contiennent}, observed in an
|
||||||
|
* otherwise Portuguese reply, passes this check. Catching that would need
|
||||||
|
* dictionary or language-identification work this method does not do.
|
||||||
|
*/
|
||||||
|
static boolean hasForeignScript(String text) {
|
||||||
|
return text != null && FOREIGN.matcher(text).find();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes a reply safe and readable in chat: no colour codes to forge server
|
||||||
|
* messages, no markdown or emoji (chat renders neither, and emoji are empty
|
||||||
|
* boxes on Bedrock), no leading slash that could read as a command.
|
||||||
|
*
|
||||||
|
* <p>{@code null} returns {@code ""}: {@code Ai.deliver} never calls here
|
||||||
|
* with a null, but hardening is cheap and keeps the contract total.
|
||||||
|
*
|
||||||
|
* <p>Truncation backs off one char when it lands on a high surrogate, so the
|
||||||
|
* result is never left with an orphan surrogate that would render as a
|
||||||
|
* replacement box.
|
||||||
|
*/
|
||||||
|
static String sanitise(String raw, int max) {
|
||||||
|
if (raw == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String text = raw
|
||||||
|
// A colour code is the section sign plus the code character, so both
|
||||||
|
// go. Dropping only the sign would leave the bare letter behind and
|
||||||
|
// "§c" would read as a stray "c" in the middle of the sentence.
|
||||||
|
.replaceAll("§[0-9A-Za-z]", " ")
|
||||||
|
.replace('§', ' ')
|
||||||
|
.replaceAll("[\\r\\n]+", " ")
|
||||||
|
// Markdown emphasis/inline-code: only strip the markers when they
|
||||||
|
// flank non-space text. A lone "*" surrounded by spaces is not
|
||||||
|
// emphasis (chat uses it as a bullet), so the regex requires a
|
||||||
|
// non-space right after the opening markers and before the
|
||||||
|
// closing ones. "{1,3}" keeps it handling *, ** and ***.
|
||||||
|
.replaceAll("(?s)\\*{1,3}(?!\\s)(.+?)(?<!\\s)\\*{1,3}", "$1")
|
||||||
|
.replaceAll("(?s)`{1,3}(?!\\s)(.+?)(?<!\\s)`{1,3}", "$1")
|
||||||
|
.replaceAll("^#{1,6}\\s+", "")
|
||||||
|
.replaceAll("[\\x{1F000}-\\x{1FAFF}\\x{2190}-\\x{2BFF}\\x{FE0F}\\x{20E3}]", "")
|
||||||
|
.replaceAll("\\s{2,}", " ")
|
||||||
|
.trim();
|
||||||
|
while (text.startsWith("/")) {
|
||||||
|
text = text.substring(1).trim();
|
||||||
|
}
|
||||||
|
if (text.length() > max) {
|
||||||
|
int cut = max;
|
||||||
|
// Back off one char if we sliced a high surrogate off its low half,
|
||||||
|
// otherwise the String is left with an orphan surrogate that
|
||||||
|
// renders as a replacement character.
|
||||||
|
if (cut > 0 && Character.isHighSurrogate(text.charAt(cut - 1))) {
|
||||||
|
cut--;
|
||||||
|
}
|
||||||
|
text = text.substring(0, cut).trim() + "…";
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shortens text for a log line. */
|
||||||
|
static String forLog(String text) {
|
||||||
|
return text.length() > 300 ? text.substring(0, 300) + "…" : text;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,11 +62,17 @@ public final class Canalhandia extends JavaPlugin implements Listener {
|
|||||||
offlineStats = new OfflineStats(this);
|
offlineStats = new OfflineStats(this);
|
||||||
milestones = new Milestones(this);
|
milestones = new Milestones(this);
|
||||||
ai = new Ai(this);
|
ai = new Ai(this);
|
||||||
|
// Snapshot the server's recipes on the main thread; RecipeBook.describe
|
||||||
|
// reads from the async answer path and Bukkit.recipeIterator() is not
|
||||||
|
// safe off the main thread. Datapack reloads after this are not
|
||||||
|
// re-snapshotted.
|
||||||
|
RecipeBook.preload();
|
||||||
optOutKey = new NamespacedKey(this, "opt_out");
|
optOutKey = new NamespacedKey(this, "opt_out");
|
||||||
|
|
||||||
CanalhandiaCommand root = new CanalhandiaCommand(this);
|
CanalhandiaCommand root = new CanalhandiaCommand(this);
|
||||||
for (String name : List.of("canalhandia", "curiosidade", "adivinha", "enquete", "ranking",
|
for (String name : List.of("canalhandia", "curiosidade", "adivinha", "enquete", "ranking",
|
||||||
"reagir", "reacoes", "palpite", "votar", "legal", "wow", "top", "f", "ia")) {
|
"reagir", "reacoes", "palpite", "votar", "legal", "wow", "top", "f", "ia", "iap",
|
||||||
|
"errado")) {
|
||||||
register(name, root);
|
register(name, root);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,6 +305,38 @@ public final class Canalhandia extends JavaPlugin implements Listener {
|
|||||||
return reactions;
|
return reactions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the reaction row on a public AI answer: 👍 (reuses the shared
|
||||||
|
* {@code joia}/{@code /legal} reaction so the typed Bedrock twin resolves
|
||||||
|
* via {@link Settings#reactionForCommand}) and ❌ ({@code /errado}, new).
|
||||||
|
*
|
||||||
|
* <p>The {@code askerId} param is kept in the signature because
|
||||||
|
* {@link Ai#deliver} passes it, but is not used structurally here.
|
||||||
|
*/
|
||||||
|
void openAiReactions(UUID askerId) {
|
||||||
|
if (!settings.reactionsEnabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Reactions reactions = new Reactions(nextId++, List.of(
|
||||||
|
new ReactionDef("joia", "[👍]", "[+1]", "legal"),
|
||||||
|
new ReactionDef("errado", "[❌]", "[ERRADO]", "errado")));
|
||||||
|
liveReactions = reactions;
|
||||||
|
remember(reactions);
|
||||||
|
reactions.show();
|
||||||
|
getServer().getScheduler().runTaskLater(this, () -> {
|
||||||
|
reactions.hide();
|
||||||
|
if (liveReactions == reactions) {
|
||||||
|
liveReactions = null;
|
||||||
|
}
|
||||||
|
if (reactions.hasAnyVote()) {
|
||||||
|
broadcastPerPlatform(bedrock -> Component.text(" ")
|
||||||
|
.append(reactions.summary(bedrock, settings.summaryNames())));
|
||||||
|
}
|
||||||
|
}, settings.reactionWindowSeconds() * 20L);
|
||||||
|
broadcastPerPlatform(bedrock -> Component.text(" ")
|
||||||
|
.append(reactions.buttons(bedrock)));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The newest reaction set still accepting clicks, for typed shortcuts like
|
* The newest reaction set still accepting clicks, for typed shortcuts like
|
||||||
* {@code /wow} where the player never sees an id.
|
* {@code /wow} where the player never sees an id.
|
||||||
@@ -475,6 +513,17 @@ public final class Canalhandia extends JavaPlugin implements Listener {
|
|||||||
}, settings.reactionWindowSeconds() * 20L);
|
}, settings.reactionWindowSeconds() * 20L);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drops a player's short-term AI memory on quit, so a rejoin does not
|
||||||
|
* answer a fresh question with an old one (carry-forward #6).
|
||||||
|
*/
|
||||||
|
@EventHandler
|
||||||
|
public void onQuit(org.bukkit.event.player.PlayerQuitEvent event) {
|
||||||
|
if (ai != null) {
|
||||||
|
ai.conversations().forget(event.getPlayer().getUniqueId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- per-player opt out -------------------------------------------------
|
// --- per-player opt out -------------------------------------------------
|
||||||
|
|
||||||
boolean isOptedOut(Player player) {
|
boolean isOptedOut(Player player) {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import java.util.ArrayList;
|
|||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -46,7 +47,8 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
|
|||||||
case "palpite" -> guessLatest(sender, args);
|
case "palpite" -> guessLatest(sender, args);
|
||||||
case "votar" -> voteLatest(sender, args);
|
case "votar" -> voteLatest(sender, args);
|
||||||
case "reacoes" -> whoReacted(sender);
|
case "reacoes" -> whoReacted(sender);
|
||||||
case "ia" -> ia(sender, args);
|
case "ia" -> ia(sender, args, false);
|
||||||
|
case "iap" -> ia(sender, args, true);
|
||||||
default -> {
|
default -> {
|
||||||
String reaction = plugin.settings().reactionForCommand(command.getName());
|
String reaction = plugin.settings().reactionForCommand(command.getName());
|
||||||
if (reaction != null) {
|
if (reaction != null) {
|
||||||
@@ -656,10 +658,12 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
|
|||||||
}
|
}
|
||||||
Msg.line(sender, "categorias ativas", enabled.isEmpty() ? "nenhuma" : String.join(", ", enabled));
|
Msg.line(sender, "categorias ativas", enabled.isEmpty() ? "nenhuma" : String.join(", ", enabled));
|
||||||
Msg.line(sender, "ia", plugin.ai().configured()
|
Msg.line(sender, "ia", plugin.ai().configured()
|
||||||
? settings.aiModel() + " · " + plugin.ai().askedToday() + "/"
|
? settings.aiModel() + " · perfil " + settings.aiProfile().name().toLowerCase(Locale.ROOT)
|
||||||
+ settings.aiDailyLimit() + " hoje · cooldown "
|
+ " · " + plugin.ai().askedToday() + "/" + settings.aiDailyLimit() + " hoje"
|
||||||
+ settings.aiCooldownSeconds() + "s · "
|
+ " · cooldown " + settings.aiCooldownSeconds() + "s · "
|
||||||
+ (settings.aiPublic() ? "resposta pública" : "resposta privada")
|
+ (settings.aiPublic() ? "resposta pública" : "resposta privada")
|
||||||
|
+ " · " + plugin.ai().corrections().all().size() + " correções"
|
||||||
|
+ " · " + plugin.ai().feedbackWrong() + " feedback ruim"
|
||||||
: "sem chave configurada");
|
: "sem chave configurada");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -754,8 +758,13 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
|
|||||||
* has it out of the box and LuckPerms can hand it to anyone else with
|
* has it out of the box and LuckPerms can hand it to anyone else with
|
||||||
* {@code lp user <nome> permission set canalhandia.ia true}.
|
* {@code lp user <nome> permission set canalhandia.ia true}.
|
||||||
*/
|
*/
|
||||||
private boolean ia(CommandSender sender, String[] args) {
|
private boolean ia(CommandSender sender, String[] args, boolean isPrivate) {
|
||||||
if (!sender.hasPermission("canalhandia.ia")) {
|
// /ia and /iap are separate gates so LuckPerms can grant them
|
||||||
|
// independently — an operator can let someone ask privately without
|
||||||
|
// letting them spam public chat, or vice-versa. Both default to op,
|
||||||
|
// matching the "operator + LuckPerms-permitted only" rule.
|
||||||
|
String perm = isPrivate ? "canalhandia.ia.privado" : "canalhandia.ia";
|
||||||
|
if (!sender.hasPermission(perm)) {
|
||||||
return denied(sender);
|
return denied(sender);
|
||||||
}
|
}
|
||||||
if (!plugin.settings().moduleEnabled(Module.IA)) {
|
if (!plugin.settings().moduleEnabled(Module.IA)) {
|
||||||
@@ -766,13 +775,82 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
|
|||||||
Msg.error(sender, "Só jogadores podem usar /ia.");
|
Msg.error(sender, "Só jogadores podem usar /ia.");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (args.length == 0) {
|
// Subcommands come before the question. They only hijack when the
|
||||||
Msg.error(sender, "Uso: /ia <pergunta>");
|
// second token is one they would actually act on, so a real question
|
||||||
|
// like "/ia perfil do servidor" or "/ia feedback do mapa?" falls
|
||||||
|
// through and is asked. "corrigir" stays greedy — a correction always
|
||||||
|
// reads the rest of the line, and "/ia corrigir ..." is never a natural
|
||||||
|
// question.
|
||||||
|
if (args.length > 0) {
|
||||||
|
String sub = args[0].toLowerCase(Locale.ROOT);
|
||||||
|
if (sub.equals("perfil") && (args.length == 1 || AiProfile.isValid(args[1]))) {
|
||||||
|
iaProfile(sender, args);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
plugin.ai().ask(player, String.join(" ", args));
|
if (sub.equals("corrigir")) {
|
||||||
|
iaCorrect(sender, args);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (sub.equals("feedback") && args.length >= 2 && args[1].equalsIgnoreCase("ruim")) {
|
||||||
|
iaFeedback(sender, args);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// default: treat all args as the question
|
||||||
|
}
|
||||||
|
if (args.length == 0) {
|
||||||
|
Msg.error(sender, isPrivate ? "Uso: /iap <pergunta>" : "Uso: /ia <pergunta>");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
plugin.ai().ask(player, String.join(" ", args), isPrivate);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void iaProfile(CommandSender sender, String[] args) {
|
||||||
|
if (!sender.hasPermission("canalhandia.ia.perfil")) {
|
||||||
|
denied(sender);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (args.length < 2) {
|
||||||
|
Msg.error(sender, "Uso: /ia perfil <economico|preciso> (atual: "
|
||||||
|
+ plugin.settings().aiProfile().name().toLowerCase(Locale.ROOT) + ")");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
AiProfile profile = AiProfile.byKey(args[1]);
|
||||||
|
plugin.settings().aiProfile(profile);
|
||||||
|
Msg.ok(sender, "Perfil da IA: " + profile.name().toLowerCase(Locale.ROOT)
|
||||||
|
+ (profile == AiProfile.PRECISO ? " (consulta a wiki)" : " (sem wiki, mais rápido)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void iaCorrect(CommandSender sender, String[] args) {
|
||||||
|
if (!sender.hasPermission("canalhandia.ia.corrigir")) {
|
||||||
|
denied(sender);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Ai.Answered last = plugin.ai().lastAnswer();
|
||||||
|
if (last == null) {
|
||||||
|
Msg.error(sender, "Nenhuma resposta recente para corrigir.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (args.length < 2) {
|
||||||
|
Msg.error(sender, "Uso: /ia corrigir <resposta correta>");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String correct = String.join(" ", Arrays.copyOfRange(args, 1, args.length));
|
||||||
|
plugin.ai().corrections().add(last.question(), correct);
|
||||||
|
Msg.ok(sender, "Correção registrada para perguntas parecidas com: " + last.question());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void iaFeedback(CommandSender sender, String[] args) {
|
||||||
|
if (args.length < 2 || !args[1].equalsIgnoreCase("ruim")) {
|
||||||
|
Msg.error(sender, "Uso: /ia feedback ruim (marca a última resposta como errada)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (plugin.ai().flagLastAnswerWrong()) {
|
||||||
|
Msg.ok(sender, "Obrigado pelo feedback. Um operador vai revisar.");
|
||||||
|
} else {
|
||||||
|
Msg.error(sender, "Nenhuma resposta recente para marcar.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private boolean admin(CommandSender sender) {
|
private boolean admin(CommandSender sender) {
|
||||||
if (sender.hasPermission(ADMIN)) {
|
if (sender.hasPermission(ADMIN)) {
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
package dev.marcospaulo.canalhandia;
|
||||||
|
|
||||||
|
import java.util.ArrayDeque;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Deque;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Short per-player memory, so a follow-up like "e no nether?" makes sense.
|
||||||
|
*
|
||||||
|
* <p>Deliberately small and forgetful: the point is continuity within one
|
||||||
|
* exchange, not a transcript. Every way of losing memory here — expiry,
|
||||||
|
* eviction, a restart — costs one player one lost follow-up, so this class
|
||||||
|
* always prefers forgetting to growing.
|
||||||
|
*
|
||||||
|
* <p>Thread-safe. {@link Ai} calls both methods from
|
||||||
|
* {@code runTaskAsynchronously}, so two players asking at once means two
|
||||||
|
* threads in here at the same time, and {@link #history} <em>writes</em> while
|
||||||
|
* it reads — it prunes expired entries. Unsynchronised that is not a stale
|
||||||
|
* read but a corrupt map: a concurrent resize can lose entries or spin, and
|
||||||
|
* pruning under another thread's write throws {@code ConcurrentModification}
|
||||||
|
* straight up into the player's answer. Guarded by {@link #byPlayer}'s own
|
||||||
|
* monitor, consistently with {@link Wiki}; nothing slow happens under the lock.
|
||||||
|
*/
|
||||||
|
final class Conversations {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A hard ceiling on remembered players. Expiry alone does not bound this:
|
||||||
|
* entries are only pruned for the player who asks, so someone who asks once
|
||||||
|
* and logs off would sit in the map until the next restart. Least-recently
|
||||||
|
* used goes first, and at this size a busy server loses nothing that is
|
||||||
|
* still a live conversation.
|
||||||
|
*/
|
||||||
|
static final int MAX_PLAYERS = 200;
|
||||||
|
|
||||||
|
private static final long NANOS_PER_MINUTE = 60_000_000_000L;
|
||||||
|
|
||||||
|
/** {@code at} is a {@link System#nanoTime} reading; see {@link #expired}. */
|
||||||
|
private record Entry(MiniMax.Turn turn, long at) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private final int maxTurns;
|
||||||
|
private final long windowNanos;
|
||||||
|
/**
|
||||||
|
* Access-ordered so eviction drops the player who has been quiet longest
|
||||||
|
* rather than whoever happened to ask first.
|
||||||
|
*/
|
||||||
|
private final Map<UUID, Deque<Entry>> byPlayer = new LinkedHashMap<>(16, 0.75f, true);
|
||||||
|
|
||||||
|
Conversations(int maxExchanges, int windowMinutes) {
|
||||||
|
// Each exchange is two turns, and the product is computed in long
|
||||||
|
// arithmetic: a config of Integer.MAX_VALUE would overflow an int
|
||||||
|
// multiply to a negative bound, which silently disables memory instead
|
||||||
|
// of honouring the (absurd) request.
|
||||||
|
this.maxTurns = (int) Math.min(2L * Math.max(0, maxExchanges), Integer.MAX_VALUE);
|
||||||
|
long minutes = Math.max(0, windowMinutes);
|
||||||
|
// Saturating rather than wrapping: an overflowed window would come out
|
||||||
|
// negative and expire every entry on the spot, turning "remember for a
|
||||||
|
// very long time" into "remember nothing".
|
||||||
|
this.windowNanos = minutes > Long.MAX_VALUE / NANOS_PER_MINUTE
|
||||||
|
? Long.MAX_VALUE
|
||||||
|
: minutes * NANOS_PER_MINUTE;
|
||||||
|
}
|
||||||
|
|
||||||
|
void remember(UUID player, String question, String answer) {
|
||||||
|
if (maxTurns == 0) {
|
||||||
|
// Short-circuit before touching the map. Falling through would add
|
||||||
|
// two turns, drop both, and leave an empty deque behind — a memory
|
||||||
|
// configured off would still grow one map entry per player.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
long now = System.nanoTime();
|
||||||
|
synchronized (byPlayer) {
|
||||||
|
Deque<Entry> entries = byPlayer.computeIfAbsent(player, key -> new ArrayDeque<>());
|
||||||
|
entries.addLast(new Entry(new MiniMax.Turn("user", question), now));
|
||||||
|
entries.addLast(new Entry(new MiniMax.Turn("assistant", answer), now));
|
||||||
|
while (entries.size() > maxTurns) {
|
||||||
|
entries.removeFirst();
|
||||||
|
}
|
||||||
|
while (byPlayer.size() > MAX_PLAYERS) {
|
||||||
|
byPlayer.remove(byPlayer.keySet().iterator().next());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recent messages still inside the window, oldest first. */
|
||||||
|
List<MiniMax.Turn> history(UUID player) {
|
||||||
|
long now = System.nanoTime();
|
||||||
|
List<MiniMax.Turn> out = new ArrayList<>();
|
||||||
|
synchronized (byPlayer) {
|
||||||
|
Deque<Entry> entries = byPlayer.get(player);
|
||||||
|
if (entries == null) {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
entries.removeIf(entry -> expired(entry, now));
|
||||||
|
if (entries.isEmpty()) {
|
||||||
|
// Do not leave the key behind: a player whose memory has run
|
||||||
|
// out is indistinguishable from one who never asked.
|
||||||
|
byPlayer.remove(player);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
for (Entry entry : entries) {
|
||||||
|
out.add(entry.turn());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
void forget(UUID player) {
|
||||||
|
synchronized (byPlayer) {
|
||||||
|
byPlayer.remove(player);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How many players are currently remembered. For tests. */
|
||||||
|
int size() {
|
||||||
|
synchronized (byPlayer) {
|
||||||
|
return byPlayer.size();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Elapsed time compared as a difference, and against {@link System#nanoTime}
|
||||||
|
* rather than the wall clock. Two reasons. The difference form is the only
|
||||||
|
* correct way to compare nanoTime readings, which are allowed to be negative
|
||||||
|
* and to wrap. And nanoTime is monotonic: an NTP step backwards mid-session
|
||||||
|
* would leave wall-clock entries stamped in the future, so {@code now - at}
|
||||||
|
* would go negative and the entry would never expire — memory that outlives
|
||||||
|
* its window and answers a fresh question with an hour-old one.
|
||||||
|
*
|
||||||
|
* <p>{@code >=} and not {@code >}, so a window of zero expires everything
|
||||||
|
* immediately instead of depending on whether two calls landed in the same
|
||||||
|
* clock tick.
|
||||||
|
*/
|
||||||
|
private boolean expired(Entry entry, long now) {
|
||||||
|
return now - entry.at() >= windowNanos;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package dev.marcospaulo.canalhandia;
|
||||||
|
|
||||||
|
import org.bukkit.configuration.file.YamlConfiguration;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Operator corrections, injected when a new question resembles one that was
|
||||||
|
* answered wrongly before.
|
||||||
|
*
|
||||||
|
* <p>This is the cheap alternative to fine-tuning: a wrong answer becomes
|
||||||
|
* context, so the same mistake stops recurring.
|
||||||
|
*/
|
||||||
|
final class Corrections {
|
||||||
|
|
||||||
|
record Entry(String question, String answer) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private final File file;
|
||||||
|
/**
|
||||||
|
* Read by {@link #all()} from the async answer thread (Ai.compose) and
|
||||||
|
* mutated by {@link #add()}/{@link #load()} from the main thread (/ia
|
||||||
|
* corrigir). Guarded by its own monitor, like {@link Conversations}: only
|
||||||
|
* fast, in-memory work happens under the lock — file parsing and the YAML
|
||||||
|
* save stay outside it — so {@link #all()}'s {@code List.copyOf} never
|
||||||
|
* races a structural change and throws no
|
||||||
|
* {@code ConcurrentModificationException}.
|
||||||
|
*/
|
||||||
|
private final List<Entry> entries = new ArrayList<>();
|
||||||
|
|
||||||
|
Corrections(File file) {
|
||||||
|
this.file = file;
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
void load() {
|
||||||
|
YamlConfiguration yaml = file.exists() ? YamlConfiguration.loadConfiguration(file) : null;
|
||||||
|
synchronized (entries) {
|
||||||
|
entries.clear();
|
||||||
|
if (yaml == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (String key : yaml.getKeys(false)) {
|
||||||
|
String question = yaml.getString(key + ".pergunta");
|
||||||
|
String answer = yaml.getString(key + ".resposta");
|
||||||
|
if (question != null && answer != null) {
|
||||||
|
entries.add(new Entry(question, answer));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void add(String question, String answer) {
|
||||||
|
YamlConfiguration yaml = new YamlConfiguration();
|
||||||
|
synchronized (entries) {
|
||||||
|
entries.add(new Entry(question, answer));
|
||||||
|
for (int i = 0; i < entries.size(); i++) {
|
||||||
|
yaml.set("c" + i + ".pergunta", entries.get(i).question());
|
||||||
|
yaml.set("c" + i + ".resposta", entries.get(i).answer());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
yaml.save(file);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IllegalStateException("não consegui gravar " + file, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Entry> all() {
|
||||||
|
synchronized (entries) {
|
||||||
|
return List.copyOf(entries);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Corrections sharing at least one significant word with the question. */
|
||||||
|
static List<Entry> matching(List<Entry> all, String question) {
|
||||||
|
Set<String> asked = significantWords(question);
|
||||||
|
List<Entry> out = new ArrayList<>();
|
||||||
|
for (Entry entry : all) {
|
||||||
|
Set<String> known = significantWords(entry.question());
|
||||||
|
known.retainAll(asked);
|
||||||
|
if (known.size() >= 1) {
|
||||||
|
out.add(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Set<String> significantWords(String text) {
|
||||||
|
Set<String> words = new HashSet<>();
|
||||||
|
for (String word : text.toLowerCase(Locale.ROOT).split("[^\\p{L}0-9]+")) {
|
||||||
|
// Short words are almost all articles and prepositions in Portuguese.
|
||||||
|
if (word.length() > 4) {
|
||||||
|
words.add(word);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return words;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package dev.marcospaulo.canalhandia;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
/** The one place the plugin talks to the network, so tests can replace it. */
|
||||||
|
interface Fetcher {
|
||||||
|
|
||||||
|
/** GET a URL, returning the body. */
|
||||||
|
String get(String url) throws IOException, InterruptedException;
|
||||||
|
|
||||||
|
/** POST JSON with a bearer token, returning the body. */
|
||||||
|
String postJson(String url, String json, String bearer)
|
||||||
|
throws IOException, InterruptedException;
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package dev.marcospaulo.canalhandia;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
final class HttpFetcher implements Fetcher {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* pt.minecraft.wiki answers 403 to a default user agent — MediaWiki policy
|
||||||
|
* requires callers to identify themselves.
|
||||||
|
*/
|
||||||
|
private static final String AGENT =
|
||||||
|
"Canalhandia-Minecraft-Bot/1.0 (https://marcospaulo.dev.br)";
|
||||||
|
|
||||||
|
private final HttpClient http = HttpClient.newBuilder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(10))
|
||||||
|
.build();
|
||||||
|
private final int timeoutSeconds;
|
||||||
|
|
||||||
|
HttpFetcher(int timeoutSeconds) {
|
||||||
|
this.timeoutSeconds = timeoutSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String get(String url) throws IOException, InterruptedException {
|
||||||
|
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
|
||||||
|
.timeout(Duration.ofSeconds(timeoutSeconds))
|
||||||
|
.header("User-Agent", AGENT)
|
||||||
|
.GET()
|
||||||
|
.build();
|
||||||
|
return body(http.send(request, HttpResponse.BodyHandlers.ofString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rejects a bearer token that cannot go in a header, before it reaches one.
|
||||||
|
*
|
||||||
|
* <p>{@code HttpRequest.Builder.header()} validates the value itself, but
|
||||||
|
* its {@code IllegalArgumentException} quotes the offending value back —
|
||||||
|
* measured on temurin-25: {@code invalid header value: "Bearer sk-…"}. That
|
||||||
|
* throw happens before any I/O, so it reaches callers as an ordinary call
|
||||||
|
* failure and lands the credential in whatever log catches it. A key with a
|
||||||
|
* stray second line is enough to trigger it.
|
||||||
|
*
|
||||||
|
* <p>Checking here rather than at the call site means no future caller has
|
||||||
|
* to remember to redact. The message deliberately names only the position
|
||||||
|
* of the offending character, never any part of the key.
|
||||||
|
*
|
||||||
|
* <p>The invariant, demonstrated by fuzzing all 65,536 char values:
|
||||||
|
* {@code Ai.cleanKey output ⊆ checkBearer accepts ⊆ JDK accepts}. Widening
|
||||||
|
* either of the first two without rechecking the third reopens the leak.
|
||||||
|
*/
|
||||||
|
static void checkBearer(String bearer) throws IOException {
|
||||||
|
if (bearer == null || bearer.isEmpty()) {
|
||||||
|
throw new IOException("Chave de API vazia.");
|
||||||
|
}
|
||||||
|
for (int i = 0; i < bearer.length(); i++) {
|
||||||
|
char c = bearer.charAt(i);
|
||||||
|
// Printable ASCII only. Restricting to what a bearer token is
|
||||||
|
// actually made of is the only range that is safe by construction:
|
||||||
|
// the JDK rejects every char above U+00FF as well as the low
|
||||||
|
// controls — 65,312 of the 65,536 values — so an allow-list of the
|
||||||
|
// 94 printable ones cannot drift out of what it accepts. A key
|
||||||
|
// saved by Notepad or PowerShell Out-File carries a U+FEFF byte
|
||||||
|
// order mark, which a deny-list of low controls alone lets through.
|
||||||
|
if (c < 0x21 || c > 0x7E) {
|
||||||
|
throw new IOException("Chave de API inválida: caractere não imprimível na posição "
|
||||||
|
+ i + " (de " + bearer.length() + "). "
|
||||||
|
+ "Verifique se o arquivo da chave tem uma única linha, "
|
||||||
|
+ "sem quebra de linha e sem marca de ordem de byte (BOM).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String postJson(String url, String json, String bearer)
|
||||||
|
throws IOException, InterruptedException {
|
||||||
|
checkBearer(bearer);
|
||||||
|
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
|
||||||
|
.timeout(Duration.ofSeconds(timeoutSeconds))
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("User-Agent", AGENT)
|
||||||
|
.header("Authorization", "Bearer " + bearer)
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(json, StandardCharsets.UTF_8))
|
||||||
|
.build();
|
||||||
|
return body(http.send(request, HttpResponse.BodyHandlers.ofString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String body(HttpResponse<String> response) throws IOException {
|
||||||
|
if (response.statusCode() / 100 != 2) {
|
||||||
|
throw new IOException("HTTP " + response.statusCode() + ": "
|
||||||
|
+ AiText.forLog(response.body()));
|
||||||
|
}
|
||||||
|
return response.body();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
package dev.marcospaulo.canalhandia;
|
||||||
|
|
||||||
|
import com.google.gson.JsonArray;
|
||||||
|
import com.google.gson.JsonElement;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import com.google.gson.JsonParser;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MiniMax chat-completions client.
|
||||||
|
*
|
||||||
|
* <p>Two calls per grounded question: {@link #searchTerm} makes the model name
|
||||||
|
* a wiki article, then {@link #answer} answers with that article in context.
|
||||||
|
*
|
||||||
|
* <p>The API key is a parameter rather than a field so it is never held longer
|
||||||
|
* than a call, and it leaves this class by exactly one route: the {@code bearer}
|
||||||
|
* argument of {@link Fetcher#postJson}, which {@link HttpFetcher} turns into an
|
||||||
|
* {@code Authorization} header. It is never put in the request body, never
|
||||||
|
* concatenated into a message, and never passed to {@link #warn}. The failure
|
||||||
|
* paths below log the response or the exception, and neither carries it.
|
||||||
|
*/
|
||||||
|
final class MiniMax {
|
||||||
|
|
||||||
|
static final String URL = "https://api.minimax.io/v1/text/chatcompletion_v2";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tool the model is forced to call. Written as JSON rather than built
|
||||||
|
* with {@code JsonObject} calls because it is a fixed schema that never
|
||||||
|
* varies at runtime, and this way it can be read against the API docs — and
|
||||||
|
* against the request that was measured to work — line for line.
|
||||||
|
*/
|
||||||
|
private static final String TOOLS = """
|
||||||
|
[{"type":"function","function":{
|
||||||
|
"name":"buscar_wiki",
|
||||||
|
"description":"Busca um artigo na Minecraft Wiki em português.",
|
||||||
|
"parameters":{"type":"object",
|
||||||
|
"properties":{"termo":{"type":"string",
|
||||||
|
"description":"Termo curto do jogo, ex: Camelo, Creeper"}},
|
||||||
|
"required":["termo"]}}}]
|
||||||
|
""";
|
||||||
|
|
||||||
|
private static final String TOOL_CHOICE = """
|
||||||
|
{"type":"function","function":{"name":"buscar_wiki"}}
|
||||||
|
""";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enough for a tool call and the reasoning that precedes it. Answers get far
|
||||||
|
* more; see {@link #answer}.
|
||||||
|
*/
|
||||||
|
private static final int TERM_TOKENS = 500;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Term selection is deterministic on purpose: the same question must pick
|
||||||
|
* the same article every time, or the wiki cache thrashes and two players
|
||||||
|
* asking the same thing get differently grounded answers. Only
|
||||||
|
* {@link #answer} takes its temperature from config.
|
||||||
|
*/
|
||||||
|
private static final double TERM_TEMPERATURE = 0.0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One message in the request.
|
||||||
|
*
|
||||||
|
* <p>Named {@code Turn} and not {@code Msg} because the package already has
|
||||||
|
* a top-level {@link Msg}, the chat-formatting helper. A nested record of
|
||||||
|
* that name would shadow it inside this file, so a later {@code Msg.error}
|
||||||
|
* here would resolve to the record instead.
|
||||||
|
*/
|
||||||
|
record Turn(String role, String content) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private final Fetcher fetcher;
|
||||||
|
private final String url;
|
||||||
|
private final Consumer<String> warn;
|
||||||
|
|
||||||
|
MiniMax(Fetcher fetcher) {
|
||||||
|
this(fetcher, URL, message -> {
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param url the chat-completions endpoint, or null for {@link #URL}.
|
||||||
|
* MiniMax has regional hosts ({@code api.minimax.io} versus
|
||||||
|
* {@code api.minimaxi.com}) that answer only for accounts registered
|
||||||
|
* against them, so the wrong one fails every call with a valid key.
|
||||||
|
* {@code Settings.aiUrl()} supplies it.
|
||||||
|
* @param warn where failures are reported. Every failure mode here returns
|
||||||
|
* null, which the caller cannot tell apart from "no answer": an expired
|
||||||
|
* key, an exhausted balance, a regional host mismatch and a network
|
||||||
|
* blip all look identical in chat. Without this the only symptom of a
|
||||||
|
* dead API key is {@code /ia} quietly getting worse.
|
||||||
|
*/
|
||||||
|
MiniMax(Fetcher fetcher, String url, Consumer<String> warn) {
|
||||||
|
this.fetcher = fetcher;
|
||||||
|
this.url = url == null || url.isBlank() ? URL : url;
|
||||||
|
this.warn = warn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asks the model which wiki article to read.
|
||||||
|
*
|
||||||
|
* <p>The call is forced with {@code tool_choice} rather than left to
|
||||||
|
* {@code auto}. Given the choice the model skipped the search on exactly
|
||||||
|
* the questions it was most likely to get wrong. A tool argument is also
|
||||||
|
* structured output, so unlike a free-text reply it survives the model's
|
||||||
|
* hidden reasoning eating the token budget: measured over the same
|
||||||
|
* questions, a free-text extraction call answered 2 of 7 while this
|
||||||
|
* answered 5 of 5.
|
||||||
|
*/
|
||||||
|
String searchTerm(String key, String model, String question) {
|
||||||
|
JsonObject body = base(model, List.of(
|
||||||
|
new Turn("system", "Você escolhe qual artigo da Minecraft Wiki consultar."),
|
||||||
|
new Turn("user", question)), TERM_TOKENS, TERM_TEMPERATURE);
|
||||||
|
body.add("tools", JsonParser.parseString(TOOLS).getAsJsonArray());
|
||||||
|
body.add("tool_choice", JsonParser.parseString(TOOL_CHOICE).getAsJsonObject());
|
||||||
|
|
||||||
|
JsonObject message = message(post(key, body));
|
||||||
|
if (message == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
JsonElement calls = message.get("tool_calls");
|
||||||
|
// No tool_calls at all is a plain no-result, not a malformed response:
|
||||||
|
// the model answered in prose instead. Nothing to warn about.
|
||||||
|
if (calls == null || !calls.isJsonArray() || calls.getAsJsonArray().isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// The arguments are a JSON *string* that has to be parsed again.
|
||||||
|
String arguments = calls.getAsJsonArray().get(0).getAsJsonObject()
|
||||||
|
.getAsJsonObject("function").get("arguments").getAsString();
|
||||||
|
JsonObject parsed = JsonParser.parseString(arguments).getAsJsonObject();
|
||||||
|
if (!parsed.has("termo")) {
|
||||||
|
warn.accept("IA: tool call sem \"termo\": " + AiText.forLog(arguments));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String term = parsed.get("termo").getAsString().trim();
|
||||||
|
return term.isEmpty() ? null : term;
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
// Nothing guarantees the shape of a tool call, and an unchecked
|
||||||
|
// throw from here would surface as a bare stack trace in the
|
||||||
|
// async worker rather than as a lost bit of grounding.
|
||||||
|
warn.accept("IA: tool call malformada: " + e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Answers a question. Returns null on any failure, including empty content.
|
||||||
|
*
|
||||||
|
* <p>Empty content is a failure and not a short answer: the model's hidden
|
||||||
|
* reasoning is charged against {@code max_tokens}, so too small a budget
|
||||||
|
* spends the whole allowance thinking and returns nothing at all. Measured
|
||||||
|
* twice at 400 tokens, which is why answers are given 1200.
|
||||||
|
*/
|
||||||
|
String answer(String key, String model, List<Turn> messages, int maxTokens, double temperature) {
|
||||||
|
JsonObject message = message(post(key, base(model, messages, maxTokens, temperature)));
|
||||||
|
if (message == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
JsonElement content = message.get("content");
|
||||||
|
// isJsonPrimitive before getAsString: on an object or array that call
|
||||||
|
// throws UnsupportedOperationException, and this runs outside post()'s
|
||||||
|
// try. Some OpenAI-compatible servers return content as an array of
|
||||||
|
// parts rather than a string.
|
||||||
|
if (content == null || !content.isJsonPrimitive() || content.getAsString().isBlank()) {
|
||||||
|
warn.accept("IA: resposta vazia com max_tokens=" + maxTokens
|
||||||
|
+ " (o raciocínio do modelo pode ter consumido o orçamento).");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return content.getAsString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonObject base(String model, List<Turn> messages, int maxTokens, double temperature) {
|
||||||
|
JsonArray array = new JsonArray();
|
||||||
|
for (Turn msg : messages) {
|
||||||
|
JsonObject object = new JsonObject();
|
||||||
|
object.addProperty("role", msg.role());
|
||||||
|
object.addProperty("content", msg.content());
|
||||||
|
array.add(object);
|
||||||
|
}
|
||||||
|
JsonObject body = new JsonObject();
|
||||||
|
body.addProperty("model", model);
|
||||||
|
body.add("messages", array);
|
||||||
|
body.addProperty("max_tokens", maxTokens);
|
||||||
|
body.addProperty("temperature", temperature);
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POSTs and parses. The request timeout lives in {@link HttpFetcher}, which
|
||||||
|
* owns the connection; there is no retry, because {@code /ia} already runs
|
||||||
|
* on a per-player cooldown and a silent retry would double the wait a player
|
||||||
|
* sees with no way to tell why.
|
||||||
|
*/
|
||||||
|
private JsonObject post(String key, JsonObject body) {
|
||||||
|
try {
|
||||||
|
return JsonParser.parseString(fetcher.postJson(url, body.toString(), key))
|
||||||
|
.getAsJsonObject();
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
// Swallowing this would leave an async worker running through a
|
||||||
|
// plugin disable or reload as if nothing had happened.
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
warn.accept("IA: chamada interrompida.");
|
||||||
|
return null;
|
||||||
|
} catch (Exception e) {
|
||||||
|
// The host is named because MiniMax's regional endpoints reject
|
||||||
|
// each other's keys: without it a wrong-host misconfiguration and
|
||||||
|
// an expired key produce the same log line.
|
||||||
|
redacted(key, "IA: falha na chamada a " + url + ": " + e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Warns with any occurrence of the key removed.
|
||||||
|
*
|
||||||
|
* <p>This is the one place an exception could carry it. A key with a stray
|
||||||
|
* newline in it — two lines pasted into {@code minimax.key}, which
|
||||||
|
* {@code trim()} does not fix — makes the JDK reject the Authorization
|
||||||
|
* header with {@code invalid header value: "Bearer sk-…"}, quoting the
|
||||||
|
* whole value back. That throws before the request leaves, so it arrives
|
||||||
|
* here as a plain call failure and would otherwise be logged verbatim.
|
||||||
|
*/
|
||||||
|
private void redacted(String key, String message) {
|
||||||
|
warn.accept(key == null || key.isEmpty() ? message : message.replace(key, "***"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The first choice's message, or null if the response reported a failure.
|
||||||
|
*
|
||||||
|
* <p>Every step checks the <em>type</em> it is about to assume, not just
|
||||||
|
* the presence of the key. This is called outside {@link #post}'s try, and
|
||||||
|
* Gson's {@code getAsJsonObject}/{@code getAsInt} throw unchecked on JSON
|
||||||
|
* that parses but has the wrong shape — {@code {"base_resp":"texto"}} or
|
||||||
|
* {@code {"choices":["texto"]}} — which would escape to the async worker as
|
||||||
|
* a bare stack trace instead of the null this method promises.
|
||||||
|
*/
|
||||||
|
private JsonObject message(JsonObject root) {
|
||||||
|
if (root == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// HTTP 200 does not mean success here: MiniMax reports application
|
||||||
|
// errors — bad key, no balance, rate limit — with a 200 and a non-zero
|
||||||
|
// status_code, so checking the HTTP status alone misses all of them.
|
||||||
|
JsonElement baseResp = root.get("base_resp");
|
||||||
|
if (baseResp != null && baseResp.isJsonObject()) {
|
||||||
|
JsonObject base = baseResp.getAsJsonObject();
|
||||||
|
JsonElement status = base.get("status_code");
|
||||||
|
if (status != null && status.isJsonPrimitive() && status.getAsJsonPrimitive().isNumber()
|
||||||
|
&& status.getAsInt() != 0) {
|
||||||
|
warn.accept("IA: recusada, base_resp " + AiText.forLog(base.toString()));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JsonElement choices = root.get("choices");
|
||||||
|
if (choices == null || !choices.isJsonArray() || choices.getAsJsonArray().isEmpty()) {
|
||||||
|
warn.accept("IA: resposta sem \"choices\": " + AiText.forLog(root.toString()));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
JsonElement first = choices.getAsJsonArray().get(0);
|
||||||
|
JsonElement message = first.isJsonObject() ? first.getAsJsonObject().get("message") : null;
|
||||||
|
if (message == null || !message.isJsonObject()) {
|
||||||
|
warn.accept("IA: choices com formato inesperado: " + AiText.forLog(root.toString()));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return message.getAsJsonObject();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,478 @@
|
|||||||
|
package dev.marcospaulo.canalhandia;
|
||||||
|
|
||||||
|
import org.bukkit.Bukkit;
|
||||||
|
import org.bukkit.Material;
|
||||||
|
import org.bukkit.inventory.ItemStack;
|
||||||
|
import org.bukkit.inventory.Recipe;
|
||||||
|
import org.bukkit.inventory.RecipeChoice;
|
||||||
|
import org.bukkit.inventory.ShapedRecipe;
|
||||||
|
import org.bukkit.inventory.ShapelessRecipe;
|
||||||
|
|
||||||
|
import java.text.Normalizer;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Iterator;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recipes for {@code /ia}, from the two places they can actually be had.
|
||||||
|
*
|
||||||
|
* <p>The wiki cannot supply them: MediaWiki's {@code explaintext} strips
|
||||||
|
* tables, and every recipe lives in one. The fire-resistance article comes back
|
||||||
|
* as 1086 characters for that reason, and the model still answered "não tenho
|
||||||
|
* certeza" grounded on it.
|
||||||
|
*
|
||||||
|
* <p><strong>Crafting</strong> comes from {@link Bukkit#recipeIterator()} — the
|
||||||
|
* running server's own data, authoritative for this exact version, free and
|
||||||
|
* instant. Player questions are Portuguese and {@link Material} names are
|
||||||
|
* English, so {@link Wiki#englishTitle} bridges the two.
|
||||||
|
*
|
||||||
|
* <p><strong>Brewing</strong> comes from {@link #BREWING} below, because Bukkit
|
||||||
|
* does not expose it. Checked against the 26.2 API: the {@link Recipe}
|
||||||
|
* implementations are shaped, shapeless, cooking, smithing, stonecutting,
|
||||||
|
* transmute, merchant and complex — there is no brewing recipe type;
|
||||||
|
* {@code PotionBrewer} has {@code addPotionMix}, {@code removePotionMix} and
|
||||||
|
* {@code resetPotionMixes} but no getter and no iterator; and vanilla brewing
|
||||||
|
* is hardcoded in {@code PotionBrewing} rather than registered as a recipe, so
|
||||||
|
* {@code recipeIterator()} never yields it. A table is the only option, and
|
||||||
|
* potions are the questions that motivated this whole feature.
|
||||||
|
*/
|
||||||
|
final class RecipeBook {
|
||||||
|
|
||||||
|
/** Chat is narrow and the model only needs a sample. */
|
||||||
|
private static final int MAX_RECIPES = 3;
|
||||||
|
/**
|
||||||
|
* A {@link RecipeChoice.MaterialChoice} built from a tag can hold dozens of
|
||||||
|
* materials ("any planks", "any log"). Printing all of them would bury the
|
||||||
|
* shape it belongs to.
|
||||||
|
*/
|
||||||
|
private static final int MAX_CHOICES = 4;
|
||||||
|
|
||||||
|
private RecipeBook() {
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Question parsing (pure — unit tested)
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** True if the question looks like it is asking how to make something. */
|
||||||
|
static boolean isRecipeQuestion(String question) {
|
||||||
|
if (question == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String q = plain(question);
|
||||||
|
return q.contains("receita") || q.contains("como faz") || q.contains("como faco")
|
||||||
|
|| q.contains("como fazer") || q.contains("como criar")
|
||||||
|
|| q.contains("como craft") || q.contains("crafta")
|
||||||
|
|| q.contains("como se faz") || q.contains("como fabricar");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Words that only ever introduce the question. Stripped from the front so
|
||||||
|
* "como faço uma espada de diamante" is searched as "espada de diamante" —
|
||||||
|
* the wiki finds the article far more reliably without the preamble.
|
||||||
|
*
|
||||||
|
* <p>Leading position only. "de" is in here, but "espada de diamante" keeps
|
||||||
|
* its "de" because stripping stops at the first word that is not listed.
|
||||||
|
*/
|
||||||
|
private static final Set<String> LEADING_NOISE = Set.of(
|
||||||
|
"qual", "quais", "e", "o", "a", "os", "as", "um", "uma", "receita", "receitas",
|
||||||
|
"de", "do", "da", "dos", "das", "como", "se", "faz", "faco", "fazer",
|
||||||
|
"criar", "crio", "cria", "craftar", "crafta", "crafto", "craftear", "fabricar",
|
||||||
|
"montar", "monta", "para", "pra", "no", "na", "minecraft");
|
||||||
|
|
||||||
|
private static final Pattern WHITESPACE = Pattern.compile("\\s+");
|
||||||
|
private static final Pattern ACCENTS = Pattern.compile("\\p{M}+");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The thing the question is about: the question with its interrogative
|
||||||
|
* preamble removed. Accents and case are preserved, because this is what
|
||||||
|
* gets searched on the Portuguese wiki.
|
||||||
|
*/
|
||||||
|
static String subject(String question) {
|
||||||
|
if (question == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String cleaned = question.replace("?", " ").replace("!", " ").trim();
|
||||||
|
if (cleaned.isEmpty()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String[] words = WHITESPACE.split(cleaned);
|
||||||
|
int start = 0;
|
||||||
|
while (start < words.length && LEADING_NOISE.contains(plain(words[start]))) {
|
||||||
|
start++;
|
||||||
|
}
|
||||||
|
// Every word was noise ("como se faz?"). There is no subject to look
|
||||||
|
// up, and returning the preamble would search the wiki for "como faz".
|
||||||
|
if (start == words.length) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return String.join(" ", List.of(words).subList(start, words.length));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lowercased, stripped of accents and with hyphens as spaces, so "Poção"
|
||||||
|
* and "pocao" compare equal and the in-game "Mestre-Tartaruga" reaches a
|
||||||
|
* table keyed on "mestre tartaruga".
|
||||||
|
*/
|
||||||
|
private static String plain(String text) {
|
||||||
|
String lower = text.toLowerCase(Locale.ROOT).replace('-', ' ');
|
||||||
|
return ACCENTS.matcher(Normalizer.normalize(lower, Normalizer.Form.NFD)).replaceAll("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Brewing (pure — unit tested)
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base potions, keyed by the accent-free Portuguese name of the effect.
|
||||||
|
*
|
||||||
|
* <p>Insertion-ordered only for readability; lookup picks the longest
|
||||||
|
* matching key, so "resistencia ao fogo" wins over any shorter key that
|
||||||
|
* also appears. Every one starts from the awkward potion described in
|
||||||
|
* {@link #AWKWARD} unless it says otherwise.
|
||||||
|
*/
|
||||||
|
private static final Map<String, String> BREWING = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
private static final String AWKWARD =
|
||||||
|
"Base: Frasco de Água + Fungo do Nether (nether wart) = Poção Estranha.";
|
||||||
|
|
||||||
|
private static final String MODIFIERS =
|
||||||
|
"Modificadores: Pó de Pedra Luminosa deixa mais forte (II), "
|
||||||
|
+ "Redstone aumenta a duração, Pólvora transforma em arremessável, "
|
||||||
|
+ "Bafo do Dragão em prolongada.";
|
||||||
|
|
||||||
|
static {
|
||||||
|
BREWING.put("estranha", "Poção Estranha: Frasco de Água + Fungo do Nether. "
|
||||||
|
+ "Ela não faz nada sozinha; é a base de quase todas as outras.");
|
||||||
|
BREWING.put("resistencia ao fogo", "Poção de Resistência ao Fogo: "
|
||||||
|
+ AWKWARD + " Depois adicione Creme de Magma.");
|
||||||
|
BREWING.put("forca", "Poção de Força: " + AWKWARD + " Depois adicione Pó de Blaze.");
|
||||||
|
BREWING.put("cura", "Poção de Cura: " + AWKWARD
|
||||||
|
+ " Depois adicione Fatia de Melancia Reluzente.");
|
||||||
|
BREWING.put("regeneracao", "Poção de Regeneração: " + AWKWARD
|
||||||
|
+ " Depois adicione Lágrima de Ghast.");
|
||||||
|
// "Agilidade" is the item's in-game name; "Velocidade" is the effect's.
|
||||||
|
// Players type both, so both are keys.
|
||||||
|
BREWING.put("agilidade", "Poção de Agilidade (Velocidade): " + AWKWARD
|
||||||
|
+ " Depois adicione Açúcar.");
|
||||||
|
BREWING.put("velocidade", "Poção de Agilidade (Velocidade): " + AWKWARD
|
||||||
|
+ " Depois adicione Açúcar.");
|
||||||
|
BREWING.put("rapidez", "Poção de Agilidade (Velocidade): " + AWKWARD
|
||||||
|
+ " Depois adicione Açúcar.");
|
||||||
|
BREWING.put("visao noturna", "Poção de Visão Noturna: " + AWKWARD
|
||||||
|
+ " Depois adicione Cenoura Dourada.");
|
||||||
|
BREWING.put("respiracao aquatica", "Poção de Respiração Aquática: " + AWKWARD
|
||||||
|
+ " Depois adicione Baiacu.");
|
||||||
|
BREWING.put("salto", "Poção de Salto: " + AWKWARD + " Depois adicione Pé de Coelho.");
|
||||||
|
BREWING.put("queda lenta", "Poção de Queda Lenta: " + AWKWARD
|
||||||
|
+ " Depois adicione Membrana de Phantom.");
|
||||||
|
BREWING.put("veneno", "Poção de Veneno: " + AWKWARD + " Depois adicione Olho de Aranha.");
|
||||||
|
BREWING.put("mestre tartaruga", "Poção do Mestre-Tartaruga: " + AWKWARD
|
||||||
|
+ " Depois adicione Casco de Tartaruga.");
|
||||||
|
// The four below do not come from the awkward potion, which is the part
|
||||||
|
// players get wrong most often.
|
||||||
|
BREWING.put("fraqueza", "Poção de Fraqueza: Frasco de Água + Olho de Aranha Fermentado. "
|
||||||
|
+ "Não precisa de Fungo do Nether.");
|
||||||
|
BREWING.put("lentidao", "Poção de Lentidão: faça Poção de Velocidade ou de Salto "
|
||||||
|
+ "e adicione Olho de Aranha Fermentado.");
|
||||||
|
BREWING.put("invisibilidade", "Poção de Invisibilidade: faça Poção de Visão Noturna "
|
||||||
|
+ "e adicione Olho de Aranha Fermentado.");
|
||||||
|
// "Poção de Dano" is the item's in-game name. Longest-key-wins keeps
|
||||||
|
// the fuller phrasing working when a player types it.
|
||||||
|
BREWING.put("dano", "Poção de Dano (Dano Instantâneo): faça Poção de Cura "
|
||||||
|
+ "e adicione Olho de Aranha Fermentado.");
|
||||||
|
BREWING.put("dano instantaneo", "Poção de Dano (Dano Instantâneo): faça Poção de Cura "
|
||||||
|
+ "e adicione Olho de Aranha Fermentado.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keys matched on word boundaries, built once.
|
||||||
|
*
|
||||||
|
* <p>Bare {@code contains} hijacked real questions: "salto" sits inside
|
||||||
|
* <em>basalto</em>, so "como faço basalto" answered with the jump potion;
|
||||||
|
* "cura" sits inside <em>curar</em>, so "como faço para curar um aldeão
|
||||||
|
* zumbi" answered with the healing potion; "forca" sits inside
|
||||||
|
* <em>reforcar</em>. Because {@link #describe} consults brewing first,
|
||||||
|
* each of those took over the entire answer.
|
||||||
|
*/
|
||||||
|
private static final Map<Pattern, String> BREWING_PATTERNS = buildBrewingPatterns();
|
||||||
|
|
||||||
|
private static Map<Pattern, String> buildBrewingPatterns() {
|
||||||
|
Map<Pattern, String> patterns = new LinkedHashMap<>();
|
||||||
|
BREWING.forEach((key, value) ->
|
||||||
|
patterns.put(Pattern.compile("\\b" + Pattern.quote(key) + "\\b"), value));
|
||||||
|
return Collections.unmodifiableMap(patterns);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The brewing entry the question asks for, or null. Longest matching key
|
||||||
|
* wins, so "resistencia ao fogo" is not shadowed by a shorter key.
|
||||||
|
*/
|
||||||
|
static String brewing(String question) {
|
||||||
|
if (question == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String q = plain(question);
|
||||||
|
// The question must actually be about a potion. "poc" covers poção,
|
||||||
|
// poções, pocao and pocoes once accents are stripped. Without this
|
||||||
|
// gate an effect name alone is enough to hijack the answer, and the
|
||||||
|
// effect names are ordinary Portuguese words.
|
||||||
|
if (!q.contains("poc")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String best = null;
|
||||||
|
int bestLength = -1;
|
||||||
|
for (Map.Entry<Pattern, String> entry : BREWING_PATTERNS.entrySet()) {
|
||||||
|
Pattern key = entry.getKey();
|
||||||
|
if (key.matcher(q).find() && key.pattern().length() > bestLength) {
|
||||||
|
bestLength = key.pattern().length();
|
||||||
|
best = entry.getValue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best == null ? null : "Fabricação de poções (alambique):\n" + best + "\n" + MODIFIERS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Material lookup (pure — unit tested)
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every material keyed by its name lowercased with underscores as spaces,
|
||||||
|
* so "diamond sword" finds {@code DIAMOND_SWORD}.
|
||||||
|
*
|
||||||
|
* <p>Built once. The obvious alternative — rebuilding the names on each
|
||||||
|
* question — allocates two throwaway strings per material per question,
|
||||||
|
* which over 2154 materials is some 4300 allocations to answer one line of
|
||||||
|
* chat.
|
||||||
|
*
|
||||||
|
* <p>{@code Material.values()} is safe in a static initialiser and off a
|
||||||
|
* server; {@code Material.isItem()} is not, and throws
|
||||||
|
* {@code ExceptionInInitializerError} without one. Nothing here calls it,
|
||||||
|
* which is what keeps this class unit-testable.
|
||||||
|
*/
|
||||||
|
private static final Map<String, Material> BY_NAME;
|
||||||
|
|
||||||
|
static {
|
||||||
|
Map<String, Material> byName = new HashMap<>();
|
||||||
|
for (Material material : Material.values()) {
|
||||||
|
String name = material.name().toLowerCase(Locale.ROOT).replace('_', ' ');
|
||||||
|
// Legacy constants duplicate modern ones under a "legacy " prefix
|
||||||
|
// and have no recipes.
|
||||||
|
if (!name.startsWith("legacy ")) {
|
||||||
|
byName.put(name, material);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BY_NAME = Map.copyOf(byName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* English wiki titles that name a real item under a different word than the
|
||||||
|
* enum uses. Only titles verified by hand belong here.
|
||||||
|
*
|
||||||
|
* <p>This exists because the obvious generalisation — falling back to the
|
||||||
|
* longest material name contained in the title — is actively harmful. It
|
||||||
|
* was measured returning confidently wrong items: "Redstone Repeater" tied
|
||||||
|
* on length and resolved to {@code REDSTONE}, handing a player asking about
|
||||||
|
* repeaters the recipe for redstone dust; "Book and Quill" gave
|
||||||
|
* {@code BOOK}, "Minecart with Chest" gave {@code MINECART}, "Rabbit's Foot"
|
||||||
|
* gave {@code RABBIT} (raw meat), "Chestplate" gave {@code CHEST}. A
|
||||||
|
* plausible recipe for the wrong item is exactly the failure this whole
|
||||||
|
* feature exists to remove, and it is worse than no answer, because the
|
||||||
|
* model will state it confidently. A suffix rule fails the same way —
|
||||||
|
* "axe" is a suffix of "pickaxe".
|
||||||
|
*
|
||||||
|
* <p>So: exact match, this table, or null.
|
||||||
|
*/
|
||||||
|
private static final Map<String, Material> ALIASES = Map.ofEntries(
|
||||||
|
Map.entry("redstone repeater", Material.REPEATER),
|
||||||
|
Map.entry("redstone comparator", Material.COMPARATOR),
|
||||||
|
Map.entry("book and quill", Material.WRITABLE_BOOK),
|
||||||
|
Map.entry("minecart with chest", Material.CHEST_MINECART),
|
||||||
|
Map.entry("minecart with furnace", Material.FURNACE_MINECART),
|
||||||
|
Map.entry("minecart with hopper", Material.HOPPER_MINECART),
|
||||||
|
Map.entry("minecart with tnt", Material.TNT_MINECART),
|
||||||
|
Map.entry("rabbit's foot", Material.RABBIT_FOOT),
|
||||||
|
Map.entry("jack o'lantern", Material.JACK_O_LANTERN),
|
||||||
|
Map.entry("steak", Material.COOKED_BEEF),
|
||||||
|
Map.entry("eye of ender", Material.ENDER_EYE),
|
||||||
|
Map.entry("nether quartz", Material.QUARTZ),
|
||||||
|
Map.entry("bottle o' enchanting", Material.EXPERIENCE_BOTTLE),
|
||||||
|
Map.entry("firework rocket", Material.FIREWORK_ROCKET),
|
||||||
|
Map.entry("clock", Material.CLOCK));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The material an English wiki title names, or null.
|
||||||
|
*
|
||||||
|
* <p>Exact match, then {@link #ALIASES}. Nothing else: a title this cannot
|
||||||
|
* resolve yields no grounding, which is the correct outcome. See the note on
|
||||||
|
* {@link #ALIASES} for why there is no fuzzy fallback.
|
||||||
|
*
|
||||||
|
* <p>Colour and material families resolve to null on purpose. There is no
|
||||||
|
* {@code Material.BED}, only {@code WHITE_BED} and its fifteen siblings, so
|
||||||
|
* "cama" is not answered rather than answered with an arbitrary colour.
|
||||||
|
*/
|
||||||
|
static Material materialFor(String englishTitle) {
|
||||||
|
if (englishTitle == null || englishTitle.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String title = englishTitle.toLowerCase(Locale.ROOT).replace('_', ' ').trim();
|
||||||
|
Material exact = BY_NAME.get(title);
|
||||||
|
return exact != null ? exact : ALIASES.get(title);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// The server-dependent part (verified in Task 13, not unit tested)
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plain text describing how to make whatever the question names, or null if
|
||||||
|
* nothing was found. Null must be passed through as "no grounding", never
|
||||||
|
* guessed at.
|
||||||
|
*
|
||||||
|
* <p>Brewing is answered first and without touching the network: potions
|
||||||
|
* have no crafting recipe, so the wiki round trip would only end in a null.
|
||||||
|
*/
|
||||||
|
static String describe(String question, Wiki wiki) {
|
||||||
|
String potion = brewing(question);
|
||||||
|
if (potion != null) {
|
||||||
|
return potion;
|
||||||
|
}
|
||||||
|
String term = subject(question);
|
||||||
|
if (term.isEmpty() || wiki == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Material material = materialFor(wiki.englishTitle(term));
|
||||||
|
if (material == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return craftingFor(material);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snapshot of the server's recipes, taken once on the main thread at
|
||||||
|
* enable. {@link Bukkit#recipeIterator()} is documented main-thread-only
|
||||||
|
* and has been observed misbehaving off it, so {@link #describe} iterates
|
||||||
|
* this copy instead. Datapack reloads after enable are not re-snapshotted.
|
||||||
|
*/
|
||||||
|
private static volatile List<Recipe> recipes = List.of();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snapshot the server's recipes on the main thread (at enable).
|
||||||
|
*/
|
||||||
|
static void preload() {
|
||||||
|
List<Recipe> snapshot = new ArrayList<>();
|
||||||
|
Iterator<Recipe> it = Bukkit.recipeIterator();
|
||||||
|
while (it.hasNext()) {
|
||||||
|
snapshot.add(it.next());
|
||||||
|
}
|
||||||
|
recipes = List.copyOf(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads the snapshot taken at enable. */
|
||||||
|
private static String craftingFor(Material material) {
|
||||||
|
List<String> lines = new ArrayList<>();
|
||||||
|
Iterator<Recipe> it = recipes.iterator();
|
||||||
|
while (it.hasNext() && lines.size() < MAX_RECIPES) {
|
||||||
|
Recipe recipe = it.next();
|
||||||
|
if (recipe.getResult().getType() != material) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (recipe instanceof ShapedRecipe shaped) {
|
||||||
|
lines.add(describeShaped(shaped));
|
||||||
|
} else if (recipe instanceof ShapelessRecipe shapeless) {
|
||||||
|
lines.add(describeShapeless(shapeless));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines.isEmpty() ? null
|
||||||
|
: "Receitas do servidor para " + pretty(material) + ":\n"
|
||||||
|
+ String.join("\n", lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String describeShaped(ShapedRecipe recipe) {
|
||||||
|
StringBuilder out = new StringBuilder("Bancada, formato ");
|
||||||
|
for (String row : recipe.getShape()) {
|
||||||
|
out.append('[').append(row).append(']');
|
||||||
|
}
|
||||||
|
List<String> parts = new ArrayList<>();
|
||||||
|
// getChoiceMap, not the deprecated getIngredientMap: the latter
|
||||||
|
// collapses "any plank" to one arbitrary stack, which reads as a recipe
|
||||||
|
// that only accepts oak.
|
||||||
|
recipe.getChoiceMap().forEach((symbol, choice) -> {
|
||||||
|
if (choice != null) {
|
||||||
|
parts.add(symbol + "=" + describeChoice(choice));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// The map's iteration order is not specified; sorting keeps the same
|
||||||
|
// recipe from being described two different ways on two calls.
|
||||||
|
Collections.sort(parts);
|
||||||
|
return parts.isEmpty() ? out.toString() : out.append(" onde ")
|
||||||
|
.append(String.join(", ", parts)).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String describeShapeless(ShapelessRecipe recipe) {
|
||||||
|
List<String> parts = new ArrayList<>();
|
||||||
|
// getChoiceList, not the deprecated getIngredientList, for the same
|
||||||
|
// reason as getChoiceMap above.
|
||||||
|
for (RecipeChoice choice : recipe.getChoiceList()) {
|
||||||
|
if (choice != null) {
|
||||||
|
parts.add(describeChoice(choice));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Guarded like describeShaped: a bare "Sem formato: " with nothing
|
||||||
|
// after it reads as a recipe with no ingredients.
|
||||||
|
return parts.isEmpty() ? "Sem formato" : "Sem formato: " + String.join(" + ", parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The brewing keys, for the invariant test that they are all matchable.
|
||||||
|
* An accented or uppercase key could never fire and nothing would notice.
|
||||||
|
*/
|
||||||
|
static Set<String> brewingKeys() {
|
||||||
|
return Collections.unmodifiableSet(BREWING.keySet());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One ingredient slot, which may accept any of several materials.
|
||||||
|
*
|
||||||
|
* <p>Package-private rather than private: {@code MaterialChoice} needs no
|
||||||
|
* running server, so this is the one part of the recipe rendering that can
|
||||||
|
* be covered before Task 13.
|
||||||
|
*/
|
||||||
|
static String describeChoice(RecipeChoice choice) {
|
||||||
|
List<String> names = new ArrayList<>();
|
||||||
|
if (choice instanceof RecipeChoice.MaterialChoice materials) {
|
||||||
|
for (Material material : materials.getChoices()) {
|
||||||
|
addOnce(names, pretty(material));
|
||||||
|
}
|
||||||
|
} else if (choice instanceof RecipeChoice.ExactChoice exact) {
|
||||||
|
for (ItemStack stack : exact.getChoices()) {
|
||||||
|
addOnce(names, pretty(stack.getType()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (names.isEmpty()) {
|
||||||
|
return "?";
|
||||||
|
}
|
||||||
|
if (names.size() > MAX_CHOICES) {
|
||||||
|
return String.join("/", names.subList(0, MAX_CHOICES)) + "/...";
|
||||||
|
}
|
||||||
|
return String.join("/", names);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void addOnce(List<String> names, String name) {
|
||||||
|
if (!names.contains(name)) {
|
||||||
|
names.add(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String pretty(Material material) {
|
||||||
|
return material.name().toLowerCase(Locale.ROOT).replace('_', ' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -252,7 +252,7 @@ final class Settings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
int aiMaxTokens() {
|
int aiMaxTokens() {
|
||||||
return Math.max(32, plugin.getConfig().getInt("ia.max-tokens", 300));
|
return Math.max(32, plugin.getConfig().getInt("ia.max-tokens", 1200));
|
||||||
}
|
}
|
||||||
|
|
||||||
double aiTemperature() {
|
double aiTemperature() {
|
||||||
@@ -297,6 +297,31 @@ final class Settings {
|
|||||||
set("ia.publico", value);
|
set("ia.publico", value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AiProfile aiProfile() {
|
||||||
|
return AiProfile.byKey(plugin.getConfig().getString("ia.perfil", "PRECISO"));
|
||||||
|
}
|
||||||
|
|
||||||
|
void aiProfile(AiProfile profile) {
|
||||||
|
set("ia.perfil", profile.name());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How much article text to send. Lead paragraphs alone were not enough. */
|
||||||
|
int aiWikiChars() {
|
||||||
|
return Math.max(500, plugin.getConfig().getInt("ia.wiki-caracteres", 7000));
|
||||||
|
}
|
||||||
|
|
||||||
|
int aiMemoryExchanges() {
|
||||||
|
return Math.max(0, plugin.getConfig().getInt("ia.memoria-perguntas", 3));
|
||||||
|
}
|
||||||
|
|
||||||
|
int aiMemoryMinutes() {
|
||||||
|
return Math.max(1, plugin.getConfig().getInt("ia.memoria-minutos", 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
String aiServerContext() {
|
||||||
|
return String.join(" ", plugin.getConfig().getStringList("ia.contexto"));
|
||||||
|
}
|
||||||
|
|
||||||
// --- content ------------------------------------------------------------
|
// --- content ------------------------------------------------------------
|
||||||
|
|
||||||
boolean categoryEnabled(Category category) {
|
boolean categoryEnabled(Category category) {
|
||||||
|
|||||||
@@ -0,0 +1,260 @@
|
|||||||
|
package dev.marcospaulo.canalhandia;
|
||||||
|
|
||||||
|
import com.google.gson.JsonArray;
|
||||||
|
import com.google.gson.JsonElement;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import com.google.gson.JsonParser;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.URLEncoder;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads articles from the Portuguese Minecraft Wiki.
|
||||||
|
*
|
||||||
|
* <p>Full article text, not {@code exintro}: grounded on lead paragraphs alone
|
||||||
|
* the model answered "não tenho certeza" to questions it could otherwise get
|
||||||
|
* right, because the specifics live further down the page.
|
||||||
|
*
|
||||||
|
* <p>Note that {@code explaintext} drops tables, so crafting and brewing
|
||||||
|
* recipes never appear here. Crafting comes from the running server's recipe
|
||||||
|
* registry instead; brewing is not exposed by Bukkit at all and comes from a
|
||||||
|
* hardcoded table. See {@link RecipeBook}.
|
||||||
|
*/
|
||||||
|
final class Wiki {
|
||||||
|
|
||||||
|
private static final String API = "https://pt.minecraft.wiki/api.php";
|
||||||
|
/** Entries are small and a server sees few distinct topics; this is generous. */
|
||||||
|
private static final int MAX_ENTRIES = 200;
|
||||||
|
/** {@code [\r\n]} rather than {@code \n}, so a CRLF article collapses too. */
|
||||||
|
private static final Pattern BLANK_LINES = Pattern.compile("[\r\n]{2,}");
|
||||||
|
|
||||||
|
record Article(String title, String text) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private final Fetcher fetcher;
|
||||||
|
private final int maxChars;
|
||||||
|
private final Consumer<String> warn;
|
||||||
|
/**
|
||||||
|
* Keyed by the normalised <em>search term</em>, not by article title: two
|
||||||
|
* terms that resolve to the same article get their own entry, which costs a
|
||||||
|
* duplicate copy of the text and saves a round trip on each. Newest-last,
|
||||||
|
* evicted at {@value #MAX_ENTRIES} entries. Lost on restart, which is fine.
|
||||||
|
*
|
||||||
|
* <p>Guarded by its own monitor. {@link Ai} calls {@link #lookup} from
|
||||||
|
* {@code runTaskAsynchronously}, so several players asking at once means
|
||||||
|
* several threads in here at the same time, and an unsynchronised
|
||||||
|
* {@code LinkedHashMap} can corrupt its own links under a concurrent write.
|
||||||
|
* The lock is never held across a network call.
|
||||||
|
*/
|
||||||
|
private final Map<String, Article> cache = new LinkedHashMap<>();
|
||||||
|
/**
|
||||||
|
* Portuguese search term to English wiki title, for {@link #englishTitle}.
|
||||||
|
* Separate from {@link #cache} because the two are populated independently:
|
||||||
|
* a recipe question needs the translation but not the article text.
|
||||||
|
*
|
||||||
|
* <p>Misses are cached as null values — hence {@code containsKey} rather
|
||||||
|
* than a null check at the read. A term the wiki has no English link for
|
||||||
|
* would otherwise re-fetch on every repeat of the same question.
|
||||||
|
*/
|
||||||
|
private final Map<String, String> englishTitles = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
Wiki(Fetcher fetcher, int maxChars) {
|
||||||
|
this(fetcher, maxChars, message -> {
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param warn where failures are reported. Grounding is the entire point of
|
||||||
|
* this class, and every one of its failure modes returns null, which the
|
||||||
|
* caller cannot tell apart from a question the wiki simply has no article
|
||||||
|
* for. A 403 — the exact failure the custom user agent exists to prevent —
|
||||||
|
* would revert {@code /ia} to the confidently wrong answers it was built
|
||||||
|
* to stop, and do it against a clean server log. Grounding you cannot
|
||||||
|
* tell is broken is grounding you do not have.
|
||||||
|
*/
|
||||||
|
Wiki(Fetcher fetcher, int maxChars, Consumer<String> warn) {
|
||||||
|
this.fetcher = fetcher;
|
||||||
|
// A config value of 0 would make every substring throw, and since the
|
||||||
|
// catch below turns that into null, grounding would switch itself off
|
||||||
|
// for good and silently. Cheaper to clamp than to diagnose.
|
||||||
|
this.maxChars = Math.max(1, maxChars);
|
||||||
|
this.warn = warn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The best article for a search term, or null if there is none. */
|
||||||
|
Article lookup(String term) {
|
||||||
|
String key = key(term);
|
||||||
|
synchronized (cache) {
|
||||||
|
Article cached = cache.get(key);
|
||||||
|
if (cached != null) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
String title = search(term);
|
||||||
|
if (title == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String text = extract(title);
|
||||||
|
if (text == null || text.isBlank()) {
|
||||||
|
warn.accept("Wiki: artigo \"" + title + "\" veio sem texto.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Article article = new Article(title, trim(text));
|
||||||
|
remember(key, article);
|
||||||
|
return article;
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
// Swallowing this would leave an async worker running through a
|
||||||
|
// plugin disable or reload as if nothing had happened.
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
warn.accept("Wiki: consulta de \"" + term + "\" interrompida.");
|
||||||
|
return null;
|
||||||
|
} catch (Exception e) {
|
||||||
|
// A wiki outage must not break the answer; the caller falls back
|
||||||
|
// to answering without a source and says so.
|
||||||
|
warn.accept("Wiki: falha ao consultar \"" + term + "\": " + e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The English wiki title for a Portuguese search term, or null if there is
|
||||||
|
* none.
|
||||||
|
*
|
||||||
|
* <p>This exists because {@link Material} names are English and players ask
|
||||||
|
* in Portuguese. Matching "espada de diamante" against {@code DIAMOND_SWORD}
|
||||||
|
* directly does not work: measured over twenty realistic questions, scanning
|
||||||
|
* every material name for a substring of the question resolved exactly one,
|
||||||
|
* and that one ("tridente" containing "trident") by coincidence rather than
|
||||||
|
* translation. The wiki already knows the mapping, so we ask it: the pt
|
||||||
|
* article carries an interlanguage link to its English counterpart, and
|
||||||
|
* "Espada de Diamante" → "Diamond Sword" uppercases straight onto the
|
||||||
|
* enum constant.
|
||||||
|
*
|
||||||
|
* <p>Not every article has the link — "Mesa de Encantamento" has none — and
|
||||||
|
* a missing link simply means no recipe grounding for that question. The
|
||||||
|
* caller must not guess from a null.
|
||||||
|
*/
|
||||||
|
String englishTitle(String term) {
|
||||||
|
String key = key(term);
|
||||||
|
synchronized (englishTitles) {
|
||||||
|
if (englishTitles.containsKey(key)) {
|
||||||
|
return englishTitles.get(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String english = null;
|
||||||
|
try {
|
||||||
|
String title = search(term);
|
||||||
|
if (title != null) {
|
||||||
|
english = langlink(title);
|
||||||
|
}
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
warn.accept("Wiki: tradução de \"" + term + "\" interrompida.");
|
||||||
|
// Deliberately not cached: an interrupt says nothing about whether
|
||||||
|
// the term has an English title, and caching null here would make
|
||||||
|
// one shutdown poison the term until the next restart.
|
||||||
|
return null;
|
||||||
|
} catch (Exception e) {
|
||||||
|
warn.accept("Wiki: falha ao traduzir \"" + term + "\": " + e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
synchronized (englishTitles) {
|
||||||
|
englishTitles.put(key, english);
|
||||||
|
while (englishTitles.size() > MAX_ENTRIES) {
|
||||||
|
englishTitles.remove(englishTitles.keySet().iterator().next());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return english;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The English interlanguage link of a pt article title, or null. */
|
||||||
|
private String langlink(String title) throws IOException, InterruptedException {
|
||||||
|
String url = API + "?action=query&prop=langlinks&lllang=en&format=json&titles="
|
||||||
|
+ URLEncoder.encode(title, StandardCharsets.UTF_8);
|
||||||
|
JsonObject root = JsonParser.parseString(fetcher.get(url)).getAsJsonObject();
|
||||||
|
JsonElement pages = root.has("query") ? root.getAsJsonObject("query").get("pages") : null;
|
||||||
|
if (pages == null || !pages.isJsonObject()) {
|
||||||
|
throw new IOException("langlinks sem 'pages': " + AiText.forLog(root.toString()));
|
||||||
|
}
|
||||||
|
JsonObject byPageId = pages.getAsJsonObject();
|
||||||
|
for (String key : byPageId.keySet()) {
|
||||||
|
JsonElement links = byPageId.getAsJsonObject(key).get("langlinks");
|
||||||
|
// An article with no English counterpart carries no langlinks key
|
||||||
|
// at all. That is a plain no-result, not a malformed response.
|
||||||
|
if (links != null && links.isJsonArray() && !links.getAsJsonArray().isEmpty()) {
|
||||||
|
// MediaWiki puts the title in "*", not in a named field.
|
||||||
|
JsonElement value = links.getAsJsonArray().get(0).getAsJsonObject().get("*");
|
||||||
|
if (value != null) {
|
||||||
|
return value.getAsString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String search(String term) throws IOException, InterruptedException {
|
||||||
|
String url = API + "?action=query&list=search&format=json&srlimit=1&srsearch="
|
||||||
|
+ URLEncoder.encode(term, StandardCharsets.UTF_8);
|
||||||
|
JsonObject root = JsonParser.parseString(fetcher.get(url)).getAsJsonObject();
|
||||||
|
JsonElement hits = root.has("query") ? root.getAsJsonObject("query").get("search") : null;
|
||||||
|
// Kept distinct from an outage so the two do not read alike in the log:
|
||||||
|
// MediaWiki answers some warning shapes with a query object and no
|
||||||
|
// search key, which would otherwise surface as a bare NullPointerException.
|
||||||
|
if (hits == null || !hits.isJsonArray()) {
|
||||||
|
throw new IOException("busca sem 'search': " + AiText.forLog(root.toString()));
|
||||||
|
}
|
||||||
|
JsonArray array = hits.getAsJsonArray();
|
||||||
|
return array.isEmpty() ? null : array.get(0).getAsJsonObject().get("title").getAsString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extract(String title) throws IOException, InterruptedException {
|
||||||
|
String url = API + "?action=query&prop=extracts&explaintext=1&format=json&titles="
|
||||||
|
+ URLEncoder.encode(title, StandardCharsets.UTF_8);
|
||||||
|
JsonObject root = JsonParser.parseString(fetcher.get(url)).getAsJsonObject();
|
||||||
|
JsonElement pages = root.has("query") ? root.getAsJsonObject("query").get("pages") : null;
|
||||||
|
if (pages == null || !pages.isJsonObject()) {
|
||||||
|
throw new IOException("extrato sem 'pages': " + AiText.forLog(root.toString()));
|
||||||
|
}
|
||||||
|
JsonObject byPageId = pages.getAsJsonObject();
|
||||||
|
for (String key : byPageId.keySet()) {
|
||||||
|
JsonObject page = byPageId.getAsJsonObject(key);
|
||||||
|
// A missing page carries "missing" and no extract. That is a plain
|
||||||
|
// no-result, not a malformed response.
|
||||||
|
if (page.has("extract")) {
|
||||||
|
return page.get("extract").getAsString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String trim(String text) {
|
||||||
|
String collapsed = BLANK_LINES.matcher(text).replaceAll("\n").trim();
|
||||||
|
return collapsed.length() > maxChars ? collapsed.substring(0, maxChars) : collapsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code Locale.ROOT} rather than the default locale: a server started
|
||||||
|
* under a Turkish locale lowercases "I" to a dotless "ı", and any code that
|
||||||
|
* later calls {@code Locale.setDefault} would leave earlier entries keyed
|
||||||
|
* under rules no lookup uses again.
|
||||||
|
*/
|
||||||
|
private static String key(String term) {
|
||||||
|
return term.toLowerCase(Locale.ROOT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void remember(String key, Article article) {
|
||||||
|
synchronized (cache) {
|
||||||
|
cache.put(key, article);
|
||||||
|
while (cache.size() > MAX_ENTRIES) {
|
||||||
|
cache.remove(cache.keySet().iterator().next());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -93,6 +93,10 @@ reacoes:
|
|||||||
java: "[🔥]"
|
java: "[🔥]"
|
||||||
texto: "[TOP]"
|
texto: "[TOP]"
|
||||||
comando: "top"
|
comando: "top"
|
||||||
|
errado:
|
||||||
|
java: "[❌]"
|
||||||
|
texto: "[ERRADO]"
|
||||||
|
comando: "errado"
|
||||||
|
|
||||||
# --- Jogos -------------------------------------------------------------------
|
# --- Jogos -------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -129,7 +133,9 @@ ia:
|
|||||||
modelo: "MiniMax-M2.7"
|
modelo: "MiniMax-M2.7"
|
||||||
|
|
||||||
# Tamanho da resposta pedida ao modelo, e o corte final no chat.
|
# Tamanho da resposta pedida ao modelo, e o corte final no chat.
|
||||||
max-tokens: 300
|
# 1200, não 300: o raciocínio oculto do M3 consome o orçamento e a resposta
|
||||||
|
# chega vazia quando o teto é baixo.
|
||||||
|
max-tokens: 1200
|
||||||
max-caracteres: 500
|
max-caracteres: 500
|
||||||
|
|
||||||
# Tamanho máximo da pergunta, em caracteres.
|
# Tamanho máximo da pergunta, em caracteres.
|
||||||
@@ -161,3 +167,22 @@ ia:
|
|||||||
terminal nem de Minecraft. Escreva em texto puro: nada de markdown,
|
terminal nem de Minecraft. Escreva em texto puro: nada de markdown,
|
||||||
asteriscos, crases ou emoji, porque o chat do Minecraft não formata nada
|
asteriscos, crases ou emoji, porque o chat do Minecraft não formata nada
|
||||||
disso.
|
disso.
|
||||||
|
|
||||||
|
# ECONOMICO pula a consulta à wiki (resposta rápida, sem fonte).
|
||||||
|
# PRECISO consulta a wiki (mais lento, mais correto). Troque em jogo com
|
||||||
|
# /ia perfil <nome>.
|
||||||
|
perfil: PRECISO
|
||||||
|
|
||||||
|
# Quantos caracteres do artigo da wiki enviar. Só a introdução não basta:
|
||||||
|
# a receita e os detalhes ficam mais abaixo na página.
|
||||||
|
wiki-caracteres: 7000
|
||||||
|
|
||||||
|
# Memória curta por jogador, para perguntas de seguimento ("e no nether?").
|
||||||
|
memoria-perguntas: 3
|
||||||
|
memoria-minutos: 10
|
||||||
|
|
||||||
|
# Fatos do servidor que a IA nunca teria como saber. Uma linha por fato.
|
||||||
|
contexto:
|
||||||
|
- "O servidor se chama Canalhandia e roda Minecraft 26.2 (Paper)."
|
||||||
|
- "Jogadores de Bedrock entram pelo Geyser e o nome deles começa com ponto."
|
||||||
|
- "O servidor tem BlueMap, voice chat e Distant Horizons."
|
||||||
|
|||||||
@@ -60,6 +60,13 @@ commands:
|
|||||||
description: Faz uma pergunta simples para a IA.
|
description: Faz uma pergunta simples para a IA.
|
||||||
usage: /ia <pergunta>
|
usage: /ia <pergunta>
|
||||||
aliases: [pergunta]
|
aliases: [pergunta]
|
||||||
|
iap:
|
||||||
|
description: Pergunta para a IA, resposta só para você.
|
||||||
|
usage: /iap <pergunta>
|
||||||
|
aliases: [iaprivado]
|
||||||
|
errado:
|
||||||
|
description: Reage com "errado" à última mensagem (resposta da IA).
|
||||||
|
usage: /errado
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
# Declared explicitly: an undeclared Bukkit permission falls back to op-only,
|
# Declared explicitly: an undeclared Bukkit permission falls back to op-only,
|
||||||
@@ -82,6 +89,15 @@ permissions:
|
|||||||
canalhandia.ia:
|
canalhandia.ia:
|
||||||
description: Permite usar /ia. Padrão op; o LuckPerms pode conceder a outros.
|
description: Permite usar /ia. Padrão op; o LuckPerms pode conceder a outros.
|
||||||
default: op
|
default: op
|
||||||
|
canalhandia.ia.privado:
|
||||||
|
description: Permite perguntar em privado com /iap. Padrão op; o LuckPerms pode conceder a outros.
|
||||||
|
default: op
|
||||||
|
canalhandia.ia.corrigir:
|
||||||
|
description: Permite registrar correções para as respostas da IA.
|
||||||
|
default: op
|
||||||
|
canalhandia.ia.perfil:
|
||||||
|
description: Permite trocar o perfil da IA entre economico e preciso.
|
||||||
|
default: op
|
||||||
canalhandia.isento:
|
canalhandia.isento:
|
||||||
description: Quem tem isto nunca é sorteado como assunto.
|
description: Quem tem isto nunca é sorteado como assunto.
|
||||||
default: false
|
default: false
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package dev.marcospaulo.canalhandia;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The key-cleaning half of {@link Ai}. The rest of that class needs a running
|
||||||
|
* server; {@code cleanKey} is pure and is where the credential is made safe to
|
||||||
|
* put in a header.
|
||||||
|
*/
|
||||||
|
class AiKeyTest {
|
||||||
|
|
||||||
|
private static final char LF = 10;
|
||||||
|
private static final char CR = 13;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void keepsAWellFormedKeyUnchanged() {
|
||||||
|
assertEquals("sk-abc123", Ai.cleanKey("sk-abc123"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void stripsSurroundingWhitespaceAndTrailingNewline() {
|
||||||
|
assertEquals("sk-abc123", Ai.cleanKey(" sk-abc123 " + LF));
|
||||||
|
assertEquals("sk-abc123", Ai.cleanKey("sk-abc123" + CR + LF));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The case trim() misses: a second line in the key file. */
|
||||||
|
@Test
|
||||||
|
void takesTheFirstLineRatherThanWeldingTheSecondOn() {
|
||||||
|
assertEquals("sk-abc123", Ai.cleanKey("sk-abc123" + LF + "# comentário"));
|
||||||
|
assertEquals("sk-abc123", Ai.cleanKey("sk-abc123" + LF + LF + "lixo"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void skipsLeadingBlankLines() {
|
||||||
|
assertEquals("sk-abc123", Ai.cleanKey(LF + " " + LF + "sk-abc123"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void removesControlCharactersFromWithinTheLine() {
|
||||||
|
assertEquals("sk-abc", Ai.cleanKey("sk-" + (char) 0 + "abc" + (char) 0x7F));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The case a deny-list of low control characters misses. Notepad and
|
||||||
|
* PowerShell {@code Out-File} put a U+FEFF byte order mark at the head of
|
||||||
|
* the file; the JDK rejects every char above U+00FF, quoting the header
|
||||||
|
* value back as it does.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void stripsAByteOrderMarkAndAnythingElseNonAscii() {
|
||||||
|
assertEquals("sk-abc123", Ai.cleanKey((char) 0xFEFF + "sk-abc123"));
|
||||||
|
assertEquals("sk-abc", Ai.cleanKey("sk-" + (char) 0x00E7 + "abc" + (char) 0x2013));
|
||||||
|
assertEquals("sk-abc", Ai.cleanKey("sk-" + (char) 0x00A0 + "abc"));
|
||||||
|
assertEquals("sk-abc", Ai.cleanKey("sk-" + (char) 0x0100 + "abc"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void nothingUsableIsNull() {
|
||||||
|
assertNull(Ai.cleanKey(null));
|
||||||
|
assertNull(Ai.cleanKey(""));
|
||||||
|
assertNull(Ai.cleanKey(" " + LF + " "));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The contract that matters, asserted end to end: anything {@code cleanKey}
|
||||||
|
* hands back is something {@link HttpFetcher} accepts, so a real key can
|
||||||
|
* never reach the JDK's header validator and be quoted back into a log.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void whateverSurvivesCleaningIsAcceptedAsABearer() {
|
||||||
|
String[] messy = {
|
||||||
|
"sk-abc123",
|
||||||
|
" sk-abc " + LF,
|
||||||
|
"sk-a" + LF + "b",
|
||||||
|
"sk" + (char) 9 + "-x",
|
||||||
|
"sk-x" + CR + LF + "# nota",
|
||||||
|
LF + "sk-y",
|
||||||
|
(char) 0xFEFF + "sk-bom",
|
||||||
|
"sk-" + (char) 0x00E7 + (char) 0x0100 + (char) 0xFFFD + "z",
|
||||||
|
};
|
||||||
|
for (String raw : messy) {
|
||||||
|
String key = Ai.cleanKey(raw);
|
||||||
|
assertNotNull(key, "nothing survived cleaning of " + raw);
|
||||||
|
assertDoesNotThrow(() -> HttpFetcher.checkBearer(key),
|
||||||
|
"HttpFetcher rejected a key cleanKey had approved");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package dev.marcospaulo.canalhandia;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class AiTextTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void stripsMarkdownEmphasis() {
|
||||||
|
assertEquals("use magma cream",
|
||||||
|
AiText.sanitise("use **magma cream**", 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void stripsEmojiAndSectionSigns() {
|
||||||
|
assertEquals("boa sorte",
|
||||||
|
AiText.sanitise("boa sorte 😄 §c", 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void stripsLeadingSlashesSoRepliesCannotLookLikeCommands() {
|
||||||
|
assertEquals("give me diamonds",
|
||||||
|
AiText.sanitise("//give me diamonds", 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void keepsPortugueseAccents() {
|
||||||
|
assertEquals("poção de resistência ao fogo",
|
||||||
|
AiText.sanitise("poção de resistência ao fogo", 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void truncatesToLimit() {
|
||||||
|
assertEquals("abc…", AiText.sanitise("abcdefg", 3));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The model leaked "搭档" and "contiennent" into Portuguese answers.
|
||||||
|
@Test
|
||||||
|
void detectsCjk() {
|
||||||
|
assertTrue(AiText.hasForeignScript("te aceite como搭档"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void plainPortugueseIsNotForeign() {
|
||||||
|
assertFalse(AiText.hasForeignScript("camelos são pacíficos e mansos"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// null must not reach the regex chain — it is total hardening for a method
|
||||||
|
// whose caller promises non-null but should not explode if it does not.
|
||||||
|
@Test
|
||||||
|
void sanitiseReturnsEmptyForNull() {
|
||||||
|
assertEquals("", AiText.sanitise(null, 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncating a supplementary character in half leaves an orphan surrogate
|
||||||
|
// that renders as a replacement box. The cut backs off one char.
|
||||||
|
@Test
|
||||||
|
void truncationBacksOffAHighSurrogate() {
|
||||||
|
// 𝄞 (U+1D11E) is two UTF-16 chars; it is not in the emoji strip range,
|
||||||
|
// so it survives to the truncation step. Cutting at index 4 would slice
|
||||||
|
// the high surrogate (D834) off its low half (DD1E).
|
||||||
|
assertEquals("abc…", AiText.sanitise("abc𝄞def", 4));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A lone "*" surrounded by spaces is a bullet, not markdown emphasis.
|
||||||
|
// The strip must only remove markers that flank non-space text.
|
||||||
|
@Test
|
||||||
|
void keepsStandaloneAsterisk() {
|
||||||
|
assertEquals("foo * bar", AiText.sanitise("foo * bar", 500));
|
||||||
|
// And real emphasis still strips.
|
||||||
|
assertEquals("use magma cream", AiText.sanitise("use *magma cream*", 500));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package dev.marcospaulo.canalhandia;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class ConversationsTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void keepsRecentExchangesForFollowUps() {
|
||||||
|
Conversations c = new Conversations(3, 10);
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
c.remember(id, "onde acho diamante?", "abaixo do Y 16");
|
||||||
|
assertEquals(2, c.history(id).size());
|
||||||
|
assertEquals("onde acho diamante?", c.history(id).get(0).content());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void dropsOldestBeyondLimit() {
|
||||||
|
Conversations c = new Conversations(2, 10);
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
c.remember(id, "q1", "a1");
|
||||||
|
c.remember(id, "q2", "a2");
|
||||||
|
c.remember(id, "q3", "a3");
|
||||||
|
assertEquals(4, c.history(id).size());
|
||||||
|
assertEquals("q2", c.history(id).get(0).content());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void expiresAfterTheWindow() {
|
||||||
|
Conversations c = new Conversations(3, 0);
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
c.remember(id, "q", "a");
|
||||||
|
assertTrue(c.history(id).isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void playersDoNotShareHistory() {
|
||||||
|
Conversations c = new Conversations(3, 10);
|
||||||
|
UUID a = UUID.randomUUID();
|
||||||
|
UUID b = UUID.randomUUID();
|
||||||
|
c.remember(a, "q", "resposta de A");
|
||||||
|
assertTrue(c.history(b).isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Roles alternate user/assistant, which is what the API expects. */
|
||||||
|
@Test
|
||||||
|
void alternatesUserAndAssistantRoles() {
|
||||||
|
Conversations c = new Conversations(3, 10);
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
c.remember(id, "q", "a");
|
||||||
|
List<MiniMax.Turn> history = c.history(id);
|
||||||
|
assertEquals("user", history.get(0).role());
|
||||||
|
assertEquals("assistant", history.get(1).role());
|
||||||
|
assertEquals("a", history.get(1).content());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A memory of zero exchanges must remember nothing at all, not even a key. */
|
||||||
|
@Test
|
||||||
|
void zeroExchangesRemembersNothing() {
|
||||||
|
Conversations c = new Conversations(0, 10);
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
c.remember(id, "q", "a");
|
||||||
|
assertTrue(c.history(id).isEmpty());
|
||||||
|
assertEquals(0, c.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void forgetDropsOnlyThatPlayer() {
|
||||||
|
Conversations c = new Conversations(3, 10);
|
||||||
|
UUID a = UUID.randomUUID();
|
||||||
|
UUID b = UUID.randomUUID();
|
||||||
|
c.remember(a, "q", "a");
|
||||||
|
c.remember(b, "q", "b");
|
||||||
|
c.forget(a);
|
||||||
|
assertTrue(c.history(a).isEmpty());
|
||||||
|
assertEquals(2, c.history(b).size());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Expired entries must not leave the player behind as a permanent key. */
|
||||||
|
@Test
|
||||||
|
void expiryEvictsThePlayerEntirely() {
|
||||||
|
Conversations c = new Conversations(3, 0);
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
c.remember(id, "q", "a");
|
||||||
|
c.history(id);
|
||||||
|
assertEquals(0, c.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A busy server must not accumulate a map entry per player forever. */
|
||||||
|
@Test
|
||||||
|
void boundsTheNumberOfRememberedPlayers() {
|
||||||
|
Conversations c = new Conversations(3, 10);
|
||||||
|
for (int i = 0; i < Conversations.MAX_PLAYERS + 50; i++) {
|
||||||
|
c.remember(UUID.randomUUID(), "q", "a");
|
||||||
|
}
|
||||||
|
assertEquals(Conversations.MAX_PLAYERS, c.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link Ai} calls this from {@code runTaskAsynchronously}, so several
|
||||||
|
* players asking at once means several threads in here at the same time.
|
||||||
|
* Unsynchronised, this corrupts the map or spins forever on a resize.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void survivesConcurrentUse() throws Exception {
|
||||||
|
Conversations c = new Conversations(3, 10);
|
||||||
|
int threads = 8;
|
||||||
|
CountDownLatch start = new CountDownLatch(1);
|
||||||
|
CountDownLatch done = new CountDownLatch(threads);
|
||||||
|
AtomicReference<Throwable> failure = new AtomicReference<>();
|
||||||
|
List<Thread> workers = new ArrayList<>();
|
||||||
|
for (int t = 0; t < threads; t++) {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Thread worker = new Thread(() -> {
|
||||||
|
try {
|
||||||
|
start.await();
|
||||||
|
for (int i = 0; i < 400; i++) {
|
||||||
|
c.remember(id, "q" + i, "a" + i);
|
||||||
|
assertFalse(c.history(id).isEmpty());
|
||||||
|
c.history(UUID.randomUUID());
|
||||||
|
}
|
||||||
|
} catch (Throwable e) {
|
||||||
|
failure.compareAndSet(null, e);
|
||||||
|
} finally {
|
||||||
|
done.countDown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
workers.add(worker);
|
||||||
|
worker.start();
|
||||||
|
}
|
||||||
|
start.countDown();
|
||||||
|
assertTrue(done.await(30, TimeUnit.SECONDS), "threads travaram");
|
||||||
|
for (Thread worker : workers) {
|
||||||
|
worker.join();
|
||||||
|
}
|
||||||
|
assertNull(failure.get(), String.valueOf(failure.get()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package dev.marcospaulo.canalhandia;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import java.util.List;
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class CorrectionsTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void matchesOnSharedSignificantWords() {
|
||||||
|
List<Corrections.Entry> all = List.of(
|
||||||
|
new Corrections.Entry("como pegar um camelo",
|
||||||
|
"camelos são mansos, basta pôr uma sela"));
|
||||||
|
assertEquals(1, Corrections.matching(all, "como eu pego camelo?").size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ignoresUnrelatedCorrections() {
|
||||||
|
List<Corrections.Entry> all = List.of(
|
||||||
|
new Corrections.Entry("como pegar um camelo", "…"));
|
||||||
|
assertTrue(Corrections.matching(all, "onde acho diamante?").isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shortWordsDoNotCreateMatches() {
|
||||||
|
List<Corrections.Entry> all = List.of(
|
||||||
|
new Corrections.Entry("o que e um creeper", "explode"));
|
||||||
|
assertTrue(Corrections.matching(all, "o que e um zumbi").isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void singleSharedContentWordMatches() {
|
||||||
|
// "camelo" is the only word > 4 chars shared with the conjugated
|
||||||
|
// question; the threshold is one, not two, precisely for this case.
|
||||||
|
List<Corrections.Entry> all = List.of(
|
||||||
|
new Corrections.Entry("como pegar um camelo", "ponha uma sela"));
|
||||||
|
assertEquals(1, Corrections.matching(all, "como eu pego camelo?").size());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package dev.marcospaulo.canalhandia;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only the bearer check is exercised here. It runs before the request is built
|
||||||
|
* and before any I/O, so these tests reach no network: the URL below is a
|
||||||
|
* reserved-for-invalid TLD and would fail loudly if one were ever attempted.
|
||||||
|
*/
|
||||||
|
class HttpFetcherTest {
|
||||||
|
|
||||||
|
private static final String URL = "https://nunca.invalid/v1/chat";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key file with a second line survives {@code trim()}. The resulting
|
||||||
|
* header value makes the JDK throw
|
||||||
|
* {@code invalid header value: "Bearer sk-…"} — the credential, in an
|
||||||
|
* exception message, in whatever log catches it.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void rejectsABearerWithANewlineBeforeBuildingTheRequest() {
|
||||||
|
String key = "sk-canalhandia-segredo" + (char) 10 + "# comentário";
|
||||||
|
IOException thrown = assertThrows(IOException.class,
|
||||||
|
() -> new HttpFetcher(5).postJson(URL, "{}", key));
|
||||||
|
assertFalse(thrown.getMessage().contains("segredo"),
|
||||||
|
"key leaked into the exception: " + thrown.getMessage());
|
||||||
|
assertTrue(thrown.getMessage().contains("imprimível"), thrown.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsCarriageReturnAndNulAndDelete() {
|
||||||
|
for (char c : new char[]{13, 0, 0x7F, 9}) {
|
||||||
|
String key = "sk-segredo" + c + "x";
|
||||||
|
IOException thrown = assertThrows(IOException.class,
|
||||||
|
() -> new HttpFetcher(5).postJson(URL, "{}", key),
|
||||||
|
"accepted a bearer containing char " + (int) c);
|
||||||
|
assertFalse(thrown.getMessage().contains("segredo"), thrown.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The message assertions matter here: {@code UnknownHostException} is an
|
||||||
|
* {@code IOException}, so a bare {@code assertThrows} would still pass if
|
||||||
|
* the guard regressed — while quietly doing real DNS I/O.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void rejectsAnEmptyOrNullBearer() {
|
||||||
|
for (String bearer : new String[]{"", null}) {
|
||||||
|
IOException thrown = assertThrows(IOException.class,
|
||||||
|
() -> new HttpFetcher(5).postJson(URL, "{}", bearer));
|
||||||
|
assertTrue(thrown.getMessage().contains("vazia"),
|
||||||
|
"guard did not fire; this may have hit the network: " + thrown);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key file saved by Notepad or PowerShell {@code Out-File} starts with a
|
||||||
|
* U+FEFF byte order mark. The JDK rejects every char above U+00FF, so a
|
||||||
|
* deny-list of low control characters alone lets this through to the
|
||||||
|
* validator that quotes the value back.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void rejectsAByteOrderMarkAndOtherNonAsciiCharacters() {
|
||||||
|
// Written as code points, not literals: a real BOM in this source
|
||||||
|
// file would be invisible to whoever next reads the test.
|
||||||
|
for (char c : new char[]{0xFEFF, 0x00A0, 0x00E7, 0x2013, 0xFFFD, 0x0100, 0x20}) {
|
||||||
|
String key = c + "sk-segredo";
|
||||||
|
IOException thrown = assertThrows(IOException.class,
|
||||||
|
() -> new HttpFetcher(5).postJson(URL, "{}", key),
|
||||||
|
"accepted a bearer containing U+" + Integer.toHexString(c));
|
||||||
|
assertFalse(thrown.getMessage().contains("segredo"), thrown.getMessage());
|
||||||
|
assertTrue(thrown.getMessage().contains("imprimível"), thrown.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void acceptsAnOrdinaryPrintableAsciiKey() {
|
||||||
|
assertDoesNotThrow(() -> HttpFetcher.checkBearer("sk-Abc123_-.~+/=:"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The message must be actionable without quoting the key, so it names the
|
||||||
|
* position and the likely cause instead.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void theMessageSaysWhereTheProblemIsWithoutQuotingTheKey() {
|
||||||
|
IOException thrown = assertThrows(IOException.class,
|
||||||
|
() -> new HttpFetcher(5).postJson(URL, "{}", "abc" + (char) 10 + "def"));
|
||||||
|
assertTrue(thrown.getMessage().contains("3"), thrown.getMessage());
|
||||||
|
assertFalse(thrown.getMessage().contains("abc"), thrown.getMessage());
|
||||||
|
assertFalse(thrown.getMessage().contains("def"), thrown.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
package dev.marcospaulo.canalhandia;
|
||||||
|
|
||||||
|
import com.google.gson.JsonArray;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import com.google.gson.JsonParser;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class MiniMaxTest {
|
||||||
|
|
||||||
|
private static Fetcher replying(String body) {
|
||||||
|
return new Fetcher() {
|
||||||
|
@Override
|
||||||
|
public String get(String url) {
|
||||||
|
throw new UnsupportedOperationException();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String postJson(String url, String json, String bearer) {
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void readsAnswerContent() {
|
||||||
|
MiniMax api = new MiniMax(replying(
|
||||||
|
"{\"choices\":[{\"message\":{\"content\":\"pólvora\"}}]}"));
|
||||||
|
assertEquals("pólvora", api.answer("k", "m", List.of(), 1200, 0.3));
|
||||||
|
}
|
||||||
|
|
||||||
|
// MiniMax reports application errors with HTTP 200 and a non-zero base_resp.
|
||||||
|
@Test
|
||||||
|
void treatsNonZeroBaseRespAsFailure() {
|
||||||
|
MiniMax api = new MiniMax(replying(
|
||||||
|
"{\"base_resp\":{\"status_code\":1004,\"status_msg\":\"bad key\"},\"choices\":[]}"));
|
||||||
|
assertNull(api.answer("k", "m", List.of(), 1200, 0.3));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hidden reasoning can consume the whole budget, leaving content empty.
|
||||||
|
@Test
|
||||||
|
void emptyContentIsNullNotBlank() {
|
||||||
|
MiniMax api = new MiniMax(replying(
|
||||||
|
"{\"choices\":[{\"message\":{\"content\":\"\"}}]}"));
|
||||||
|
assertNull(api.answer("k", "m", List.of(), 1200, 0.3));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void readsForcedToolArgument() {
|
||||||
|
MiniMax api = new MiniMax(replying(
|
||||||
|
"{\"choices\":[{\"message\":{\"tool_calls\":[{\"id\":\"1\",\"function\":"
|
||||||
|
+ "{\"name\":\"buscar_wiki\",\"arguments\":\"{\\\"termo\\\":\\\"Camelo\\\"}\"}}]}}]}"));
|
||||||
|
assertEquals("Camelo", api.searchTerm("k", "m", "como pego um camelo?"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void missingToolCallYieldsNull() {
|
||||||
|
MiniMax api = new MiniMax(replying(
|
||||||
|
"{\"choices\":[{\"message\":{\"content\":\"sei lá\"}}]}"));
|
||||||
|
assertNull(api.searchTerm("k", "m", "oi"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- request shape ------------------------------------------------------
|
||||||
|
|
||||||
|
/** Captures what was sent, so the request shape itself can be asserted. */
|
||||||
|
private static final class Recorder implements Fetcher {
|
||||||
|
String url;
|
||||||
|
String json;
|
||||||
|
String bearer;
|
||||||
|
private final String reply;
|
||||||
|
|
||||||
|
Recorder(String reply) {
|
||||||
|
this.reply = reply;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String get(String u) {
|
||||||
|
throw new UnsupportedOperationException();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String postJson(String u, String body, String token) {
|
||||||
|
this.url = u;
|
||||||
|
this.json = body;
|
||||||
|
this.bearer = token;
|
||||||
|
return reply;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void forcesTheBuscarWikiFunctionAtTemperatureZero() {
|
||||||
|
Recorder recorder = new Recorder("{\"choices\":[]}");
|
||||||
|
new MiniMax(recorder).searchTerm("k", "MiniMax-M2.7", "onde acho diamante?");
|
||||||
|
|
||||||
|
JsonObject body = JsonParser.parseString(recorder.json).getAsJsonObject();
|
||||||
|
assertEquals(0.0, body.get("temperature").getAsDouble(),
|
||||||
|
"term selection must be deterministic");
|
||||||
|
assertEquals(500, body.get("max_tokens").getAsInt());
|
||||||
|
// A regression that sent only the system prompt would still produce a
|
||||||
|
// tool call — just an unguided one — and every other test would pass.
|
||||||
|
JsonArray messages = body.getAsJsonArray("messages");
|
||||||
|
assertEquals(2, messages.size());
|
||||||
|
assertEquals("user", messages.get(1).getAsJsonObject().get("role").getAsString());
|
||||||
|
assertEquals("onde acho diamante?",
|
||||||
|
messages.get(1).getAsJsonObject().get("content").getAsString());
|
||||||
|
JsonObject function = body.getAsJsonArray("tools").get(0).getAsJsonObject()
|
||||||
|
.getAsJsonObject("function");
|
||||||
|
assertEquals("buscar_wiki", function.get("name").getAsString());
|
||||||
|
JsonObject parameters = function.getAsJsonObject("parameters");
|
||||||
|
assertEquals("object", parameters.get("type").getAsString());
|
||||||
|
assertTrue(parameters.getAsJsonObject("properties").has("termo"));
|
||||||
|
assertEquals("string", parameters.getAsJsonObject("properties")
|
||||||
|
.getAsJsonObject("termo").get("type").getAsString());
|
||||||
|
assertEquals("termo", parameters.getAsJsonArray("required").get(0).getAsString());
|
||||||
|
// "auto" let the model skip the search on exactly the questions it was
|
||||||
|
// most likely to get wrong, so the function is named explicitly.
|
||||||
|
JsonObject choice = body.getAsJsonObject("tool_choice");
|
||||||
|
assertEquals("function", choice.get("type").getAsString());
|
||||||
|
assertEquals("buscar_wiki", choice.getAsJsonObject("function").get("name").getAsString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void answerSendsNoToolsAndCarriesItsOwnBudget() {
|
||||||
|
Recorder recorder = new Recorder("{\"choices\":[{\"message\":{\"content\":\"ok\"}}]}");
|
||||||
|
new MiniMax(recorder).answer("k", "MiniMax-M2.7",
|
||||||
|
List.of(new MiniMax.Turn("system", "regras"), new MiniMax.Turn("user", "oi")),
|
||||||
|
1200, 0.3);
|
||||||
|
|
||||||
|
JsonObject body = JsonParser.parseString(recorder.json).getAsJsonObject();
|
||||||
|
assertFalse(body.has("tools"), "the answering call must give the model nothing to call");
|
||||||
|
assertFalse(body.has("tool_choice"));
|
||||||
|
assertEquals("MiniMax-M2.7", body.get("model").getAsString());
|
||||||
|
assertEquals(1200, body.get("max_tokens").getAsInt());
|
||||||
|
assertEquals(0.3, body.get("temperature").getAsDouble());
|
||||||
|
assertEquals(2, body.getAsJsonArray("messages").size());
|
||||||
|
assertEquals("system", body.getAsJsonArray("messages").get(0).getAsJsonObject()
|
||||||
|
.get("role").getAsString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void postsToTheDefaultEndpointUnlessOneIsGiven() {
|
||||||
|
Recorder standard = new Recorder("{\"choices\":[{\"message\":{\"content\":\"ok\"}}]}");
|
||||||
|
new MiniMax(standard).answer("k", "m", List.of(), 1200, 0.3);
|
||||||
|
assertEquals(MiniMax.URL, standard.url);
|
||||||
|
|
||||||
|
Recorder regional = new Recorder("{\"choices\":[{\"message\":{\"content\":\"ok\"}}]}");
|
||||||
|
new MiniMax(regional, "https://api.minimaxi.com/v1/text/chatcompletion_v2", warn -> {
|
||||||
|
}).answer("k", "m", List.of(), 1200, 0.3);
|
||||||
|
assertEquals("https://api.minimaxi.com/v1/text/chatcompletion_v2", regional.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- failures are reported, not swallowed -------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reportsTransportFailureRatherThanFailingSilently() {
|
||||||
|
List<String> warnings = new ArrayList<>();
|
||||||
|
MiniMax api = new MiniMax(new Fetcher() {
|
||||||
|
@Override
|
||||||
|
public String get(String url) {
|
||||||
|
throw new UnsupportedOperationException();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String postJson(String url, String json, String bearer) throws IOException {
|
||||||
|
throw new IOException("HTTP 401: {\"base_resp\":{\"status_code\":1004}}");
|
||||||
|
}
|
||||||
|
}, null, warnings::add);
|
||||||
|
|
||||||
|
assertNull(api.answer("k", "m", List.of(), 1200, 0.3));
|
||||||
|
assertEquals(1, warnings.size(), warnings.toString());
|
||||||
|
assertTrue(warnings.get(0).contains("401"), warnings.get(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reportsARefusalDistinctlyFromATransportFailure() {
|
||||||
|
List<String> warnings = new ArrayList<>();
|
||||||
|
MiniMax api = new MiniMax(replying(
|
||||||
|
"{\"base_resp\":{\"status_code\":1008,\"status_msg\":\"sem saldo\"}}"),
|
||||||
|
null, warnings::add);
|
||||||
|
|
||||||
|
assertNull(api.answer("k", "m", List.of(), 1200, 0.3));
|
||||||
|
assertEquals(1, warnings.size(), warnings.toString());
|
||||||
|
assertTrue(warnings.get(0).contains("1008"), warnings.get(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reportsAnEmptyAnswerBecauseThatMeansTheBudgetWasTooSmall() {
|
||||||
|
List<String> warnings = new ArrayList<>();
|
||||||
|
MiniMax api = new MiniMax(replying("{\"choices\":[{\"message\":{\"content\":\"\"}}]}"),
|
||||||
|
null, warnings::add);
|
||||||
|
|
||||||
|
assertNull(api.answer("k", "m", List.of(), 400, 0.3));
|
||||||
|
assertEquals(1, warnings.size(), warnings.toString());
|
||||||
|
assertTrue(warnings.get(0).contains("400"), warnings.get(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The key travels in the Authorization header and must never reach a log
|
||||||
|
* line. Every failure path is exercised with a distinctive key and the
|
||||||
|
* warnings are searched for it.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void theApiKeyNeverReachesAWarning() {
|
||||||
|
String key = "sk-canalhandia-segredo-nao-vaze";
|
||||||
|
List<String> warnings = new ArrayList<>();
|
||||||
|
|
||||||
|
Recorder recorder = new Recorder("{\"base_resp\":{\"status_code\":1004,"
|
||||||
|
+ "\"status_msg\":\"invalid api key\"}}");
|
||||||
|
MiniMax refused = new MiniMax(recorder, null, warnings::add);
|
||||||
|
assertNull(refused.answer(key, "m", List.of(new MiniMax.Turn("user", "oi")), 1200, 0.3));
|
||||||
|
assertNull(refused.searchTerm(key, "m", "oi"));
|
||||||
|
|
||||||
|
MiniMax broken = new MiniMax(new Fetcher() {
|
||||||
|
@Override
|
||||||
|
public String get(String url) {
|
||||||
|
throw new UnsupportedOperationException();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String postJson(String url, String json, String bearer) throws IOException {
|
||||||
|
throw new IOException("connection reset");
|
||||||
|
}
|
||||||
|
}, null, warnings::add);
|
||||||
|
assertNull(broken.answer(key, "m", List.of(), 1200, 0.3));
|
||||||
|
assertNull(broken.searchTerm(key, "m", "oi"));
|
||||||
|
|
||||||
|
MiniMax garbage = new MiniMax(replying("not json at all"), null, warnings::add);
|
||||||
|
assertNull(garbage.answer(key, "m", List.of(), 1200, 0.3));
|
||||||
|
|
||||||
|
assertFalse(warnings.isEmpty());
|
||||||
|
for (String warning : warnings) {
|
||||||
|
assertFalse(warning.contains(key), "key leaked into a log line: " + warning);
|
||||||
|
}
|
||||||
|
// It does reach the header, which is the only place it belongs.
|
||||||
|
assertEquals(key, recorder.bearer);
|
||||||
|
assertFalse(recorder.json.contains(key), "key leaked into the request body");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key with a stray newline — two lines pasted into minimax.key, which
|
||||||
|
* {@code trim()} does not fix — makes the JDK reject the Authorization
|
||||||
|
* header with an {@code IllegalArgumentException} whose message quotes the
|
||||||
|
* whole header value back, key included. Measured on temurin-25:
|
||||||
|
* {@code invalid header value: "Bearer sk-..."}. Logging that exception
|
||||||
|
* verbatim would print the key.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void aKeyThatBreaksTheHeaderIsRedactedFromTheWarning() {
|
||||||
|
String key = "sk-canalhandia-segredo" + (char) 10 + "linha2";
|
||||||
|
List<String> warnings = new ArrayList<>();
|
||||||
|
MiniMax api = new MiniMax(new Fetcher() {
|
||||||
|
@Override
|
||||||
|
public String get(String url) {
|
||||||
|
throw new UnsupportedOperationException();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String postJson(String url, String json, String bearer) {
|
||||||
|
throw new IllegalArgumentException("invalid header value: \"Bearer " + bearer + "\"");
|
||||||
|
}
|
||||||
|
}, null, warnings::add);
|
||||||
|
|
||||||
|
assertNull(api.answer(key, "m", List.of(), 1200, 0.3));
|
||||||
|
assertEquals(1, warnings.size(), warnings.toString());
|
||||||
|
assertFalse(warnings.get(0).contains("segredo"), warnings.get(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON that parses but has the wrong shape. Gson throws unchecked on these,
|
||||||
|
* and {@code message()} runs outside {@code post()}'s try, so without type
|
||||||
|
* checks they escape to the async Bukkit worker as a bare stack trace
|
||||||
|
* rather than the null the API promises.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void malformedButParseableResponsesReturnNullRatherThanThrowing() {
|
||||||
|
String[] shapes = {
|
||||||
|
"{\"choices\":[\"texto\"]}",
|
||||||
|
"{\"base_resp\":\"texto\"}",
|
||||||
|
"{\"choices\":[{\"message\":\"texto\"}]}",
|
||||||
|
"{\"base_resp\":{\"status_code\":\"nao-e-numero\"},\"choices\":[]}",
|
||||||
|
"{\"choices\":{\"nao\":\"array\"}}",
|
||||||
|
"[]",
|
||||||
|
"\"apenas uma string\"",
|
||||||
|
"null",
|
||||||
|
};
|
||||||
|
for (String shape : shapes) {
|
||||||
|
MiniMax api = new MiniMax(replying(shape), null, w -> {
|
||||||
|
});
|
||||||
|
assertNull(api.answer("k", "m", List.of(), 1200, 0.3), shape);
|
||||||
|
assertNull(api.searchTerm("k", "m", "oi"), shape);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Some OpenAI-compatible servers return content as an array of parts. */
|
||||||
|
@Test
|
||||||
|
void nonStringContentIsNullRatherThanThrowing() {
|
||||||
|
MiniMax api = new MiniMax(replying(
|
||||||
|
"{\"choices\":[{\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"oi\"}]}}]}"));
|
||||||
|
assertNull(api.answer("k", "m", List.of(), 1200, 0.3));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void theFailureWarningNamesTheHostSoAWrongRegionIsDiagnosable() {
|
||||||
|
List<String> warnings = new ArrayList<>();
|
||||||
|
new MiniMax(new Fetcher() {
|
||||||
|
@Override
|
||||||
|
public String get(String url) {
|
||||||
|
throw new UnsupportedOperationException();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String postJson(String url, String json, String bearer) throws IOException {
|
||||||
|
throw new IOException("HTTP 401");
|
||||||
|
}
|
||||||
|
}, "https://api.minimaxi.com/v1/text/chatcompletion_v2", warnings::add)
|
||||||
|
.answer("k", "m", List.of(), 1200, 0.3);
|
||||||
|
|
||||||
|
assertTrue(warnings.get(0).contains("api.minimaxi.com"), warnings.get(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- malformed tool calls -----------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void malformedToolArgumentsYieldNullRatherThanThrowing() {
|
||||||
|
List<String> warnings = new ArrayList<>();
|
||||||
|
MiniMax api = new MiniMax(replying(
|
||||||
|
"{\"choices\":[{\"message\":{\"tool_calls\":[{\"id\":\"1\",\"function\":"
|
||||||
|
+ "{\"name\":\"buscar_wiki\",\"arguments\":\"nao sou json\"}}]}}]}"),
|
||||||
|
null, warnings::add);
|
||||||
|
assertNull(api.searchTerm("k", "m", "oi"));
|
||||||
|
assertEquals(1, warnings.size(), warnings.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void emptyToolCallsListYieldsNull() {
|
||||||
|
MiniMax api = new MiniMax(replying(
|
||||||
|
"{\"choices\":[{\"message\":{\"tool_calls\":[]}}]}"));
|
||||||
|
assertNull(api.searchTerm("k", "m", "oi"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void blankTermYieldsNull() {
|
||||||
|
MiniMax api = new MiniMax(replying(
|
||||||
|
"{\"choices\":[{\"message\":{\"tool_calls\":[{\"function\":"
|
||||||
|
+ "{\"name\":\"buscar_wiki\",\"arguments\":\"{\\\"termo\\\":\\\" \\\"}\"}}]}}]}"));
|
||||||
|
assertNull(api.searchTerm("k", "m", "oi"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void trimsTheTerm() {
|
||||||
|
MiniMax api = new MiniMax(replying(
|
||||||
|
"{\"choices\":[{\"message\":{\"tool_calls\":[{\"function\":"
|
||||||
|
+ "{\"name\":\"buscar_wiki\",\"arguments\":\"{\\\"termo\\\":\\\" Creeper \\\"}\"}}]}}]}"));
|
||||||
|
assertEquals("Creeper", api.searchTerm("k", "m", "oi"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
package dev.marcospaulo.canalhandia;
|
||||||
|
|
||||||
|
import org.bukkit.Material;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Covers the pure half of {@link RecipeBook}. The other half calls
|
||||||
|
* {@code Bukkit.recipeIterator()} and is verified on a running server.
|
||||||
|
*
|
||||||
|
* <p>Nothing here may touch {@code Material.isItem()}: it throws
|
||||||
|
* {@code ExceptionInInitializerError} off a server, which is why
|
||||||
|
* {@link RecipeBook} resolves materials by name instead of filtering by it.
|
||||||
|
*/
|
||||||
|
class RecipeBookTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void recognisesTheWaysPlayersAskForARecipe() {
|
||||||
|
assertTrue(RecipeBook.isRecipeQuestion("como faço uma espada de diamante"));
|
||||||
|
assertTrue(RecipeBook.isRecipeQuestion("qual a receita da bigorna"));
|
||||||
|
assertTrue(RecipeBook.isRecipeQuestion("como fazer um baú"));
|
||||||
|
assertTrue(RecipeBook.isRecipeQuestion("como criar um funil"));
|
||||||
|
assertTrue(RecipeBook.isRecipeQuestion("como craftar um escudo"));
|
||||||
|
assertTrue(RecipeBook.isRecipeQuestion("como se faz pão"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void acceptsUnaccentedAndShoutedSpellings() {
|
||||||
|
// Players type without accents constantly, and the cedilla in "faço"
|
||||||
|
// is the single most common word in these questions.
|
||||||
|
assertTrue(RecipeBook.isRecipeQuestion("como faco uma cama"));
|
||||||
|
assertTrue(RecipeBook.isRecipeQuestion("COMO FAÇO UMA CAMA"));
|
||||||
|
assertTrue(RecipeBook.isRecipeQuestion("Qual A RECEITA do bolo"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ignoresQuestionsThatAreNotAboutRecipes() {
|
||||||
|
assertFalse(RecipeBook.isRecipeQuestion("quantos jogadores estão online"));
|
||||||
|
assertFalse(RecipeBook.isRecipeQuestion("onde encontro diamante"));
|
||||||
|
assertFalse(RecipeBook.isRecipeQuestion("o que come um camelo"));
|
||||||
|
assertFalse(RecipeBook.isRecipeQuestion(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void stripsThePreambleToLeaveTheSubject() {
|
||||||
|
assertEquals("espada de diamante",
|
||||||
|
RecipeBook.subject("como faço uma espada de diamante"));
|
||||||
|
assertEquals("picareta de ferro",
|
||||||
|
RecipeBook.subject("qual a receita da picareta de ferro"));
|
||||||
|
assertEquals("baú", RecipeBook.subject("como fazer um baú?"));
|
||||||
|
assertEquals("funil", RecipeBook.subject("como criar um funil"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void keepsInnerPrepositionsWhileStrippingLeadingOnes() {
|
||||||
|
// "de" is leading noise in "receita de ferro" but load-bearing inside
|
||||||
|
// "espada de diamante". Stripping stops at the first real word.
|
||||||
|
assertEquals("espada de diamante", RecipeBook.subject("receita de espada de diamante"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void yieldsNoSubjectWhenTheQuestionIsAllPreamble() {
|
||||||
|
// Searching the wiki for "como faz" would return an unrelated article
|
||||||
|
// and ground the answer on it.
|
||||||
|
assertEquals("", RecipeBook.subject("como se faz?"));
|
||||||
|
assertEquals("", RecipeBook.subject(""));
|
||||||
|
assertEquals("", RecipeBook.subject(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void translatesEnglishTitlesOntoMaterials() {
|
||||||
|
assertEquals(Material.DIAMOND_SWORD, RecipeBook.materialFor("Diamond Sword"));
|
||||||
|
assertEquals(Material.HOPPER, RecipeBook.materialFor("Hopper"));
|
||||||
|
assertEquals(Material.ANVIL, RecipeBook.materialFor("Anvil"));
|
||||||
|
assertEquals(Material.MAGMA_CREAM, RecipeBook.materialFor("Magma Cream"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolvesTheHandCheckedAliases() {
|
||||||
|
assertEquals(Material.REPEATER, RecipeBook.materialFor("Redstone Repeater"));
|
||||||
|
assertEquals(Material.WRITABLE_BOOK, RecipeBook.materialFor("Book and Quill"));
|
||||||
|
assertEquals(Material.CHEST_MINECART, RecipeBook.materialFor("Minecart with Chest"));
|
||||||
|
assertEquals(Material.RABBIT_FOOT, RecipeBook.materialFor("Rabbit's Foot"));
|
||||||
|
assertEquals(Material.JACK_O_LANTERN, RecipeBook.materialFor("Jack o'Lantern"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void neverResolvesAQualifiedTitleToTheWrongItem() {
|
||||||
|
// Every one of these was returned by a longest-substring fallback, and
|
||||||
|
// every one is a plausible recipe for something the player did not ask
|
||||||
|
// about — the exact failure this feature exists to remove. Null is the
|
||||||
|
// correct answer: no grounding beats confident grounding on the wrong
|
||||||
|
// item. These must never resolve by inference, only by ALIASES above.
|
||||||
|
assertNotEquals(Material.REDSTONE, RecipeBook.materialFor("Redstone Repeater"));
|
||||||
|
assertNotEquals(Material.BOOK, RecipeBook.materialFor("Book and Quill"));
|
||||||
|
assertNotEquals(Material.MINECART, RecipeBook.materialFor("Minecart with Chest"));
|
||||||
|
assertNotEquals(Material.RABBIT, RecipeBook.materialFor("Rabbit's Foot"));
|
||||||
|
|
||||||
|
assertNull(RecipeBook.materialFor("Chestplate"));
|
||||||
|
assertNull(RecipeBook.materialFor("Water Bottle"));
|
||||||
|
assertNull(RecipeBook.materialFor("Diamond Sword (item)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void colourFamiliesResolveToNullRatherThanAnArbitraryVariant() {
|
||||||
|
// There is no Material.BED, only WHITE_BED and fifteen siblings. A
|
||||||
|
// suffix rule would pick one at random — and would also resolve "axe"
|
||||||
|
// from "pickaxe", which is the same bug in a worse place.
|
||||||
|
assertNull(RecipeBook.materialFor("Bed"));
|
||||||
|
assertNull(RecipeBook.materialFor("Wool"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void returnsNoMaterialWhenTheTitleNamesNone() {
|
||||||
|
assertNull(RecipeBook.materialFor("Enchanting"));
|
||||||
|
assertNull(RecipeBook.materialFor("Pocket Edition v0.2.0 alpha"));
|
||||||
|
assertNull(RecipeBook.materialFor(null));
|
||||||
|
assertNull(RecipeBook.materialFor(" "));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void answersTheFireResistancePotionThatMotivatedThisFeature() {
|
||||||
|
// The exact question that failed while fully grounded on the wiki.
|
||||||
|
String answer = RecipeBook.brewing("como faço poção de resistência ao fogo");
|
||||||
|
|
||||||
|
assertNotNull(answer, "the question this whole task exists for");
|
||||||
|
assertTrue(answer.contains("Creme de Magma"), answer);
|
||||||
|
assertTrue(answer.contains("Fungo do Nether"), answer);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void picksTheLongestBrewingKeySoShorterOnesDoNotShadowIt() {
|
||||||
|
// "fogo" and "resistencia" both appear; only the full key is right.
|
||||||
|
String answer = RecipeBook.brewing("receita da pocao de resistencia ao fogo");
|
||||||
|
assertNotNull(answer);
|
||||||
|
assertTrue(answer.contains("Creme de Magma"), answer);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void coversTheBrewsThatDoNotStartFromTheAwkwardPotion() {
|
||||||
|
assertTrue(RecipeBook.brewing("como faço poção de fraqueza")
|
||||||
|
.contains("Olho de Aranha Fermentado"));
|
||||||
|
assertTrue(RecipeBook.brewing("como faço poção de invisibilidade")
|
||||||
|
.contains("Visão Noturna"));
|
||||||
|
assertTrue(RecipeBook.brewing("receita da poção de dano instantâneo")
|
||||||
|
.contains("Cura"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void explainsTheBrewingModifiers() {
|
||||||
|
String answer = RecipeBook.brewing("como faço poção de força");
|
||||||
|
assertTrue(answer.contains("Redstone"), answer);
|
||||||
|
assertTrue(answer.contains("Pólvora"), answer);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void returnsNoBrewingForNonPotionQuestions() {
|
||||||
|
assertNull(RecipeBook.brewing("como faço uma espada de diamante"));
|
||||||
|
assertNull(RecipeBook.brewing("como fazer uma cama"));
|
||||||
|
assertNull(RecipeBook.brewing(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void doesNotHijackQuestionsThatMerelyContainAnEffectName() {
|
||||||
|
// "salto" is inside basalto, "cura" inside curar, "forca" inside
|
||||||
|
// reforcar. describe() answers brewing first, so a false positive here
|
||||||
|
// takes over the entire answer.
|
||||||
|
assertNull(RecipeBook.brewing("como faço basalto"));
|
||||||
|
assertNull(RecipeBook.brewing("como faço para curar um aldeão zumbi"));
|
||||||
|
assertNull(RecipeBook.brewing("como faço para reforçar a base"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void stillMatchesWhenTheEffectNameIsAWholeWord() {
|
||||||
|
assertNotNull(RecipeBook.brewing("como faço poção de salto"));
|
||||||
|
assertNotNull(RecipeBook.brewing("como faço uma poção de cura"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void usesTheInGameItemNamesPlayersActuallySee() {
|
||||||
|
// Frasco de Água, not Garrafa de Água — the latter is the empty bottle.
|
||||||
|
assertTrue(RecipeBook.brewing("como faço poção de cura").contains("Frasco de Água"));
|
||||||
|
assertTrue(RecipeBook.brewing("como faço poção de salto").contains("Pé de Coelho"));
|
||||||
|
assertTrue(RecipeBook.brewing("como faço poção de cura")
|
||||||
|
.contains("Fatia de Melancia Reluzente"));
|
||||||
|
// Bafo do Dragão, not "Fogo do Dragão", which is not an item at all and
|
||||||
|
// would send a player hunting for something that does not exist.
|
||||||
|
assertTrue(RecipeBook.brewing("como faço poção de força").contains("Bafo do Dragão"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findsThePotionsUnderTheirInGameNames() {
|
||||||
|
// The item is "Poção de Agilidade"; Velocidade is the effect's name.
|
||||||
|
assertNotNull(RecipeBook.brewing("como faço poção de agilidade"));
|
||||||
|
// The item is "Poção de Dano"; players rarely type "Dano Instantâneo".
|
||||||
|
assertNotNull(RecipeBook.brewing("como faço poção de dano"));
|
||||||
|
// Hyphenated exactly as the game writes it.
|
||||||
|
assertNotNull(RecipeBook.brewing("como faço poção do mestre-tartaruga"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void everyBrewingKeyIsMatchable() {
|
||||||
|
// A key with an accent or an uppercase letter can never match, because
|
||||||
|
// lookup compares against accent-free lowercase text. Nothing else
|
||||||
|
// would notice: the entry would just silently never fire.
|
||||||
|
for (String key : RecipeBook.brewingKeys()) {
|
||||||
|
assertEquals(key.toLowerCase(java.util.Locale.ROOT), key, "key must be lowercase");
|
||||||
|
assertEquals(java.text.Normalizer.normalize(key, java.text.Normalizer.Form.NFD), key,
|
||||||
|
"key must be accent-free: " + key);
|
||||||
|
assertFalse(key.contains("-"), "hyphens are normalised to spaces: " + key);
|
||||||
|
assertNotNull(RecipeBook.brewing("como faço poção de " + key),
|
||||||
|
"key should match its own question: " + key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: describeChoice cannot be covered here after all. Constructing a
|
||||||
|
// RecipeChoice.MaterialChoice initialises org.bukkit.Registry, which needs
|
||||||
|
// a running server, and the class is sealed so it cannot be subclassed or
|
||||||
|
// faked either. MAX_CHOICES truncation and ingredient dedup are therefore
|
||||||
|
// verified in Task 13 along with the rest of the recipe rendering.
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package dev.marcospaulo.canalhandia;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guards the test harness itself. The rest of the suite is written test-first, so
|
||||||
|
* a silently broken harness (surefire not discovering JUnit 5, wrong JDK) would
|
||||||
|
* read as "all tests pass" instead of "no tests ran". This one is here to fail loudly.
|
||||||
|
*/
|
||||||
|
class SanityTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void harnessRuns() {
|
||||||
|
assertEquals(2, 1 + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
package dev.marcospaulo.canalhandia;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import java.util.Map;
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class WikiTest {
|
||||||
|
|
||||||
|
/** Returns canned bodies keyed by a substring of the requested URL. */
|
||||||
|
private static Fetcher stub(Map<String, String> byUrlFragment) {
|
||||||
|
return new Fetcher() {
|
||||||
|
@Override
|
||||||
|
public String get(String url) {
|
||||||
|
for (Map.Entry<String, String> e : byUrlFragment.entrySet()) {
|
||||||
|
if (url.contains(e.getKey())) {
|
||||||
|
return e.getValue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new AssertionError("unexpected url: " + url);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String postJson(String url, String json, String bearer) {
|
||||||
|
throw new UnsupportedOperationException();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findsArticleAndReturnsFullText() {
|
||||||
|
Wiki wiki = new Wiki(stub(Map.of(
|
||||||
|
"list=search", "{\"query\":{\"search\":[{\"title\":\"Camelo\"}]}}",
|
||||||
|
"prop=extracts", "{\"query\":{\"pages\":{\"1\":{\"title\":\"Camelo\","
|
||||||
|
+ "\"extract\":\"Um camelo pode ser equipado com uma sela.\"}}}}")),
|
||||||
|
7000);
|
||||||
|
|
||||||
|
Wiki.Article article = wiki.lookup("Camelo");
|
||||||
|
|
||||||
|
assertNotNull(article);
|
||||||
|
assertEquals("Camelo", article.title());
|
||||||
|
assertTrue(article.text().contains("sela"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void returnsNullWhenNothingMatches() {
|
||||||
|
Wiki wiki = new Wiki(stub(Map.of(
|
||||||
|
"list=search", "{\"query\":{\"search\":[]}}")), 7000);
|
||||||
|
assertNull(wiki.lookup("asdfghjkl"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void truncatesLongArticles() {
|
||||||
|
String longText = "x".repeat(9000);
|
||||||
|
Wiki wiki = new Wiki(stub(Map.of(
|
||||||
|
"list=search", "{\"query\":{\"search\":[{\"title\":\"T\"}]}}",
|
||||||
|
"prop=extracts", "{\"query\":{\"pages\":{\"1\":{\"title\":\"T\","
|
||||||
|
+ "\"extract\":\"" + longText + "\"}}}}")), 100);
|
||||||
|
assertEquals(100, wiki.lookup("T").text().length());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cachesByTitleSoRepeatQuestionsCostOneFetch() {
|
||||||
|
int[] calls = {0};
|
||||||
|
Fetcher counting = new Fetcher() {
|
||||||
|
@Override
|
||||||
|
public String get(String url) {
|
||||||
|
calls[0]++;
|
||||||
|
return url.contains("list=search")
|
||||||
|
? "{\"query\":{\"search\":[{\"title\":\"Creeper\"}]}}"
|
||||||
|
: "{\"query\":{\"pages\":{\"1\":{\"title\":\"Creeper\",\"extract\":\"polvora\"}}}}";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String postJson(String url, String json, String bearer) {
|
||||||
|
throw new UnsupportedOperationException();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Wiki wiki = new Wiki(counting, 7000);
|
||||||
|
wiki.lookup("Creeper");
|
||||||
|
int afterFirst = calls[0];
|
||||||
|
wiki.lookup("Creeper");
|
||||||
|
assertEquals(afterFirst, calls[0], "second lookup should be served from cache");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void networkFailureYieldsNullRatherThanThrowing() {
|
||||||
|
Wiki wiki = new Wiki(new Fetcher() {
|
||||||
|
@Override
|
||||||
|
public String get(String url) throws java.io.IOException {
|
||||||
|
throw new java.io.IOException("boom");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String postJson(String url, String json, String bearer) {
|
||||||
|
throw new UnsupportedOperationException();
|
||||||
|
}
|
||||||
|
}, 7000);
|
||||||
|
assertNull(wiki.lookup("Camelo"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Added in review. The stub above matches on URL substrings, so nothing
|
||||||
|
// yet asserted what the request URLs actually say: dropping explaintext=1
|
||||||
|
// would feed the model raw HTML as its source of truth, and switching back
|
||||||
|
// to raw concatenation would throw on the accented terms that are the norm
|
||||||
|
// in Portuguese, both while every test above stayed green. ---
|
||||||
|
|
||||||
|
/** Records every requested URL, then answers by URL fragment. */
|
||||||
|
private static Fetcher recording(java.util.List<String> seen, Map<String, String> byUrlFragment) {
|
||||||
|
return new Fetcher() {
|
||||||
|
@Override
|
||||||
|
public String get(String url) {
|
||||||
|
seen.add(url);
|
||||||
|
for (Map.Entry<String, String> e : byUrlFragment.entrySet()) {
|
||||||
|
if (url.contains(e.getKey())) {
|
||||||
|
return e.getValue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new AssertionError("unexpected url: " + url);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String postJson(String url, String json, String bearer) {
|
||||||
|
throw new UnsupportedOperationException();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Fetcher answering(Map<String, String> byUrlFragment) {
|
||||||
|
return recording(new java.util.ArrayList<>(), byUrlFragment);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void percentEncodesAccentedTerms() {
|
||||||
|
java.util.List<String> seen = new java.util.ArrayList<>();
|
||||||
|
Wiki wiki = new Wiki(recording(seen, Map.of(
|
||||||
|
"list=search", "{\"query\":{\"search\":[{\"title\":\"Poção\"}]}}",
|
||||||
|
"prop=extracts", "{\"query\":{\"pages\":{\"1\":{\"title\":\"Poção\","
|
||||||
|
+ "\"extract\":\"Uma poção de cura.\"}}}}")), 7000);
|
||||||
|
|
||||||
|
assertNotNull(wiki.lookup("poção"));
|
||||||
|
|
||||||
|
assertTrue(seen.get(0).contains("srsearch=po%C3%A7%C3%A3o"), seen.get(0));
|
||||||
|
assertTrue(seen.get(1).contains("titles=Po%C3%A7%C3%A3o"), seen.get(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void asksForTheFullPlainTextArticle() {
|
||||||
|
java.util.List<String> seen = new java.util.ArrayList<>();
|
||||||
|
Wiki wiki = new Wiki(recording(seen, Map.of(
|
||||||
|
"list=search", "{\"query\":{\"search\":[{\"title\":\"Camelo\"}]}}",
|
||||||
|
"prop=extracts", "{\"query\":{\"pages\":{\"1\":{\"title\":\"Camelo\","
|
||||||
|
+ "\"extract\":\"texto\"}}}}")), 7000);
|
||||||
|
|
||||||
|
wiki.lookup("Camelo");
|
||||||
|
|
||||||
|
assertTrue(seen.get(0).contains("srlimit=1"), seen.get(0));
|
||||||
|
assertTrue(seen.get(1).contains("explaintext=1"), seen.get(1));
|
||||||
|
assertFalse(seen.get(1).contains("exintro"),
|
||||||
|
"lead paragraphs alone made the model answer 'não tenho certeza'");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cacheIgnoresCase() {
|
||||||
|
int[] calls = {0};
|
||||||
|
Fetcher counting = new Fetcher() {
|
||||||
|
@Override
|
||||||
|
public String get(String url) {
|
||||||
|
calls[0]++;
|
||||||
|
return url.contains("list=search")
|
||||||
|
? "{\"query\":{\"search\":[{\"title\":\"Creeper\"}]}}"
|
||||||
|
: "{\"query\":{\"pages\":{\"1\":{\"title\":\"Creeper\",\"extract\":\"polvora\"}}}}";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String postJson(String url, String json, String bearer) {
|
||||||
|
throw new UnsupportedOperationException();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Wiki wiki = new Wiki(counting, 7000);
|
||||||
|
wiki.lookup("Creeper");
|
||||||
|
int afterFirst = calls[0];
|
||||||
|
assertNotNull(wiki.lookup("creeper"));
|
||||||
|
assertEquals(afterFirst, calls[0], "differing case is the same article");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void missingPageYieldsNull() {
|
||||||
|
Wiki wiki = new Wiki(answering(Map.of(
|
||||||
|
"list=search", "{\"query\":{\"search\":[{\"title\":\"Nada\"}]}}",
|
||||||
|
"prop=extracts", "{\"query\":{\"pages\":{\"-1\":{\"missing\":\"\"}}}}")), 7000);
|
||||||
|
assertNull(wiki.lookup("Nada"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void nonJsonBodyYieldsNull() {
|
||||||
|
// What a blocked request really looks like: an HTML error page, HTTP 200.
|
||||||
|
Wiki wiki = new Wiki(answering(Map.of(
|
||||||
|
"list=search", "<html><head><title>403 Forbidden</title></head></html>")), 7000);
|
||||||
|
assertNull(wiki.lookup("Camelo"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void malformedResponseYieldsNull() {
|
||||||
|
Wiki wiki = new Wiki(answering(Map.of(
|
||||||
|
"list=search", "{\"query\":{\"warnings\":{}}}")), 7000);
|
||||||
|
assertNull(wiki.lookup("Camelo"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reportsFailuresSoASilentlyBrokenWikiIsVisible() {
|
||||||
|
java.util.List<String> warnings = new java.util.ArrayList<>();
|
||||||
|
Wiki wiki = new Wiki(new Fetcher() {
|
||||||
|
@Override
|
||||||
|
public String get(String url) throws java.io.IOException {
|
||||||
|
throw new java.io.IOException("403 Forbidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String postJson(String url, String json, String bearer) {
|
||||||
|
throw new UnsupportedOperationException();
|
||||||
|
}
|
||||||
|
}, 7000, warnings::add);
|
||||||
|
|
||||||
|
assertNull(wiki.lookup("Camelo"));
|
||||||
|
|
||||||
|
assertEquals(1, warnings.size(), warnings.toString());
|
||||||
|
assertTrue(warnings.get(0).contains("Camelo"), warnings.get(0));
|
||||||
|
assertTrue(warnings.get(0).contains("403"), warnings.get(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void clampsNonPositiveMaxChars() {
|
||||||
|
// A misconfigured 0 would make every substring throw, silently turning
|
||||||
|
// grounding off for good.
|
||||||
|
Wiki wiki = new Wiki(answering(Map.of(
|
||||||
|
"list=search", "{\"query\":{\"search\":[{\"title\":\"T\"}]}}",
|
||||||
|
"prop=extracts", "{\"query\":{\"pages\":{\"1\":{\"title\":\"T\","
|
||||||
|
+ "\"extract\":\"texto longo\"}}}}")), 0);
|
||||||
|
assertEquals(1, wiki.lookup("T").text().length());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void restoresTheInterruptFlag() {
|
||||||
|
Wiki wiki = new Wiki(new Fetcher() {
|
||||||
|
@Override
|
||||||
|
public String get(String url) throws InterruptedException {
|
||||||
|
throw new InterruptedException("disable");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String postJson(String url, String json, String bearer) {
|
||||||
|
throw new UnsupportedOperationException();
|
||||||
|
}
|
||||||
|
}, 7000);
|
||||||
|
|
||||||
|
assertNull(wiki.lookup("Camelo"));
|
||||||
|
assertTrue(Thread.interrupted(), "the interrupt must survive the catch");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- englishTitle: Material names are English and players ask in
|
||||||
|
// Portuguese. Scanning material names for a substring of the question was
|
||||||
|
// measured at 1 hit in 20 real questions, so the translation runs through
|
||||||
|
// the wiki's interlanguage links instead. ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void translatesAPortugueseTermToItsEnglishTitle() {
|
||||||
|
Wiki wiki = new Wiki(answering(Map.of(
|
||||||
|
"list=search", "{\"query\":{\"search\":[{\"title\":\"Espada de Diamante\"}]}}",
|
||||||
|
"prop=langlinks", "{\"query\":{\"pages\":{\"1\":{\"langlinks\":"
|
||||||
|
+ "[{\"lang\":\"en\",\"*\":\"Diamond Sword\"}]}}}}")), 7000);
|
||||||
|
|
||||||
|
assertEquals("Diamond Sword", wiki.englishTitle("espada de diamante"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void asksOnlyForTheEnglishLink() {
|
||||||
|
java.util.List<String> seen = new java.util.ArrayList<>();
|
||||||
|
Wiki wiki = new Wiki(recording(seen, Map.of(
|
||||||
|
"list=search", "{\"query\":{\"search\":[{\"title\":\"Funil\"}]}}",
|
||||||
|
"prop=langlinks", "{\"query\":{\"pages\":{\"1\":{\"langlinks\":"
|
||||||
|
+ "[{\"lang\":\"en\",\"*\":\"Hopper\"}]}}}}")), 7000);
|
||||||
|
|
||||||
|
wiki.englishTitle("funil");
|
||||||
|
|
||||||
|
// Without lllang=en the response carries every language the article has,
|
||||||
|
// and the first one is not reliably English.
|
||||||
|
assertTrue(seen.get(1).contains("lllang=en"), seen.get(1));
|
||||||
|
assertTrue(seen.get(1).contains("titles=Funil"), seen.get(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void articlesWithoutAnEnglishLinkYieldNull() {
|
||||||
|
// Real case: "Mesa de Encantamento" has no langlink. No English title
|
||||||
|
// means no recipe grounding, which must not be guessed at.
|
||||||
|
Wiki wiki = new Wiki(answering(Map.of(
|
||||||
|
"list=search", "{\"query\":{\"search\":[{\"title\":\"Mesa de Encantamento\"}]}}",
|
||||||
|
"prop=langlinks", "{\"query\":{\"pages\":{\"1\":{\"title\":\"Mesa\"}}}}")), 7000);
|
||||||
|
|
||||||
|
assertNull(wiki.englishTitle("mesa de encantamento"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unknownTermYieldsNullWithoutAskingForLinks() {
|
||||||
|
Wiki wiki = new Wiki(answering(Map.of(
|
||||||
|
"list=search", "{\"query\":{\"search\":[]}}")), 7000);
|
||||||
|
// The stub throws on any URL it does not recognise, so a langlinks call
|
||||||
|
// here would fail the test rather than pass silently.
|
||||||
|
assertNull(wiki.englishTitle("asdfghjkl"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cachesTranslationsIncludingTheMisses() {
|
||||||
|
int[] calls = {0};
|
||||||
|
Fetcher counting = new Fetcher() {
|
||||||
|
@Override
|
||||||
|
public String get(String url) {
|
||||||
|
calls[0]++;
|
||||||
|
return url.contains("list=search")
|
||||||
|
? "{\"query\":{\"search\":[{\"title\":\"Mesa\"}]}}"
|
||||||
|
: "{\"query\":{\"pages\":{\"1\":{\"title\":\"Mesa\"}}}}";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String postJson(String url, String json, String bearer) {
|
||||||
|
throw new UnsupportedOperationException();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Wiki wiki = new Wiki(counting, 7000);
|
||||||
|
|
||||||
|
assertNull(wiki.englishTitle("mesa"));
|
||||||
|
int afterFirst = calls[0];
|
||||||
|
assertNull(wiki.englishTitle("mesa"));
|
||||||
|
|
||||||
|
assertEquals(afterFirst, calls[0], "a term with no English title must not re-fetch");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void translationFailureYieldsNullAndIsReported() {
|
||||||
|
java.util.List<String> warnings = new java.util.ArrayList<>();
|
||||||
|
Wiki wiki = new Wiki(new Fetcher() {
|
||||||
|
@Override
|
||||||
|
public String get(String url) throws java.io.IOException {
|
||||||
|
throw new java.io.IOException("403 Forbidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String postJson(String url, String json, String bearer) {
|
||||||
|
throw new UnsupportedOperationException();
|
||||||
|
}
|
||||||
|
}, 7000, warnings::add);
|
||||||
|
|
||||||
|
assertNull(wiki.englishTitle("funil"));
|
||||||
|
|
||||||
|
assertEquals(1, warnings.size(), warnings.toString());
|
||||||
|
assertTrue(warnings.get(0).contains("funil"), warnings.get(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void translationRestoresTheInterruptFlag() {
|
||||||
|
Wiki wiki = new Wiki(new Fetcher() {
|
||||||
|
@Override
|
||||||
|
public String get(String url) throws InterruptedException {
|
||||||
|
throw new InterruptedException("disable");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String postJson(String url, String json, String bearer) {
|
||||||
|
throw new UnsupportedOperationException();
|
||||||
|
}
|
||||||
|
}, 7000);
|
||||||
|
|
||||||
|
assertNull(wiki.englishTitle("funil"));
|
||||||
|
assertTrue(Thread.interrupted(), "the interrupt must survive the catch");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user