Improve LLM

This commit is contained in:
Andras Schmelczer 2026-03-15 14:05:34 +00:00
parent 02712f41e8
commit 80c093b7ba
16 changed files with 898 additions and 278 deletions

View file

@ -1,6 +1,6 @@
import { useState, useCallback, useRef } from 'react';
import type { FeatureFilters } from '../types';
import type { TransportMode } from './useTravelTime';
import type { TransportMode, TravelTimeEntry } from './useTravelTime';
import { apiUrl, authHeaders, logNonAbortError } from '../lib/api';
export interface AiTravelTimeFilter {
@ -11,20 +11,54 @@ export interface AiTravelTimeFilter {
max?: number;
}
interface AiFiltersResult {
export interface AiFiltersResult {
filters: FeatureFilters;
travelTimeFilters: AiTravelTimeFilter[];
notes: string;
/** Human-readable summary of what was set */
summary: string;
}
export type AiFilterErrorType = 'auth' | 'verification' | 'limit' | 'error';
/** Context of currently active filters, sent for conversational refinement. */
export interface AiFiltersContext {
filters: FeatureFilters;
travelTime: { mode: string; label: string; min?: number; max?: number }[];
}
interface UseAiFiltersResult {
fetchAiFilters: (query: string) => Promise<AiFiltersResult | null>;
fetchAiFilters: (query: string, context?: AiFiltersContext) => Promise<AiFiltersResult | null>;
loading: boolean;
error: string | null;
errorType: AiFilterErrorType | null;
notes: string | null;
summary: string | null;
}
/** Build a human-readable summary of the AI result. */
function buildSummary(
filters: FeatureFilters,
travelTimeFilters: AiTravelTimeFilter[]
): string {
const parts: string[] = [];
for (const [name, value] of Object.entries(filters)) {
if (Array.isArray(value) && value.length === 2 && typeof value[0] === 'number') {
parts.push(name);
} else if (Array.isArray(value)) {
parts.push(`${name}: ${(value as string[]).join(', ')}`);
}
}
for (const tt of travelTimeFilters) {
const bounds =
tt.max !== undefined ? `< ${tt.max} min` : tt.min !== undefined ? `> ${tt.min} min` : '';
parts.push(`${tt.mode} to ${tt.label} ${bounds}`.trim());
}
if (parts.length === 0) return 'No filters set';
return `Set ${parts.length} filter${parts.length > 1 ? 's' : ''}: ${parts.join(', ')}`;
}
export function useAiFilters(): UseAiFiltersResult {
@ -32,77 +66,93 @@ export function useAiFilters(): UseAiFiltersResult {
const [error, setError] = useState<string | null>(null);
const [errorType, setErrorType] = useState<AiFilterErrorType | null>(null);
const [notes, setNotes] = useState<string | null>(null);
const [summary, setSummary] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const fetchAiFilters = useCallback(async (query: string): Promise<AiFiltersResult | null> => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
const fetchAiFilters = useCallback(
async (query: string, context?: AiFiltersContext): Promise<AiFiltersResult | null> => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setLoading(true);
setError(null);
setErrorType(null);
setNotes(null);
setLoading(true);
setError(null);
setErrorType(null);
setNotes(null);
setSummary(null);
try {
const url = apiUrl('ai-filters');
const response = await fetch(
url,
authHeaders({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query }),
signal: controller.signal,
})
);
if (!response.ok) {
const text = await response.text();
if (response.status === 401) {
setErrorType('auth');
setError(text || 'Login required');
} else if (response.status === 403) {
setErrorType('verification');
setError(text || 'Email verification required');
} else if (response.status === 429) {
setErrorType('limit');
setError(text || 'Weekly usage limit reached');
} else {
setErrorType('error');
setError(text || `HTTP ${response.status}`);
try {
const url = apiUrl('ai-filters');
const bodyObj: Record<string, unknown> = { query };
if (context) {
bodyObj.context = {
filters: context.filters,
travel_time: context.travelTime,
};
}
const response = await fetch(
url,
authHeaders({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(bodyObj),
signal: controller.signal,
})
);
if (!response.ok) {
const text = await response.text();
if (response.status === 401) {
setErrorType('auth');
setError(text || 'Login required');
} else if (response.status === 403) {
setErrorType('verification');
setError(text || 'Email verification required');
} else if (response.status === 429) {
setErrorType('limit');
setError(text || 'Weekly usage limit reached');
} else {
setErrorType('error');
setError(text || `HTTP ${response.status}`);
}
setLoading(false);
return null;
}
const json = await response.json();
const travelTimeFilters: AiTravelTimeFilter[] = (json.travel_time_filters || []).map(
(tt: { mode: string; slug: string; label: string; min?: number; max?: number }) => ({
mode: tt.mode as TransportMode,
slug: tt.slug,
label: tt.label,
min: tt.min,
max: tt.max,
})
);
const filters = json.filters as FeatureFilters;
const summaryText = buildSummary(filters, travelTimeFilters);
const result: AiFiltersResult = {
filters,
travelTimeFilters,
notes: json.notes || '',
summary: summaryText,
};
setNotes(result.notes || null);
setSummary(summaryText);
setLoading(false);
return result;
} catch (err) {
if (controller.signal.aborted) return null;
logNonAbortError('ai-filters', err);
const message = err instanceof Error ? err.message : 'Failed to generate filters';
setErrorType('error');
setError(message);
setLoading(false);
return null;
}
},
[]
);
const json = await response.json();
const travelTimeFilters: AiTravelTimeFilter[] = (json.travel_time_filters || []).map(
(tt: { mode: string; slug: string; label: string; min?: number; max?: number }) => ({
mode: tt.mode as TransportMode,
slug: tt.slug,
label: tt.label,
min: tt.min,
max: tt.max,
})
);
const result: AiFiltersResult = {
filters: json.filters as FeatureFilters,
travelTimeFilters,
notes: json.notes || '',
};
setNotes(result.notes || null);
setLoading(false);
return result;
} catch (err) {
if (controller.signal.aborted) return null;
logNonAbortError('ai-filters', err);
const message = err instanceof Error ? err.message : 'Failed to generate filters';
setErrorType('error');
setError(message);
setLoading(false);
return null;
}
}, []);
return { fetchAiFilters, loading, error, errorType, notes };
return { fetchAiFilters, loading, error, errorType, notes, summary };
}