@asd20/ui 3.9.0 → 3.10.0

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": "@asd20/ui",
3
- "version": "3.9.0",
3
+ "version": "3.10.0",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "sideEffects": [
@@ -87,13 +87,19 @@
87
87
  <div v-if="aiAnswer" class="asd20-site-search__ai-result">
88
88
  <h3>Answer</h3>
89
89
  <div class="asd20-site-search__ai-answer" v-html="aiAnswer" />
90
+ <div
91
+ v-if="aiKeywordsDisplay"
92
+ class="asd20-site-search__ai-keywords"
93
+ >
94
+ {{ aiKeywordsDisplay }}
95
+ </div>
90
96
 
91
97
  <div
92
98
  v-if="aiGroupedSources.length"
93
99
  class="asd20-site-search__ai-sources"
94
100
  >
95
101
  <h4>Sources</h4>
96
- <ul>
102
+ <ul class="asd20-site-search__ai-source-groups">
97
103
  <li v-for="group in aiGroupedSources" :key="group.hostLabel">
98
104
  <strong>Content from: {{ group.hostLabel }}</strong>
99
105
  <ul class="asd20-site-search__ai-source-list">
@@ -279,6 +285,8 @@ export default {
279
285
  selectedGroup: null,
280
286
  searchingPages: false,
281
287
  searchingFiles: false,
288
+ keywordsFromAi: false,
289
+ aiKeywordsDisplay: '',
282
290
  }),
283
291
 
284
292
  computed: {
@@ -355,7 +363,10 @@ export default {
355
363
  },
356
364
  aiGroupedSources() {
357
365
  const groups = new Map()
358
- this.aiUiSources.forEach(src => {
366
+ const filteredSources = this.aiUiSources.filter((src) =>
367
+ this.isSourceCurrentOrg(src.url, src.hostLabel)
368
+ )
369
+ filteredSources.forEach(src => {
359
370
  const key = src.hostLabel || 'Academy District 20'
360
371
  if (!groups.has(key)) {
361
372
  groups.set(key, { hostLabel: key, sources: [] })
@@ -422,14 +433,16 @@ export default {
422
433
  },
423
434
 
424
435
  methods: {
425
- async searchPages() {
436
+ async searchPages(options = {}) {
426
437
  if (this.$store && this.$store.state.search) {
438
+ const keepAnswersTab = options.keepAnswersTab || this.keywordsFromAi
427
439
  this.searchingPages = true
428
440
  await this.$store.dispatch('search/queryPages', this.keywords)
429
441
  this.searchingPages = false
430
- if (this.keywords && this.keywords.trim()) {
442
+ if (this.keywords && this.keywords.trim() && !keepAnswersTab) {
431
443
  this.currentTab = 'Pages'
432
444
  }
445
+ this.keywordsFromAi = false
433
446
  }
434
447
  },
435
448
 
@@ -519,6 +532,33 @@ export default {
519
532
  }
520
533
  },
521
534
 
535
+ isSourceCurrentOrg(url, hostLabel) {
536
+ const org = this.organization || {}
537
+ const orgTitle = (org.title || '').toLowerCase()
538
+ const orgWebsite = org.website || ''
539
+
540
+ // If org website is available, match by hostname
541
+ if (orgWebsite) {
542
+ try {
543
+ const orgHost = new URL(orgWebsite).hostname.toLowerCase()
544
+ const srcHost = url ? new URL(url).hostname.toLowerCase() : ''
545
+ if (orgHost && srcHost && (srcHost === orgHost || srcHost.endsWith(`.${orgHost}`))) {
546
+ return true
547
+ }
548
+ } catch {
549
+ // fall through to title comparison
550
+ }
551
+ }
552
+
553
+ // Fallback to title match
554
+ if (hostLabel && orgTitle) {
555
+ return hostLabel.toLowerCase() === orgTitle
556
+ }
557
+
558
+ // If no organization info, include nothing
559
+ return false
560
+ },
561
+
522
562
  // Called when user clicks Ask or presses Enter in question field
523
563
  onAskQuestion() {
524
564
  const q = (this.questionText || '').trim()
@@ -556,8 +596,30 @@ export default {
556
596
  }
557
597
  )
558
598
 
559
- this.aiAnswer = this.sanitizeAnswer(answer)
599
+ const { cleanAnswer, keywords: aiKeywords } =
600
+ this.extractKeywordsAndCleanAnswer(answer || '')
601
+
602
+ this.aiAnswer = this.sanitizeAnswer(cleanAnswer)
560
603
  this.aiSources = sources || []
604
+
605
+ const safeKeywords = this.sanitizeKeywords(aiKeywords)
606
+ const fallbackKeywords = safeKeywords
607
+ ? ''
608
+ : this.deriveFallbackKeywords(this.questionText)
609
+ const keywordsToUse = safeKeywords || fallbackKeywords
610
+
611
+ // Show the AI-provided keywords (or fallback) in brackets for visibility.
612
+ const displayKeywordsSource = aiKeywords || keywordsToUse || ''
613
+ this.aiKeywordsDisplay = displayKeywordsSource
614
+ ? `[Keywords: ${displayKeywordsSource.replace(/<[^>]*>/g, '')}]`
615
+ : ''
616
+
617
+ if (keywordsToUse) {
618
+ this.keywordsFromAi = true
619
+ this.keywords = keywordsToUse
620
+ // Let the keywords watcher trigger the searches; keep Answers tab active.
621
+ }
622
+
561
623
  this.currentTab = 'Answers'
562
624
  } catch (e) {
563
625
  console.error('AI search failed', e)
@@ -624,6 +686,122 @@ export default {
624
686
  cleanNode(container)
625
687
  return container.innerHTML
626
688
  },
689
+
690
+ extractKeywordsAndCleanAnswer(answerText) {
691
+ if (!answerText) return { cleanAnswer: '', keywords: '' }
692
+
693
+ // Grab the last occurrence of a keywords marker to reduce prompt-injection tricks.
694
+ // Accepts `[Keywords: ...]` or `Keywords: ...` without brackets.
695
+ const regex = /\[?\s*keywords[:\-\s]*([^\]\n\r]+)\]?/gi
696
+ let match
697
+ let lastMatch = null
698
+ while ((match = regex.exec(answerText)) !== null) {
699
+ lastMatch = match
700
+ }
701
+
702
+ const rawKeywords = lastMatch ? (lastMatch[1] || '').trim() : ''
703
+
704
+ // Keep the keywords block visible for now. To hide later, uncomment below:
705
+ const cleanAnswer = lastMatch
706
+ ? `${answerText.slice(0, lastMatch.index)}${answerText
707
+ .slice(lastMatch.index + lastMatch[0].length)
708
+ .trim()}`
709
+ : answerText
710
+
711
+ return { cleanAnswer: cleanAnswer.trim(), keywords: rawKeywords }
712
+ },
713
+
714
+ sanitizeKeywords(rawKeywords) {
715
+ if (!rawKeywords || typeof rawKeywords !== 'string') return ''
716
+
717
+ // Remove brackets, leading "keywords" label, and weird punctuation; keep basic word chars, spaces, hyphens, commas
718
+ const cleaned = rawKeywords
719
+ .replace(/<[^>]*>/g, ' ')
720
+ .replace(/[\[\]\(\)\{\}]/g, ' ')
721
+ .replace(/\bkeywords\b[:\-\s]*/i, '')
722
+ .replace(/[^a-z0-9,\-\s]/gi, ' ')
723
+ .trim()
724
+
725
+ if (!cleaned) return ''
726
+
727
+ // Split on commas/semicolons/pipes first; fall back to spaces
728
+ const parts =
729
+ cleaned.split(/[,;|]/).filter(Boolean).map(p => p.trim()).filter(Boolean)
730
+ const tokens =
731
+ parts.length > 0
732
+ ? parts
733
+ : cleaned.split(/\s+/).filter(Boolean)
734
+
735
+ const unique = []
736
+ for (const token of tokens) {
737
+ if (!token) continue
738
+ if (token.length <= 1) continue
739
+ const clipped = token.slice(0, 32)
740
+ if (!unique.includes(clipped)) {
741
+ unique.push(clipped)
742
+ }
743
+ if (unique.length >= 3) break
744
+ }
745
+
746
+ const result = unique.join(' ').slice(0, 100).trim()
747
+ return result
748
+ },
749
+
750
+ deriveFallbackKeywords(questionText) {
751
+ if (!questionText) return ''
752
+ const STOP = new Set([
753
+ 'a',
754
+ 'an',
755
+ 'the',
756
+ 'and',
757
+ 'or',
758
+ 'of',
759
+ 'for',
760
+ 'to',
761
+ 'in',
762
+ 'on',
763
+ 'with',
764
+ 'at',
765
+ 'by',
766
+ 'from',
767
+ 'about',
768
+ 'as',
769
+ 'is',
770
+ 'are',
771
+ 'was',
772
+ 'were',
773
+ 'be',
774
+ 'been',
775
+ 'being',
776
+ 'this',
777
+ 'that',
778
+ 'these',
779
+ 'those',
780
+ 'it',
781
+ 'its',
782
+ 'your',
783
+ 'you',
784
+ 'we',
785
+ 'us',
786
+ 'our',
787
+ 'i',
788
+ 'me',
789
+ 'my',
790
+ ])
791
+ const tokens = questionText
792
+ .toLowerCase()
793
+ .replace(/[^a-z0-9\s]/g, ' ')
794
+ .split(/\s+/)
795
+ .filter(Boolean)
796
+ .filter((t) => !STOP.has(t))
797
+
798
+ const uniq = []
799
+ for (const t of tokens) {
800
+ if (!uniq.includes(t)) uniq.push(t)
801
+ if (uniq.length >= 3) break
802
+ }
803
+ return uniq.join(' ')
804
+ },
627
805
  },
628
806
  }
629
807
  </script>
@@ -746,30 +924,39 @@ export default {
746
924
  }
747
925
 
748
926
  ul {
749
- list-style: none;
927
+ list-style: disc;
928
+ list-style-position: inside;
750
929
  padding-left: 0;
751
930
  margin: 0;
752
931
  }
753
932
 
754
933
  li {
934
+ list-style: none;
755
935
  font-size: 0.85rem;
756
936
  margin-bottom: space(0.25);
757
937
  }
758
938
  }
759
939
 
760
- &__ai-source-list {
761
- list-style: disc;
762
- list-style-position: inside;
940
+ &__ai-source-groups {
941
+ list-style: none;
763
942
  padding-left: 0;
764
- margin: space(0.25) 0 0.35rem 0;
943
+ margin: 0;
944
+ &__ai-source-list {
945
+ list-style: disc;
946
+ list-style-position: inside;
947
+ padding-left: space(0.75);
948
+ margin: space(0.25) 0 0.35rem space(0.5);
765
949
 
766
- li {
767
- display: list-item;
768
- margin: 0;
769
- padding: 0;
950
+ li {
951
+ display: list-item;
952
+ margin: 0;
953
+ padding: 0;
954
+ }
770
955
  }
771
956
  }
772
957
 
958
+
959
+
773
960
  &__ai-snippet {
774
961
  margin: 0;
775
962
  opacity: 0.85;