Skip to content

Commit e423293

Browse files
AryanBVclaude
andcommitted
fix: Resolve TypeScript strict null check errors for Railway build
Fixes build failures caused by noUncheckedIndexedAccess TypeScript setting: llm-navigator.service.ts: - Add null checks for array index access in meaningfulParts - Add null guard for line iteration in parseLLMResponse - Fix array destructuring for split() results with proper fallbacks - Fix singleOption access after length check - Fix fallbackOption access with non-null assertions llm-conversational-classifier.service.ts: - Add null coalescing for split() array access - Add null guards for userDecisions array access - Fix lastDecision possibly undefined errors Also includes pre-filter optimization for cross-chapter products to reduce LLM token usage at root level. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 785dc4a commit e423293

2 files changed

Lines changed: 75 additions & 26 deletions

File tree

backend/src/services/llm-conversational-classifier.service.ts

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -267,10 +267,10 @@ async function continueConversation(
267267
// Parse the code from the answer (remove label if present)
268268
let selectedCode = selectedAnswer;
269269
if (selectedAnswer.includes('::')) {
270-
selectedCode = selectedAnswer.split('::')[0].trim();
270+
selectedCode = selectedAnswer.split('::')[0]?.trim() ?? selectedAnswer;
271271
} else if (selectedAnswer.includes(':')) {
272272
// Legacy format "CODE: label"
273-
selectedCode = selectedAnswer.split(':')[0].trim();
273+
selectedCode = selectedAnswer.split(':')[0]?.trim() ?? selectedAnswer;
274274
}
275275

276276
logger.info(`[LLM-CLASSIFIER] User selected: ${selectedAnswer} -> code: ${selectedCode}`);
@@ -288,7 +288,7 @@ async function continueConversation(
288288
// Record user's decision for better reasoning
289289
if (state.pendingQuestionText && state.pendingQuestionOptions) {
290290
const selectedLabel = selectedAnswer.includes('::')
291-
? selectedAnswer.split('::')[1].trim()
291+
? (selectedAnswer.split('::')[1]?.trim() ?? codeDetails.description)
292292
: codeDetails.description;
293293

294294
const alternatives = state.pendingQuestionOptions
@@ -625,10 +625,12 @@ async function buildSmartAlternatives(
625625
// No parent - return last question's alternatives only
626626
if (userDecisions.length > 0) {
627627
const lastDecision = userDecisions[userDecisions.length - 1];
628-
return lastDecision.alternatives.map(alt => ({
629-
code: alt.code,
630-
description: alt.label
631-
}));
628+
if (lastDecision) {
629+
return lastDecision.alternatives.map(alt => ({
630+
code: alt.code,
631+
description: alt.label
632+
}));
633+
}
632634
}
633635
return [];
634636
}
@@ -661,10 +663,12 @@ async function buildSmartAlternatives(
661663
// Fallback to last question's alternatives
662664
if (userDecisions.length > 0) {
663665
const lastDecision = userDecisions[userDecisions.length - 1];
664-
return lastDecision.alternatives.map(alt => ({
665-
code: alt.code,
666-
description: alt.label
667-
}));
666+
if (lastDecision) {
667+
return lastDecision.alternatives.map(alt => ({
668+
code: alt.code,
669+
description: alt.label
670+
}));
671+
}
668672
}
669673
return [];
670674
}
@@ -799,9 +803,9 @@ export async function skipToClassification(
799803
// Parse code from "CODE::LABEL" format if needed
800804
let code = selectedCode;
801805
if (selectedCode.includes('::')) {
802-
code = selectedCode.split('::')[0].trim();
806+
code = selectedCode.split('::')[0]?.trim() ?? selectedCode;
803807
} else if (selectedCode.includes(':')) {
804-
code = selectedCode.split(':')[0].trim();
808+
code = selectedCode.split(':')[0]?.trim() ?? selectedCode;
805809
}
806810

807811
const codeDetails = await validateCode(code);

backend/src/services/llm-navigator.service.ts

Lines changed: 58 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -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(/CODE:\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

Comments
 (0)