@@ -90,7 +90,7 @@ async function buildUserFriendlyReasoning(
9090 ) ;
9191
9292 if ( meaningfulParts . length >= 2 ) {
93- const mainCategory = meaningfulParts [ 0 ] . description ;
93+ const mainCategory = meaningfulParts [ 0 ] ! . description ;
9494 const subParts = meaningfulParts . slice ( 1 ) . map ( p => p . description ) ;
9595
9696 let reasoning = `"${ productName } " is classified under ${ mainCategory } ` ;
@@ -102,7 +102,7 @@ async function buildUserFriendlyReasoning(
102102 }
103103 return reasoning ;
104104 } else if ( meaningfulParts . length === 1 ) {
105- let reasoning = `"${ productName } " falls under ${ meaningfulParts [ 0 ] . description } ` ;
105+ let reasoning = `"${ productName } " falls under ${ meaningfulParts [ 0 ] ! . description } ` ;
106106 if ( context ) {
107107 reasoning += `. ${ context } ` ;
108108 }
@@ -511,6 +511,38 @@ const CROSS_CHAPTER_PRODUCTS: Record<string, string[]> = {
511511 'seafood' : [ '03' , '16' ] ,
512512} ;
513513
514+ /**
515+ * PRE-FILTER root options BEFORE sending to LLM to reduce token usage
516+ * For known cross-chapter products, only send relevant chapter headings
517+ * This reduces prompts from 1125 options (~20K tokens) to 2-10 options (~500 tokens)
518+ */
519+ function preFilterRootOptions (
520+ userInput : string ,
521+ options : HierarchyOption [ ]
522+ ) : HierarchyOption [ ] {
523+ const inputLower = userInput . toLowerCase ( ) ;
524+
525+ // Check for cross-chapter products
526+ for ( const [ product , validChapters ] of Object . entries ( CROSS_CHAPTER_PRODUCTS ) ) {
527+ if ( inputLower . includes ( product ) ) {
528+ // Filter to only include headings from valid chapters
529+ const filtered = options . filter ( opt => {
530+ const chapterCode = opt . code . substring ( 0 , 2 ) ;
531+ return validChapters . includes ( chapterCode ) ;
532+ } ) ;
533+
534+ if ( filtered . length > 0 ) {
535+ logger . info ( `[LLM-NAV] Cross-chapter product "${ product } " - filtered to chapters: ${ validChapters . join ( ', ' ) } ` ) ;
536+ return filtered ;
537+ }
538+ }
539+ }
540+
541+ // If no cross-chapter match, return original (will use full list)
542+ // TODO: Add keyword-based filtering for other products to further reduce tokens
543+ return options ;
544+ }
545+
514546/**
515547 * Filter options to only include valid chapters for cross-chapter products
516548 * This prevents the LLM from hallucinating irrelevant chapters
@@ -572,6 +604,7 @@ function parseNavigationResponse(
572604
573605 for ( let i = 0 ; i < lines . length ; i ++ ) {
574606 const line = lines [ i ] ;
607+ if ( ! line ) continue ;
575608
576609 if ( line . startsWith ( 'ACTION:' ) ) {
577610 action = line . replace ( 'ACTION:' , '' ) . trim ( ) . toUpperCase ( ) ;
@@ -658,12 +691,14 @@ function parseNavigationResponse(
658691 // New format: CODE|FRIENDLY_LABEL
659692 for ( const optLine of optionCodes ) {
660693 if ( optLine . includes ( '|' ) ) {
661- const [ code , label ] = optLine . split ( '|' ) . map ( s => s . trim ( ) ) ;
662- const matchingOption = options . find ( o => o . code === code || o . code . includes ( code ) || code . includes ( o . code ) ) ;
694+ const parts = optLine . split ( '|' ) . map ( s => s . trim ( ) ) ;
695+ const optCode = parts [ 0 ] ?? '' ;
696+ const label = parts [ 1 ] ?? '' ;
697+ const matchingOption = options . find ( o => o . code === optCode || o . code . includes ( optCode ) || optCode . includes ( o . code ) ) ;
663698 if ( matchingOption ) {
664699 finalOptions . push ( {
665700 code : matchingOption . code ,
666- label : label || matchingOption . description . split ( ':' ) [ 0 ] . trim ( ) ,
701+ label : label || ( matchingOption . description . split ( ':' ) [ 0 ] ? .trim ( ) ?? '' ) ,
667702 description : matchingOption . description
668703 } ) ;
669704 }
@@ -776,10 +811,20 @@ export async function navigateHierarchy(
776811
777812 try {
778813 // Get available options at current level
779- const options = currentCode
814+ let options = currentCode
780815 ? await getChildrenForCode ( currentCode )
781816 : await getAllChapters ( ) ;
782817
818+ // PRE-FILTER at root level to reduce token usage
819+ // For cross-chapter products, only send relevant chapters to LLM
820+ if ( currentCode === null ) {
821+ const filteredOptions = preFilterRootOptions ( userInput , options ) ;
822+ if ( filteredOptions . length > 0 && filteredOptions . length < options . length ) {
823+ logger . info ( `[LLM-NAV] Pre-filtered from ${ options . length } to ${ filteredOptions . length } options` ) ;
824+ options = filteredOptions ;
825+ }
826+ }
827+
783828 logger . info ( `[LLM-NAV] At ${ currentCode || 'root' } , found ${ options . length } options` ) ;
784829
785830 // If no options, we're at a leaf - return classification
@@ -1001,8 +1046,8 @@ export async function forceClassification(
10011046 }
10021047
10031048 // If only one option and it's not "Other", auto-select
1004- if ( options . length === 1 && ! options [ 0 ] . isOther ) {
1005- const singleOption = options [ 0 ] ;
1049+ const singleOption = options . length === 1 ? options [ 0 ] : undefined ;
1050+ if ( singleOption && ! singleOption . isOther ) {
10061051 if ( ! singleOption . hasChildren ) {
10071052 return {
10081053 type : 'classification' ,
@@ -1057,18 +1102,18 @@ export async function forceClassification(
10571102 // Parse the code from response
10581103 const codeMatch = content . match ( / C O D E : \s * ( [ 0 - 9 . ] + ) / i) ;
10591104 if ( ! codeMatch ) {
1060- // Fallback: pick the first non-Other option
1061- const fallbackOption = options . find ( o => ! o . isOther ) || options [ 0 ] ;
1105+ // Fallback: pick the first non-Other option (options is guaranteed non-empty at this point)
1106+ const fallbackOption = options . find ( o => ! o . isOther ) ?? options [ 0 ] ! ;
10621107 code = fallbackOption . code ;
10631108 logger . warn ( `[LLM-NAV] Force mode: couldn't parse code, falling back to ${ code } ` ) ;
10641109 } else {
1065- const selectedCode = codeMatch [ 1 ] . trim ( ) ;
1110+ const selectedCode = codeMatch [ 1 ] ? .trim ( ) ?? '' ;
10661111 const matchedOption = options . find ( o => o . code === selectedCode ) ;
10671112 if ( matchedOption ) {
10681113 code = matchedOption . code ;
10691114 } else {
1070- // Fallback
1071- const fallbackOption = options . find ( o => ! o . isOther ) || options [ 0 ] ;
1115+ // Fallback (options is guaranteed non-empty at this point)
1116+ const fallbackOption = options . find ( o => ! o . isOther ) ?? options [ 0 ] ! ;
10721117 code = fallbackOption . code ;
10731118 }
10741119 }
0 commit comments