lwazi 1.8.4 → 1.8.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lwazi",
3
- "version": "1.8.4",
3
+ "version": "1.8.5",
4
4
  "description": "Lwazi is an AI assistant for Laravel. Install with one command to add an AI assistant to your Laravel app.",
5
5
  "main": "bin/lwazi.js",
6
6
  "bin": {
@@ -202,7 +202,7 @@ class AnalyzeProjectCommand extends Command
202
202
 
203
203
  $manifest['flat'][$fullUrl] = [
204
204
  'label' => $text ?: basename($fullUrl),
205
- 'segments' => array_filter(explode('/', parse_url($fullUrl, PHP_PATH) ?? '')),
205
+ 'segments' => array_filter(explode('/', parse_url($fullUrl, PHP_URL_PATH) ?? '')),
206
206
  '_path' => $fullUrl,
207
207
  '_weight' => 1,
208
208
  ];
@@ -210,7 +210,7 @@ class SetupCommand extends Command
210
210
 
211
211
  $manifest['flat'][$fullUrl] = [
212
212
  'label' => $text ?: basename($fullUrl),
213
- 'segments' => array_filter(explode('/', parse_url($fullUrl, PHP_PATH) ?? '')),
213
+ 'segments' => array_filter(explode('/', parse_url($fullUrl, PHP_URL_PATH) ?? '')),
214
214
  '_path' => $fullUrl,
215
215
  '_weight' => 1,
216
216
  ];
@@ -38,6 +38,13 @@ class LwaziService
38
38
  {
39
39
  error_log('Lwazi chat: ' . $message);
40
40
 
41
+ // Extract context from message if user provides identifier
42
+ $extractedContext = $this->extractContextFromMessage($message);
43
+ if ($extractedContext && empty($this->currentContextId)) {
44
+ $this->currentContextId = $extractedContext;
45
+ error_log('Lwazi: extracted context: ' . $extractedContext);
46
+ }
47
+
41
48
  // Temporarily disable agent
42
49
  // if ($this->agent->isReady()) {
43
50
  // return $this->agent->reply($message, $this->currentContextId);
@@ -100,6 +107,53 @@ class LwaziService
100
107
  return "I couldn't find information about that. Could you try a different question?";
101
108
  }
102
109
 
110
+ // Check for general website questions BEFORE falling back to LLM
111
+ if ($intent === 'general' || $this->isGeneralWebsiteQuery($message)) {
112
+ // Simple greeting response first
113
+ if (preg_match('/^(hi|hello|hey|good morning|good afternoon|good evening|how are you)\b/i', $message)) {
114
+ $response = "Hello! I'm here to help you navigate this website. You can ask me things like 'where can I find events' or 'what is this website about'. What would you like to know?";
115
+ $this->conversationHistory[] = ['role' => 'assistant', 'content' => $response];
116
+ return $response;
117
+ }
118
+
119
+ $summary = $this->getWebsiteSummary($message);
120
+ if ($summary) {
121
+ $this->conversationHistory[] = ['role' => 'assistant', 'content' => $summary];
122
+ return $summary;
123
+ }
124
+
125
+ // Try content search as fallback
126
+ $contentResponse = $this->searchContent($message);
127
+ if ($contentResponse) {
128
+ $this->conversationHistory[] = ['role' => 'assistant', 'content' => $contentResponse];
129
+ return $contentResponse;
130
+ }
131
+ }
132
+
133
+ if ($intent === null && $this->currentContextId) {
134
+ $dataResponse = $this->fetchRelevantData($message);
135
+ if ($dataResponse) {
136
+ $this->conversationHistory[] = ['role' => 'assistant', 'content' => $dataResponse];
137
+ return $dataResponse;
138
+ }
139
+ }
140
+
141
+ // Try content search as final fallback before LLM
142
+ $contentResponse = $this->searchContent($message);
143
+ if ($contentResponse) {
144
+ $this->conversationHistory[] = ['role' => 'assistant', 'content' => $contentResponse];
145
+ return $contentResponse;
146
+ }
147
+
148
+ // Last resort: check if this could be a content query and show what's available
149
+ if (!$this->looksLikeDataQuery($message)) {
150
+ $navResponse = $this->getNavigationSuggestions($message);
151
+ if ($navResponse) {
152
+ $this->conversationHistory[] = ['role' => 'assistant', 'content' => $navResponse];
153
+ return $navResponse;
154
+ }
155
+ }
156
+
103
157
  $prompt = $this->buildPrompt();
104
158
  $messages = array_merge(
105
159
  [['role' => 'system', 'content' => $prompt]],
@@ -118,6 +172,10 @@ class LwaziService
118
172
  return 'data';
119
173
  }
120
174
 
175
+ if ($this->isGeneralWebsiteQuery($message)) {
176
+ return 'general';
177
+ }
178
+
121
179
  if ($this->looksLikeNavigationQuery($message) && !$this->looksLikeContentQuery($message)) {
122
180
  return 'navigation';
123
181
  }
@@ -128,6 +186,11 @@ class LwaziService
128
186
  return 'content';
129
187
  }
130
188
 
189
+ // Check for greetings and casual conversation
190
+ if (preg_match('/^(hi|hello|hey|good morning|good afternoon|good evening|how are you|thanks|thank you)\b/i', $msgLower)) {
191
+ return 'general';
192
+ }
193
+
131
194
  $prompt =
132
195
  "Classify the user's intent as one of: navigation, content, data, general. Return JSON: {\"intent\":\"navigation|content|data|general\"}.\n\n" .
133
196
  "- navigation: user wants to find a specific page or link (where, which page, how do i get to)\n" .
@@ -161,9 +224,11 @@ class LwaziService
161
224
  $msgLower = strtolower($message);
162
225
 
163
226
  $personalPatterns = [
164
- '/\bmy\b/', '/\bme\b/', '/\bmine\b/', '/\bmy own\b/',
227
+ '/\bmy\b/', '/\bmine\b/', '/\bmy own\b/',
165
228
  '/\bmy account\b/', '/\bmy profile\b/', '/\bmy data\b/',
166
229
  '/\bmy records\b/', '/\bmy applications\b/', '/\bmy submissions\b/',
230
+ '/\bmy courses?\b/', '/\bmy results?\b/', '/\bmy fees\b/',
231
+ '/\bmy grades?\b/', '/\bmy profile\b/', '/\bmy information\b/',
167
232
  ];
168
233
 
169
234
  foreach ($personalPatterns as $pattern) {
@@ -175,6 +240,46 @@ class LwaziService
175
240
  return false;
176
241
  }
177
242
 
243
+ protected function isGeneralWebsiteQuery(string $message): bool
244
+ {
245
+ $patterns = [
246
+ '/\bwhat.*this (site|website|app|platform)\b/i',
247
+ '/\babout (this|the) (site|website|app|platform)\b/i',
248
+ '/\bwhat.*(do you|does this).*(offer|provide|have)\b/i',
249
+ '/\btell me about (this|the) (site|website|app)\b/i',
250
+ '/\bwhat is (this|it)\b/i',
251
+ '/\bwhat can (i|you) do (here|on this)\b/i',
252
+ '/\bhow (does|does this) (work|site work)\b/i',
253
+ ];
254
+
255
+ foreach ($patterns as $pattern) {
256
+ if (preg_match($pattern, $message)) {
257
+ return true;
258
+ }
259
+ }
260
+
261
+ return false;
262
+ }
263
+
264
+ protected function getWebsiteSummary(string $message): ?string
265
+ {
266
+ // For now, provide a helpful generic response based on navigation
267
+ $tree = $this->ragService->getNavigationTree();
268
+ if ($tree) {
269
+ $flat = $tree->getFlatIndex();
270
+ $pages = array_keys($flat);
271
+
272
+ if (!empty($pages)) {
273
+ $pageList = array_slice($pages, 0, 8);
274
+ $list = implode(', ', array_map(fn($p) => str_replace('-', ' ', trim($p, '/')), $pageList));
275
+
276
+ return "This appears to be a web application with pages like: {$list}. You can navigate to any of these pages, or ask me to help you find specific content. Use the menu or tell me what you're looking for!";
277
+ }
278
+ }
279
+
280
+ return "This is a web application. You can use the navigation menu to find pages, or ask me to help you locate specific content. What would you like to find?";
281
+ }
282
+
178
283
  protected function looksLikeContentQuery(string $message): bool
179
284
  {
180
285
  return (bool) preg_match(
@@ -183,6 +288,26 @@ class LwaziService
183
288
  );
184
289
  }
185
290
 
291
+ protected function extractContextFromMessage(string $message): ?string
292
+ {
293
+ $contextParam = config('lwazi.context_param', 'context_id');
294
+
295
+ if (str_contains($contextParam, 'student')) {
296
+ if (preg_match('/\b(STU\d+)\b/i', $message, $matches)) {
297
+ return $matches[1];
298
+ }
299
+ if (preg_match('/\b(\d{6,})\b/', $message, $matches)) {
300
+ return $matches[1];
301
+ }
302
+ }
303
+
304
+ if (preg_match('/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i', $message, $matches)) {
305
+ return strtolower($matches[0]);
306
+ }
307
+
308
+ return null;
309
+ }
310
+
186
311
  public function looksLikeNavigationQuery(string $message): bool
187
312
  {
188
313
  return (bool) preg_match(
@@ -304,50 +429,139 @@ class LwaziService
304
429
  $response = "I found some relevant information:\n\n";
305
430
 
306
431
  foreach ($results as $result) {
307
- $response .= "**{$result['title']}**\n";
432
+ $url = $result['url'] ?? '/';
433
+ // Try to match with navigation for better URL
434
+ $navMatch = $this->getMatchingNavPath($url);
435
+ if ($navMatch) {
436
+ $url = $navMatch;
437
+ }
438
+
439
+ $title = $result['title'] ?? basename($url);
440
+ $response .= "**{$title}**\n";
308
441
  $response .= "{$result['snippet']}\n";
309
- $response .= "[Read more]({$result['url']})\n\n";
442
+ $response .= "[View page]({$url})\n\n";
310
443
  }
311
444
 
312
445
  return $response;
313
446
  }
314
447
 
448
+ protected function getMatchingNavPath(string $contentUrl): ?string
449
+ {
450
+ $tree = $this->ragService->getNavigationTree();
451
+ if (!$tree) {
452
+ return null;
453
+ }
454
+
455
+ $flat = $tree->getFlatIndex();
456
+
457
+ // Try exact match first
458
+ if (isset($flat[$contentUrl])) {
459
+ return $contentUrl;
460
+ }
461
+
462
+ // Try fuzzy match
463
+ $contentSlug = basename($contentUrl);
464
+ foreach ($flat as $path => $entry) {
465
+ $pathSlug = basename($path);
466
+ if (levenshtein(strtolower($contentSlug), strtolower($pathSlug)) <= 3) {
467
+ return $path;
468
+ }
469
+ }
470
+
471
+ return null;
472
+ }
473
+
474
+ protected function getNavigationSuggestions(string $message): ?string
475
+ {
476
+ $tree = $this->ragService->getNavigationTree();
477
+ if (!$tree) {
478
+ return null;
479
+ }
480
+
481
+ $flat = $tree->getFlatIndex();
482
+ if (empty($flat)) {
483
+ return null;
484
+ }
485
+
486
+ // Get a few random pages to suggest
487
+ $pages = array_keys($flat);
488
+ $suggestions = array_slice($pages, 0, 5);
489
+
490
+ if (empty($suggestions)) {
491
+ return null;
492
+ }
493
+
494
+ $list = [];
495
+ foreach ($suggestions as $path) {
496
+ $label = $flat[$path]['label'] ?? basename($path);
497
+ $list[] = "- {$label}: {$path}";
498
+ }
499
+
500
+ return "Here are some pages on this site:\n" . implode("\n", $list) . "\n\nYou can navigate to any of these, or ask me to help you find something specific.";
501
+ }
502
+
315
503
  protected function extractSearchTerms(string $message): array
316
504
  {
317
- preg_match('/\b(my|the|their|our|all|this|these)\s+(\w+)/i', $message, $matches);
318
- $topic = $matches[2] ?? '';
505
+ $msgLower = strtolower($message);
319
506
 
320
- if (empty($topic) || strlen($topic) < 3) {
321
- $words = preg_split('/\s+/', strtolower($message));
322
- $stopWords = ['where', 'can', 'find', 'get', 'look', 'show', 'list', 'tell', 'know', 'want', 'need', 'help', 'give', 'me', 'i', 'is', 'are', 'was', 'were', 'has', 'have', 'had', 'do', 'does', 'did', 'will', 'would', 'could', 'should', 'what', 'how', 'why'];
507
+ // Extract topic from common question patterns
508
+ $patterns = [
509
+ '/\babout\s+(\w+)/i',
510
+ '/\b(?:is there|are there|can i find)\s+(?:any\s+)?(\w+)/i',
511
+ '/\binfo(?:rmation)?\s+about\s+(\w+)/i',
512
+ '/\btell me (?:about|the)\s+(\w+)/i',
513
+ ];
514
+
515
+ $topics = [];
516
+ foreach ($patterns as $pattern) {
517
+ if (preg_match($pattern, $msgLower, $matches)) {
518
+ if (strlen($matches[1]) >= 3) {
519
+ $topics[] = $matches[1];
520
+ }
521
+ }
522
+ }
523
+
524
+ if (empty($topics)) {
525
+ // Look for known keywords in the message
526
+ $keywords = ['alumni', 'event', 'events', 'news', 'scholarship', 'scholarships', 'certificate', 'certificates', 'profile', 'profiles', 'course', 'courses', 'result', 'results', 'fee', 'fees', 'benefit', 'benefits', 'story', 'stories'];
527
+ foreach ($keywords as $kw) {
528
+ if (str_contains($msgLower, $kw)) {
529
+ $topics[] = $kw;
530
+ }
531
+ }
532
+ }
533
+
534
+ if (empty($topics)) {
535
+ // Fall back to extracting significant words
536
+ $words = preg_split('/\s+/', $msgLower);
537
+ $stopWords = ['where', 'can', 'find', 'get', 'look', 'show', 'list', 'tell', 'know', 'want', 'need', 'help', 'give', 'me', 'i', 'is', 'are', 'was', 'were', 'has', 'have', 'had', 'do', 'does', 'did', 'will', 'would', 'could', 'should', 'what', 'how', 'why', 'there', 'here', 'this', 'that', 'with', 'from', 'your', 'you', 'for', 'about', 'any'];
323
538
  foreach ($words as $w) {
324
- if (strlen($w) >= 4 && !in_array($w, $stopWords)) {
325
- $topic = $w;
326
- break;
539
+ $clean = preg_replace('/[^a-z]/', '', $w);
540
+ if (strlen($clean) >= 4 && !in_array($clean, $stopWords)) {
541
+ $topics[] = $clean;
542
+ if (count($topics) >= 2) break;
327
543
  }
328
544
  }
329
545
  }
330
546
 
331
- if (empty($topic) || strlen($topic) < 3) {
547
+ if (empty($topics)) {
332
548
  return [];
333
549
  }
334
-
335
- $prompt = "List 5 synonyms for: {$topic}. Return ONLY JSON array like: [\"word1\",\"word2\"]";
336
-
337
- $response = $this->callOllama([
338
- ['role' => 'system', 'content' => 'Return only valid JSON.'],
339
- ['role' => 'user', 'content' => $prompt],
340
- ]);
341
-
342
- $json = $this->extractJson($response['content'] ?? '');
343
- $terms = is_array($json) ? array_values($json) : [];
344
550
 
345
- $first = explode(' ', $topic)[0];
346
- if (strlen($first) >= 3) {
347
- $terms[] = strtolower($first);
551
+ $topic = $topics[0];
552
+
553
+ // Build search terms including the original topic
554
+ $terms = array_filter([strtolower($topic)], fn($t) => strlen($t) >= 3);
555
+
556
+ // Add common variations
557
+ $terms[] = strtolower($topic);
558
+ if (str_ends_with($topic, 's')) {
559
+ $terms[] = rtrim($topic, 's');
560
+ } else {
561
+ $terms[] = $topic . 's';
348
562
  }
349
-
350
- return $terms;
563
+
564
+ return array_values(array_unique($terms));
351
565
  }
352
566
 
353
567
  protected function findBestRouteWithTerms(array $routes, array $terms): ?string