Files
TicketAssistent/app/Services/TicketNormalizationService.php
SitiWeb f939133fe0 Add admin views for quick replies, settings, and ticket details
- Created `quick-replies.blade.php` for managing quick replies.
- Added `settings.blade.php` for admin settings management.
- Implemented `ticket-show.blade.php` to display ticket details.
- Introduced `timeline-card.blade.php` component for displaying timeline information.

Enhance quick reply management functionality

- Developed `quick-reply-manager.blade.php` for creating and editing quick replies.
- Integrated Livewire for dynamic interaction and validation.

Implement settings page for AI configuration

- Created `settings-page.blade.php` for managing AI settings, including prompts and provider instances.
- Added functionality for managing models and embeddings.

Add ticket show functionality with real-time updates

- Implemented ticket details view with processing status and tool call logs.
- Added support for displaying article suggestions and error messages.

Create unit tests for AI classifier and domain info tool

- Added `AIClassifierServiceTest.php` to validate AI classifier functionality.
- Implemented `DomainInfoToolTest.php` for domain parameter validation.
- Created `OxxaClientTest.php` to test API interactions and password hashing.
2026-04-30 01:50:21 +02:00

165 lines
6.1 KiB
PHP

<?php
namespace App\Services;
use App\Services\Llm\LlmClientInterface;
class TicketNormalizationService
{
public function __construct(
private readonly LlmClientInterface $llmClient,
private readonly AppSettingsService $settings,
) {}
public function normalize(string $original): array
{
$fallback = $this->fallbackNormalize($original);
$fallbackLanguage = $this->detectLanguage($original);
if (! (bool) config('services.llm.ranking_enabled', true)) {
return [
'normalized_message' => $fallback['text'],
'redaction_report' => [
'mode' => 'fallback_regex',
'pii_types' => $fallback['pii_types'],
'language' => $fallbackLanguage,
'reason' => 'llm_normalization_disabled',
],
];
}
$basePrompt = $this->settings->getPrompt('normalization', 'Rewrite and redact PII. Return JSON.');
$prompt = $basePrompt."\n\n".
'Also detect the original language. Return JSON with keys: normalized_message, redaction_report. '.
"redaction_report must include pii_types and language as an ISO 639-1 code such as nl, en, de, fr.\n\n".
"Original question:\n\"\"\"\n{$original}\n\"\"\"";
try {
$raw = $this->llmClient->generate($prompt, ['expect_json' => true, 'task' => 'normalization']);
$decoded = $this->decodeJsonResponse($raw);
if (is_array($decoded) && ! empty($decoded['normalized_message'])) {
return [
'normalized_message' => (string) $decoded['normalized_message'],
'redaction_report' => [
'mode' => 'llm',
'pii_types' => $decoded['redaction_report']['pii_types'] ?? [],
'language' => $this->normalizeLanguageCode($decoded['redaction_report']['language'] ?? $fallbackLanguage),
'notes' => $decoded['redaction_report']['notes'] ?? null,
'raw' => $decoded,
],
];
}
} catch (\Throwable $e) {
return [
'normalized_message' => $fallback['text'],
'redaction_report' => [
'mode' => 'fallback_regex',
'pii_types' => $fallback['pii_types'],
'language' => $fallbackLanguage,
'reason' => 'llm_exception',
'error' => $e->getMessage(),
],
];
}
return [
'normalized_message' => $fallback['text'],
'redaction_report' => [
'mode' => 'fallback_regex',
'pii_types' => $fallback['pii_types'],
'language' => $fallbackLanguage,
'reason' => 'llm_invalid_json_or_missing_fields',
],
];
}
private function fallbackNormalize(string $text): array
{
$pii = [];
// Replace highly-structured values first so looser patterns (phone) do not corrupt them.
$orderedPatterns = [
'email' => '/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i',
'iban' => '/\b[A-Z]{2}[0-9]{2}[A-Z0-9]{10,30}\b/i',
'url' => '/https?:\/\/\S+/i',
'ip' => '/\b(?:\d{1,3}\.){3}\d{1,3}\b/',
// Require separators to avoid matching long account-like sequences.
'phone' => '/(?<![A-Z0-9])(?:\+?\d{1,3}[\s\-]?)?(?:\(?\d{2,4}\)?[\s\-])\d[\d\s\-]{4,}\d(?![A-Z0-9])/i',
];
foreach ($orderedPatterns as $type => $pattern) {
if (preg_match($pattern, $text) === 1) {
$pii[] = $type;
$text = preg_replace($pattern, '['.strtoupper($type).']', $text) ?? $text;
}
}
$text = preg_replace('/\s+/', ' ', trim($text)) ?? trim($text);
return ['text' => $text, 'pii_types' => array_values(array_unique($pii))];
}
private function detectLanguage(string $text): string
{
$lower = mb_strtolower($text);
$dutchSignals = [' ik ', ' mijn ', ' een ', ' het ', ' de ', ' hoe ', ' niet ', ' wordt ', ' domeinnaam ', ' website '];
$englishSignals = [' i ', ' my ', ' the ', ' how ', ' not ', ' website ', ' domain ', ' redirected '];
$padded = ' '.$lower.' ';
$nl = 0;
$en = 0;
foreach ($dutchSignals as $signal) {
$nl += substr_count($padded, $signal);
}
foreach ($englishSignals as $signal) {
$en += substr_count($padded, $signal);
}
return $en > $nl ? 'en' : 'nl';
}
private function normalizeLanguageCode(mixed $language): string
{
$value = mb_strtolower(trim((string) $language));
return match (true) {
str_starts_with($value, 'nl'), str_contains($value, 'dutch'), str_contains($value, 'nederlands') => 'nl',
str_starts_with($value, 'en'), str_contains($value, 'english') => 'en',
str_starts_with($value, 'de'), str_contains($value, 'german'), str_contains($value, 'duits') => 'de',
str_starts_with($value, 'fr'), str_contains($value, 'french'), str_contains($value, 'frans') => 'fr',
default => 'nl',
};
}
private function decodeJsonResponse(string $raw): ?array
{
$raw = trim($raw);
$decoded = json_decode($raw, true);
if (is_array($decoded)) {
return $decoded;
}
if (preg_match('/```(?:json)?\s*(\{.*\})\s*```/is', $raw, $matches) === 1) {
$decoded = json_decode(trim($matches[1]), true);
if (is_array($decoded)) {
return $decoded;
}
}
$start = strpos($raw, '{');
$end = strrpos($raw, '}');
if ($start !== false && $end !== false && $end > $start) {
$candidate = substr($raw, $start, $end - $start + 1);
$decoded = json_decode($candidate, true);
if (is_array($decoded)) {
return $decoded;
}
}
return null;
}
}