@asd20/ui 3.8.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.8.0",
3
+ "version": "3.10.0",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "sideEffects": [
@@ -18,7 +18,7 @@
18
18
  idTag="-question"
19
19
  ref="question"
20
20
  v-model="questionText"
21
- @keyup.enter.stop.prevent="onAskQuestion"
21
+ @keyup.enter.native.stop.prevent="onAskQuestion"
22
22
  placeholder="Ask a question (preview)"
23
23
  />
24
24
  <asd20-button
@@ -73,32 +73,42 @@
73
73
  title="Ask a Question, or Search by Keyword"
74
74
  description="Use the top input for plain-language questions, then press 'Ask'; or search by keyword in the lower input box."
75
75
  />
76
- <div class="disclaimer">
77
- <p>
78
- AI generated answers can have errors. Please contact our
79
- <a href="www.asd20.org/help-desk">Help Desk</a> if you need
80
- additional information.
81
- </p>
82
- </div>
83
76
 
84
77
  <!-- Answers tab -->
85
78
  <div v-show="currentTab === 'Answers'" scrollable>
79
+ <div v-if="aiAnswer" class="disclaimer">
80
+ <p>
81
+ AI generated answers can have errors. Please contact our
82
+ <a href="www.asd20.org/help-desk">Help Desk</a> if you need
83
+ additional information.
84
+ </p>
85
+ </div>
86
+
86
87
  <div v-if="aiAnswer" class="asd20-site-search__ai-result">
87
88
  <h3>Answer</h3>
88
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>
89
96
 
90
97
  <div
91
- v-if="aiUiSources.length"
98
+ v-if="aiGroupedSources.length"
92
99
  class="asd20-site-search__ai-sources"
93
100
  >
94
101
  <h4>Sources</h4>
95
- <ul>
96
- <li v-for="src in aiUiSources" :key="src.url || src.id">
97
- <strong>Content from: {{ src.hostLabel }}</strong
98
- ><br />
99
- <a :href="src.url" target="_blank" rel="noreferrer">
100
- {{ src.title }}
101
- </a>
102
+ <ul class="asd20-site-search__ai-source-groups">
103
+ <li v-for="group in aiGroupedSources" :key="group.hostLabel">
104
+ <strong>Content from: {{ group.hostLabel }}</strong>
105
+ <ul class="asd20-site-search__ai-source-list">
106
+ <li v-for="src in group.sources" :key="src.url || src.id">
107
+ <a :href="src.url" target="_blank" rel="noreferrer">
108
+ {{ src.title }}
109
+ </a>
110
+ </li>
111
+ </ul>
102
112
  </li>
103
113
  </ul>
104
114
  </div>
@@ -275,6 +285,8 @@ export default {
275
285
  selectedGroup: null,
276
286
  searchingPages: false,
277
287
  searchingFiles: false,
288
+ keywordsFromAi: false,
289
+ aiKeywordsDisplay: '',
278
290
  }),
279
291
 
280
292
  computed: {
@@ -345,9 +357,42 @@ export default {
345
357
  aiUiSources() {
346
358
  return (this.aiSources || []).map(src => ({
347
359
  ...src,
348
- hostLabel: this.labelFromUrl(src.url),
360
+ hostLabel:
361
+ this.resolveOrgTitleFromUrl(src.url) || this.labelFromUrl(src.url),
349
362
  }))
350
363
  },
364
+ aiGroupedSources() {
365
+ const groups = new Map()
366
+ const filteredSources = this.aiUiSources.filter((src) =>
367
+ this.isSourceCurrentOrg(src.url, src.hostLabel)
368
+ )
369
+ filteredSources.forEach(src => {
370
+ const key = src.hostLabel || 'Academy District 20'
371
+ if (!groups.has(key)) {
372
+ groups.set(key, { hostLabel: key, sources: [] })
373
+ }
374
+ const group = groups.get(key)
375
+ const exists = group.sources.find(
376
+ s => (s.url && src.url && s.url === src.url) || (s.id && src.id && s.id === src.id)
377
+ )
378
+ if (!exists) {
379
+ group.sources.push(src)
380
+ }
381
+ })
382
+ const currentOrgTitle =
383
+ (this.organization && this.organization.title) || 'Academy District 20'
384
+ const currentLower = currentOrgTitle.toLowerCase()
385
+
386
+ return Array.from(groups.values()).sort((a, b) => {
387
+ const aIsCurrent =
388
+ (a.hostLabel || '').toLowerCase() === currentLower
389
+ const bIsCurrent =
390
+ (b.hostLabel || '').toLowerCase() === currentLower
391
+ if (aIsCurrent && !bIsCurrent) return -1
392
+ if (bIsCurrent && !aIsCurrent) return 1
393
+ return (a.hostLabel || '').localeCompare(b.hostLabel || '')
394
+ })
395
+ },
351
396
  },
352
397
 
353
398
  watch: {
@@ -388,14 +433,16 @@ export default {
388
433
  },
389
434
 
390
435
  methods: {
391
- async searchPages() {
436
+ async searchPages(options = {}) {
392
437
  if (this.$store && this.$store.state.search) {
438
+ const keepAnswersTab = options.keepAnswersTab || this.keywordsFromAi
393
439
  this.searchingPages = true
394
440
  await this.$store.dispatch('search/queryPages', this.keywords)
395
441
  this.searchingPages = false
396
- if (this.keywords && this.keywords.trim()) {
442
+ if (this.keywords && this.keywords.trim() && !keepAnswersTab) {
397
443
  this.currentTab = 'Pages'
398
444
  }
445
+ this.keywordsFromAi = false
399
446
  }
400
447
  },
401
448
 
@@ -447,6 +494,12 @@ export default {
447
494
  return 'Rampart High School'
448
495
  case 'pinecreek':
449
496
  return 'Pine Creek High School'
497
+ case 'dcchigh':
498
+ return 'Discovery Canyon Campus High School'
499
+ case 'liberty':
500
+ return 'Liberty High School'
501
+ case 'd20online':
502
+ return 'Academy Online High School'
450
503
  // add more mappings as needed
451
504
  default:
452
505
  return subdomain.charAt(0).toUpperCase() + subdomain.slice(1)
@@ -459,6 +512,53 @@ export default {
459
512
  }
460
513
  },
461
514
 
515
+ // Try to resolve the full organization title from organizationOptions by URL
516
+ resolveOrgTitleFromUrl(url) {
517
+ if (!url || !Array.isArray(this.organizationOptions)) return null
518
+ try {
519
+ const host = new URL(url).hostname.toLowerCase()
520
+ const match = this.organizationOptions.find((org) => {
521
+ if (!org || !org.website) return false
522
+ try {
523
+ const orgHost = new URL(org.website).hostname.toLowerCase()
524
+ return host === orgHost || host.endsWith(orgHost)
525
+ } catch {
526
+ return false
527
+ }
528
+ })
529
+ return match && match.title ? match.title : null
530
+ } catch {
531
+ return null
532
+ }
533
+ },
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
+
462
562
  // Called when user clicks Ask or presses Enter in question field
463
563
  onAskQuestion() {
464
564
  const q = (this.questionText || '').trim()
@@ -496,8 +596,30 @@ export default {
496
596
  }
497
597
  )
498
598
 
499
- this.aiAnswer = this.sanitizeAnswer(answer)
599
+ const { cleanAnswer, keywords: aiKeywords } =
600
+ this.extractKeywordsAndCleanAnswer(answer || '')
601
+
602
+ this.aiAnswer = this.sanitizeAnswer(cleanAnswer)
500
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
+
501
623
  this.currentTab = 'Answers'
502
624
  } catch (e) {
503
625
  console.error('AI search failed', e)
@@ -564,6 +686,122 @@ export default {
564
686
  cleanNode(container)
565
687
  return container.innerHTML
566
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
+ },
567
805
  },
568
806
  }
569
807
  </script>
@@ -658,6 +896,22 @@ export default {
658
896
 
659
897
  &__ai-answer {
660
898
  margin-bottom: space(0.5);
899
+
900
+ ::v-deep ol,
901
+ ::v-deep ul {
902
+ display: block !important;
903
+ list-style-position: inside;
904
+ padding-left: 0;
905
+ margin-left: 0;
906
+ flex: 0 0 auto;
907
+ flex-wrap: nowrap;
908
+ }
909
+
910
+ ::v-deep li {
911
+ display: list-item;
912
+ flex: 0 0 auto;
913
+ width: auto;
914
+ }
661
915
  }
662
916
 
663
917
  &__ai-sources {
@@ -670,17 +924,39 @@ export default {
670
924
  }
671
925
 
672
926
  ul {
673
- list-style: none;
927
+ list-style: disc;
928
+ list-style-position: inside;
674
929
  padding-left: 0;
675
930
  margin: 0;
676
931
  }
677
932
 
678
933
  li {
934
+ list-style: none;
679
935
  font-size: 0.85rem;
680
936
  margin-bottom: space(0.25);
681
937
  }
682
938
  }
683
939
 
940
+ &__ai-source-groups {
941
+ list-style: none;
942
+ padding-left: 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);
949
+
950
+ li {
951
+ display: list-item;
952
+ margin: 0;
953
+ padding: 0;
954
+ }
955
+ }
956
+ }
957
+
958
+
959
+
684
960
  &__ai-snippet {
685
961
  margin: 0;
686
962
  opacity: 0.85;