Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,46 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [2.0.0] - 2026-04-08

### Security

- Fix SQL injection in `database.js` `getPerformanceMetrics()` — parameterised query instead of
string interpolation

### New Features

- **Intelligent Model Selection**: Auto-upgrade to `sonar-pro` for multi-turn conversations
- **Citation Support**: Compact domain-only source footer on AI responses
- **Search Domain Filter**: Configurable `SEARCH_DOMAIN_FILTER` for authoritative gaming sources
- **Search Recency Filter**: Auto-detect time-sensitive queries and prioritise recent results
- **Token Usage Tracking**: Log prompt/completion/total tokens from every API response

### Architecture

- Remove 625 lines of duplicate inline socket handlers from `web-dashboard.js`
- Wire up extracted handler modules (`configHandlers`, `logsHandlers`, `networkHandlers`,
`reminderHandlers`, `serviceHandlers`)
- Replace all synchronous file I/O with async `fsPromises` equivalents
- Replace `execSync` with async `execPromise` for git SHA lookup
- Replace metrics `setInterval` with self-scheduling `setTimeout` to prevent overlap
- Cap `userStats` Map at `DEFAULT_MAX_ENTRIES` to prevent unbounded growth

### Bug Fixes

- Fix conversation cleanup using `CACHE.CLEANUP_INTERVAL_MS` (24h) instead of
`CONVERSATION_INACTIVITY_TIMEOUT_MS` (15min) as threshold
- Fix `getTimeAgo()` month/year calculation using calendar-aware math instead of `diffDays / 30`
- Add debug logging to silent `.catch()` blocks in web dashboard
- Convert help command from string concatenation to template literal

### Tests

- Add 32 new tests for Perplexity API features (model selection, search options, citations,
recency filter, token logging)
- Fix 3 pre-existing flaky time-ago tests
- **1,845 tests passing** across 182 suites, 0 failures

## [1.11.0] - 2026-01-07

### New Features
Expand Down
41 changes: 23 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,28 +1,30 @@
# Aszune AI Bot (v1.11.0)
# Aszune AI Bot (v2.0.0)

[![CI/CD](https://github.com/powerfulqa/aszune-ai-bot/actions/workflows/unified-ci.yml/badge.svg)](https://github.com/powerfulqa/aszune-ai-bot/actions/workflows/unified-ci.yml)
[![Codecov](https://codecov.io/gh/powerfulqa/aszune-ai-bot/branch/main/graph/badge.svg)](https://codecov.io/gh/powerfulqa/aszune-ai-bot)
[![Maintainability](https://qlty.sh/badges/89f58366-59f3-43bb-8a8a-6b02c47c7ad9/maintainability.svg)](https://qlty.sh/gh/powerfulqa/projects/aszune-ai-bot)
[![License](https://img.shields.io/badge/License-All%20Rights%20Reserved-red.svg)](./LICENSE)
[![Tests](https://img.shields.io/badge/Tests-1800%2B%20Passing-brightgreen.svg)](#testing--coverage)
[![Tests](https://img.shields.io/badge/Tests-1845%20Passing-brightgreen.svg)](#testing--coverage)

[Release Notes](./docs/RELEASE-NOTES-v1.11.0.md) |
[Release Notes](./docs/RELEASE-NOTES-v2.0.0.md) |
[Dashboard Overview](./wiki/Dashboard-Features-Complete.md) | [Documentation Wiki](./wiki/Home.md)

**Aszune AI Bot** is a professional Discord bot that combines advanced AI conversation capabilities
with comprehensive analytics and monitoring features. Built for gaming communities, it provides
lore, guides, and advice using the Perplexity API's **sonar** model while offering real-time
performance dashboards and server analytics directly within Discord.
lore, guides, and advice using the Perplexity API's **sonar** and **sonar-pro** models while
offering real-time performance dashboards and server analytics directly within Discord.

## Key Features

- 🤖 **AI-Powered Conversations** - Context-aware chat using Perplexity API's sonar model
- 🤖 **AI-Powered Conversations** - Context-aware chat with intelligent model selection
(sonar for quick queries, sonar-pro for multi-turn conversations)
- 📎 **Source Citations** - Responses include source domains so users can verify information
- 📊 **Web Dashboard** - Real-time monitoring with logs, services, network status, and configuration
- ⏰ **Smart Reminders** - Natural language reminder scheduling with Discord notifications
- 📈 **Analytics** - Server analytics, user engagement metrics, and performance monitoring
- 🍓 **Raspberry Pi Optimized** - Specialized optimizations for resource-constrained devices
- 🍓 **Raspberry Pi Optimised** - Specialised optimisations for resource-constrained devices

**Current Status**: 1,700+ tests passing – 70%+ coverage thresholds
**Current Status**: 1,845 tests passing – 70%+ coverage thresholds

## Table of Contents

Expand Down Expand Up @@ -55,7 +57,7 @@ performance dashboards and server analytics directly within Discord.
- 🏠 **Server Info:** `/serverinfo` - comprehensive server statistics and features
- 🇬🇧 **UK English Responses:** All bot replies use UK English spelling and phrasing
- 📋 **Slash Command Support:** All commands available as modern Discord slash commands
- 🧪 **Comprehensive Testing:** 1,800+ tests with 70%+ coverage on critical components
- 🧪 **Comprehensive Testing:** 1,845 tests with 70%+ coverage on critical components
- 🌐 **Web Dashboard:** Optional Express + Socket.io dashboard with live metrics
- 💾 **Persistent Storage:** SQLite database for conversation history and user analytics
- 🍓 **Raspberry Pi Optimised:** Specialised performance optimisations for Pi 3 to Pi 5 (primary
Expand Down Expand Up @@ -384,7 +386,7 @@ linting, formatting, security scanning, and maintainability analysis.

### Quality Standards

- **Test Coverage:** 1,708+ tests passing – dual thresholds: ≥80% critical files / ≥65% global
- **Test Coverage:** 1,845 tests passing – dual thresholds: ≥80% critical files / ≥65% global
baseline
- **Code Quality:** 94.8% reduction in ESLint issues with systematic complexity reduction
- **Security:** Zero tolerance for secrets, timing-safe authentication, vulnerability scanning
Expand Down Expand Up @@ -451,16 +453,18 @@ View the CI/CD workflow in `.github/workflows/unified-ci.yml`
### 🔴 Perplexity API Errors (400 / 401)

- Validate your API key is current and supports the `chat/completions` endpoint
- Ensure model name is `"sonar"` and the format of your payload is correct
- Model names are `"sonar"` (default) and `"sonar-pro"` (auto-selected for multi-turn)
- Test the same key using a tool like Postman or curl

---

## Future Enhancements

- [ ] Add clickable sources and reference links from Perplexity results
- [ ] Enhance error handling with retry/backoff logic for API rate limits
- [x] ~~Add clickable sources and reference links from Perplexity results~~ (v2.0.0)
- [x] ~~Enhance error handling with retry/backoff logic for API rate limits~~ (v2.0.0)
- [ ] Populate `SEARCH_DOMAIN_FILTER` with gaming-specific domains (wowpedia, wowhead, icy-veins)
- [ ] Implement AI-powered content moderation for safer interactions
- [ ] Further decompose `web-dashboard.js` (~2,600 lines remaining)

---

Expand Down Expand Up @@ -508,8 +512,9 @@ See [CHANGELOG.md](./CHANGELOG.md) for full version history.

For detailed release notes:

- [v1.11.0](./docs/RELEASE-NOTES-v1.11.0.md) - Enhanced Utility Commands (/userinfo, /serverinfo)
- [v1.10.0](./docs/RELEASE-NOTES-v1.10.0.md) - Code Quality & Documentation Cleanup
- [v1.9.0](./docs/RELEASE-NOTES-v1.9.0.md) - Dashboard Enhancements
- [v1.8.0](./docs/RELEASE-NOTES-v1.8.0.md) - Web Dashboard
- [v1.7.0](./docs/RELEASE-NOTES-v1.7.0.md) - Database & Reminders
- [v2.0.0](./docs/RELEASE-NOTES-v2.0.0.md) - Security, Perplexity API Enhancements, Architecture
- [v1.11.0](./docs/release-notes/RELEASE-NOTES-v1.11.0.md) - Enhanced Utility Commands (/userinfo, /serverinfo)
- [v1.10.0](./docs/release-notes/RELEASE-NOTES-v1.10.0.md) - Code Quality & Documentation Cleanup
- [v1.9.0](./docs/release-notes/RELEASE-NOTES-v1.9.0.md) - Dashboard Enhancements
- [v1.8.0](./docs/release-notes/RELEASE-NOTES-v1.8.0.md) - Web Dashboard
- [v1.7.0](./docs/release-notes/RELEASE-NOTES-v1.7.0.md) - Database & Reminders
211 changes: 211 additions & 0 deletions __tests__/unit/services/api-client-search-features.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
/**
* API Client - Search Features Tests
* Tests for model selection, search options, citation config, and token usage logging
*/

jest.mock('undici', () => ({ request: jest.fn() }));

const { ApiClient } = require('../../../src/services/api-client');

describe('ApiClient - Search Features', () => {
let client;

beforeEach(() => {
jest.clearAllMocks();
client = new ApiClient('test-key', 'https://api.perplexity.ai');
});

describe('_selectModel', () => {
const baseConfig = {
DEFAULT_MODEL: 'sonar',
MULTI_TURN_MODEL: 'sonar-pro',
MULTI_TURN_THRESHOLD: 2,
};

it('should return explicit model from options when provided', () => {
const messages = [{ role: 'user', content: 'hi' }];
expect(client._selectModel(messages, { model: 'custom-model' }, baseConfig)).toBe(
'custom-model'
);
});

it('should return sonar-pro when messages exceed threshold', () => {
const messages = [
{ role: 'system', content: 'system' },
{ role: 'user', content: 'hello' },
{ role: 'assistant', content: 'hi' },
];
expect(client._selectModel(messages, {}, baseConfig)).toBe('sonar-pro');
});

it('should return default model when messages are at or below threshold', () => {
const messages = [
{ role: 'user', content: 'hello' },
{ role: 'assistant', content: 'hi' },
];
expect(client._selectModel(messages, {}, baseConfig)).toBe('sonar');
});

it('should return default model when MULTI_TURN_MODEL is not configured', () => {
const messages = [
{ role: 'user', content: 'a' },
{ role: 'assistant', content: 'b' },
{ role: 'user', content: 'c' },
];
const configNoMultiTurn = { ...baseConfig, MULTI_TURN_MODEL: null };
expect(client._selectModel(messages, {}, configNoMultiTurn)).toBe('sonar');
});

it('should prefer explicit option over multi-turn upgrade', () => {
const messages = Array(10).fill({ role: 'user', content: 'msg' });
expect(client._selectModel(messages, { model: 'sonar' }, baseConfig)).toBe('sonar');
});
});

describe('_buildSearchOptions', () => {
it('should include return_citations when configured', () => {
const config = { RETURN_CITATIONS: true, SEARCH_DOMAIN_FILTER: [] };
const result = client._buildSearchOptions({}, config);
expect(result.return_citations).toBe(true);
});

it('should omit return_citations when not configured', () => {
const config = { RETURN_CITATIONS: false, SEARCH_DOMAIN_FILTER: [] };
const result = client._buildSearchOptions({}, config);
expect(result.return_citations).toBeUndefined();
});

it('should include search_domain_filter from config', () => {
const config = {
RETURN_CITATIONS: false,
SEARCH_DOMAIN_FILTER: ['wowhead.com', 'wowpedia.fandom.com'],
};
const result = client._buildSearchOptions({}, config);
expect(result.search_domain_filter).toEqual(['wowhead.com', 'wowpedia.fandom.com']);
});

it('should prefer options.searchDomainFilter over config', () => {
const config = {
RETURN_CITATIONS: false,
SEARCH_DOMAIN_FILTER: ['config-domain.com'],
};
const result = client._buildSearchOptions(
{ searchDomainFilter: ['option-domain.com'] },
config
);
expect(result.search_domain_filter).toEqual(['option-domain.com']);
});

it('should omit search_domain_filter when empty', () => {
const config = { RETURN_CITATIONS: false, SEARCH_DOMAIN_FILTER: [] };
const result = client._buildSearchOptions({}, config);
expect(result.search_domain_filter).toBeUndefined();
});

it('should include search_recency_filter from options', () => {
const config = { RETURN_CITATIONS: false, SEARCH_DOMAIN_FILTER: [] };
const result = client._buildSearchOptions({ searchRecencyFilter: 'month' }, config);
expect(result.search_recency_filter).toBe('month');
});

it('should omit search_recency_filter when not provided', () => {
const config = { RETURN_CITATIONS: false, SEARCH_DOMAIN_FILTER: [] };
const result = client._buildSearchOptions({}, config);
expect(result.search_recency_filter).toBeUndefined();
});

it('should combine all options when all configured', () => {
const config = {
RETURN_CITATIONS: true,
SEARCH_DOMAIN_FILTER: ['example.com'],
};
const result = client._buildSearchOptions({ searchRecencyFilter: 'week' }, config);
expect(result).toEqual({
return_citations: true,
search_domain_filter: ['example.com'],
search_recency_filter: 'week',
});
});
});

describe('buildRequestPayload - search features integration', () => {
const validMessages = [{ role: 'user', content: 'What is the latest WoW patch?' }];

it('should include search options in payload', () => {
const payload = client.buildRequestPayload(validMessages, {});
expect(payload.return_citations).toBe(true);
});

it('should upgrade model for long conversations', () => {
const longConvo = [
{ role: 'system', content: 'system' },
{ role: 'user', content: 'hello' },
{ role: 'assistant', content: 'hi' },
{ role: 'user', content: 'tell me more' },
];
const payload = client.buildRequestPayload(longConvo, {});
expect(payload.model).toBe('sonar-pro');
});

it('should use default model for short conversations', () => {
const payload = client.buildRequestPayload(validMessages, {});
expect(payload.model).toBe('sonar');
});

it('should pass search_recency_filter through options', () => {
const payload = client.buildRequestPayload(validMessages, {
searchRecencyFilter: 'month',
});
expect(payload.search_recency_filter).toBe('month');
});

it('should pass search_domain_filter through options', () => {
const payload = client.buildRequestPayload(validMessages, {
searchDomainFilter: ['wowhead.com'],
});
expect(payload.search_domain_filter).toEqual(['wowhead.com']);
});
});

describe('handleResponse - token usage logging', () => {
const logger = require('../../../src/utils/logger');

it('should log token usage when usage field is present', async () => {
const infoSpy = jest.spyOn(logger, 'info');
const mockResponse = {
statusCode: 200,
body: {
json: jest.fn().mockResolvedValue({
choices: [{ message: { content: 'response' } }],
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
}),
},
};

await client.handleResponse(mockResponse);

expect(infoSpy).toHaveBeenCalledWith(
'API Usage: prompt=10, completion=20, total=30'
);
infoSpy.mockRestore();
});

it('should not log usage when usage field is absent', async () => {
const infoSpy = jest.spyOn(logger, 'info');
const mockResponse = {
statusCode: 200,
body: {
json: jest.fn().mockResolvedValue({
choices: [{ message: { content: 'response' } }],
}),
},
};

await client.handleResponse(mockResponse);

const usageCalls = infoSpy.mock.calls.filter((call) => call[0].includes('API Usage'));
expect(usageCalls).toHaveLength(0);
infoSpy.mockRestore();
});
});
});
Loading
Loading