lwazi 1.3.2 → 1.4.1

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/bin/update.js CHANGED
@@ -46,6 +46,12 @@ function copyDirectory(src, dest) {
46
46
  }
47
47
  }
48
48
 
49
+ if (fs.existsSync(targetDir)) {
50
+ console.log("Removing old lwazi directory...");
51
+ fs.rmSync(targetDir, { recursive: true, force: true });
52
+ }
53
+
54
+ console.log("Copying fresh lwazi files...");
49
55
  copyDirectory(packageDir, targetDir);
50
56
 
51
57
  console.log("Running composer update...");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lwazi",
3
- "version": "1.3.2",
3
+ "version": "1.4.1",
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": {
@@ -99,10 +99,28 @@ class NavigationCrawler
99
99
  $normalized = $this->normalizeUrl($link['href'], $url);
100
100
  if ($this->isInternal($normalized)) {
101
101
  $weight = $link['weight'] ?? 1;
102
- $this->adjacency[$url][] = $normalized;
102
+ $linkText = $link['text'] ?? '';
103
103
 
104
- if (!isset($this->linkWeights[$normalized])) {
105
- $this->linkWeights[$normalized] = 0;
104
+ if (!in_array($normalized, $this->adjacency[$url], true)) {
105
+ $this->adjacency[$url][] = $normalized;
106
+ }
107
+
108
+ if (!isset($this->flatIndex[$normalized])) {
109
+ $this->flatIndex[$normalized] = [
110
+ 'label' => $linkText ?: basename($normalized),
111
+ 'segments' => [],
112
+ '_path' => $normalized,
113
+ '_weight' => 1,
114
+ '_link_texts' => [],
115
+ ];
116
+ }
117
+
118
+ if ($linkText && !in_array($linkText, $this->flatIndex[$normalized]['_link_texts'] ?? [], true)) {
119
+ $this->flatIndex[$normalized]['_link_texts'][] = $linkText;
120
+ }
121
+
122
+ if ($weight > ($this->flatIndex[$normalized]['_weight'] ?? 0)) {
123
+ $this->flatIndex[$normalized]['_weight'] = $weight;
106
124
  }
107
125
  $this->linkWeights[$normalized] = max($this->linkWeights[$normalized], $weight);
108
126
 
@@ -262,8 +280,11 @@ class NavigationCrawler
262
280
  $isMenuLink = isset($menuLinks[$href]);
263
281
  $weight = $isMenuLink ? 5 : 1;
264
282
 
283
+ $linkText = trim($a->textContent ?? '');
284
+
265
285
  $links[] = [
266
286
  'href' => $href,
287
+ 'text' => $linkText,
267
288
  'weight' => $weight,
268
289
  'is_menu' => $isMenuLink,
269
290
  ];
@@ -72,11 +72,12 @@ class LwaziAgent
72
72
  $rootUrl = $manifest['root_url'] ?? config('app.url', 'http://localhost');
73
73
 
74
74
  if ($intent === 'navigation') {
75
- $link = $this->pickNavigationLinkWithFeedback($message, $manifest);
76
- if ($link) {
77
- $fullUrl = $this->buildFullUrl($link, $rootUrl);
78
- return $this->sanitize("You can find that here: {$fullUrl}");
75
+ $result = $this->pickNavigationLinkWithFeedback($message, $manifest);
76
+
77
+ if ($result && isset($result['results']) && !empty($result['results'])) {
78
+ return $this->formatNavigationResponse($result, $rootUrl);
79
79
  }
80
+
80
81
  return $this->generateNavigationHelp($message, $manifest);
81
82
  }
82
83
 
@@ -87,6 +88,44 @@ class LwaziAgent
87
88
  return $this->sanitize($this->chat($message));
88
89
  }
89
90
 
91
+ protected function formatNavigationResponse(array $result, string $rootUrl): string
92
+ {
93
+ $results = $result['results'] ?? [];
94
+ $intent = $result['intent'] ?? 'that';
95
+
96
+ if (count($results) === 1) {
97
+ $r = $results[0];
98
+ $fullUrl = $this->buildFullUrl($r['path'], $rootUrl);
99
+ $linkTexts = $r['link_texts'] ?? [];
100
+
101
+ $response = "I found a page for \"{$intent}\":\n\n";
102
+ $response .= "🔗 {$r['label']}: {$fullUrl}\n";
103
+
104
+ if (!empty($linkTexts)) {
105
+ $response .= "\nThis page is linked as: " . implode(', ', array_slice($linkTexts, 0, 5));
106
+ }
107
+
108
+ return $this->sanitize($response);
109
+ }
110
+
111
+ $response = "I found multiple pages that match \"{$intent}\":\n\n";
112
+
113
+ foreach ($results as $i => $r) {
114
+ $fullUrl = $this->buildFullUrl($r['path'], $rootUrl);
115
+ $linkTexts = $r['link_texts'] ?? [];
116
+
117
+ $response .= ($i + 1) . ". **{$r['label']}**\n";
118
+ $response .= " {$fullUrl}\n";
119
+
120
+ if (!empty($linkTexts)) {
121
+ $response .= " Linked as: " . implode(', ', array_slice($linkTexts, 0, 3)) . "\n";
122
+ }
123
+ $response .= "\n";
124
+ }
125
+
126
+ return $this->sanitize($response);
127
+ }
128
+
90
129
  protected function buildFullUrl(string $path, string $rootUrl): string
91
130
  {
92
131
  if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) {
@@ -126,7 +165,7 @@ class LwaziAgent
126
165
  return 'general';
127
166
  }
128
167
 
129
- protected function pickNavigationLinkWithFeedback(string $message, array $manifest): ?string
168
+ protected function pickNavigationLinkWithFeedback(string $message, array $manifest): ?array
130
169
  {
131
170
  $semanticUnderstanding = $this->understandUserIntent($message, $manifest);
132
171
  if (!$semanticUnderstanding) {
@@ -146,12 +185,83 @@ class LwaziAgent
146
185
  'flat' => $manifest['flat'] ?? [],
147
186
  ];
148
187
 
149
- $treeResult = $this->searchNavigationTree($message, $semanticUnderstanding, $navigationTree);
150
- if ($treeResult) {
151
- return $treeResult['path'] ?? null;
188
+ $allResults = $this->searchNavigationTreeAll($message, $semanticUnderstanding, $navigationTree);
189
+
190
+ if (!empty($allResults)) {
191
+ return [
192
+ 'results' => $allResults,
193
+ 'intent' => $semanticUnderstanding['intent'] ?? null,
194
+ ];
195
+ }
196
+
197
+ $fallback = $this->searchByContent($message, $semanticUnderstanding, $manifest);
198
+ if ($fallback) {
199
+ return [
200
+ 'results' => [$fallback],
201
+ 'intent' => $semanticUnderstanding['intent'] ?? null,
202
+ ];
203
+ }
204
+
205
+ return null;
206
+ }
207
+
208
+ protected function searchNavigationTreeAll(string $message, array $understanding, array $navigationTree): array
209
+ {
210
+ $flatIndex = $navigationTree['flat'] ?? [];
211
+ $keywords = $understanding['keywords'] ?? [];
212
+ $query = $understanding['intent'] ?? $message;
213
+ $results = [];
214
+
215
+ foreach ($flatIndex as $path => $entry) {
216
+ $label = strtolower($entry['label'] ?? '');
217
+ $segments = $entry['segments'] ?? [];
218
+ $linkTexts = $entry['_link_texts'] ?? [];
219
+
220
+ foreach ($keywords as $keyword) {
221
+ $kw = strtolower(trim($keyword));
222
+ $match = false;
223
+ $matchType = '';
224
+
225
+ if (str_contains($label, $kw)) {
226
+ $match = true;
227
+ $matchType = 'label';
228
+ }
229
+
230
+ if (!$match) {
231
+ foreach ($segments as $seg) {
232
+ if (str_contains(strtolower($seg), $kw)) {
233
+ $match = true;
234
+ $matchType = 'segment';
235
+ break;
236
+ }
237
+ }
238
+ }
239
+
240
+ if (!$match) {
241
+ foreach ($linkTexts as $text) {
242
+ if (str_contains(strtolower($text), $kw)) {
243
+ $match = true;
244
+ $matchType = 'link_text';
245
+ break;
246
+ }
247
+ }
248
+ }
249
+
250
+ if ($match) {
251
+ $results[] = [
252
+ 'path' => $path,
253
+ 'label' => $entry['label'] ?? basename($path),
254
+ 'link_texts' => $linkTexts,
255
+ 'match_type' => $matchType,
256
+ 'score' => ($entry['_weight'] ?? 1) + ($matchType === 'link_text' ? 2 : 0),
257
+ ];
258
+ break;
259
+ }
260
+ }
152
261
  }
153
262
 
154
- return $this->searchByContent($message, $semanticUnderstanding, $manifest) ?? null;
263
+ usort($results, fn($a, $b) => $b['score'] <=> $a['score']);
264
+ return array_slice($results, 0, 10);
155
265
  }
156
266
 
157
267
  protected function recordSynonymUsage(array $keywords): void