Improve LLM
This commit is contained in:
parent
02712f41e8
commit
80c093b7ba
16 changed files with 898 additions and 278 deletions
|
|
@ -42,6 +42,7 @@ interface AiFilterInputProps {
|
|||
error: string | null;
|
||||
errorType: AiFilterErrorType | null;
|
||||
notes: string | null;
|
||||
summary: string | null;
|
||||
onSubmit: (query: string) => void;
|
||||
isLoggedIn: boolean;
|
||||
onLoginRequired: () => void;
|
||||
|
|
@ -52,6 +53,7 @@ export default memo(function AiFilterInput({
|
|||
error,
|
||||
errorType,
|
||||
notes,
|
||||
summary,
|
||||
onSubmit,
|
||||
isLoggedIn,
|
||||
onLoginRequired,
|
||||
|
|
@ -88,7 +90,7 @@ export default memo(function AiFilterInput({
|
|||
);
|
||||
|
||||
const hasContent = query.trim().length > 0;
|
||||
const showExamples = expanded && !hasContent && !loading && !error && !notes;
|
||||
const showExamples = expanded && !hasContent && !loading && !error && !notes && !summary;
|
||||
|
||||
if (!expanded) {
|
||||
return (
|
||||
|
|
@ -173,6 +175,11 @@ export default memo(function AiFilterInput({
|
|||
{error}
|
||||
</p>
|
||||
)}
|
||||
{summary && !error && !loading && (
|
||||
<p className="mt-1 text-xs text-teal-600 dark:text-teal-400">
|
||||
{summary}
|
||||
</p>
|
||||
)}
|
||||
{notes && !error && !loading && (
|
||||
<p className="mt-1 text-xs text-warm-500 dark:text-warm-400 italic">
|
||||
{notes}
|
||||
|
|
|
|||
|
|
@ -92,6 +92,7 @@ interface FiltersProps {
|
|||
aiFilterError: string | null;
|
||||
aiFilterErrorType: AiFilterErrorType | null;
|
||||
aiFilterNotes: string | null;
|
||||
aiFilterSummary: string | null;
|
||||
onAiFilterSubmit: (query: string) => void;
|
||||
isLoggedIn: boolean;
|
||||
onLoginRequired: () => void;
|
||||
|
|
@ -127,6 +128,7 @@ export default memo(function Filters({
|
|||
aiFilterError,
|
||||
aiFilterErrorType,
|
||||
aiFilterNotes,
|
||||
aiFilterSummary,
|
||||
onAiFilterSubmit,
|
||||
isLoggedIn,
|
||||
onLoginRequired,
|
||||
|
|
@ -285,7 +287,7 @@ export default memo(function Filters({
|
|||
</div>
|
||||
|
||||
<div ref={scrollRef} className="md:flex-1 md:overflow-y-auto">
|
||||
<AiFilterInput loading={aiFilterLoading} error={aiFilterError} errorType={aiFilterErrorType} notes={aiFilterNotes} onSubmit={onAiFilterSubmit} isLoggedIn={isLoggedIn} onLoginRequired={onLoginRequired} />
|
||||
<AiFilterInput loading={aiFilterLoading} error={aiFilterError} errorType={aiFilterErrorType} notes={aiFilterNotes} summary={aiFilterSummary} onSubmit={onAiFilterSubmit} isLoggedIn={isLoggedIn} onLoginRequired={onLoginRequired} />
|
||||
<div className="px-3 pb-2 space-y-2">
|
||||
<div className="flex rounded-lg bg-warm-100 dark:bg-warm-800 p-0.5">
|
||||
{(['historical', 'buy', 'rent'] as const).map((type) => {
|
||||
|
|
|
|||
|
|
@ -148,22 +148,33 @@ export default function MapPage({
|
|||
|
||||
const handleAiFilterSubmit = useCallback(
|
||||
async (query: string) => {
|
||||
const result = await aiFilters.fetchAiFilters(query);
|
||||
// Build context from current filters for conversational refinement
|
||||
const context = {
|
||||
filters,
|
||||
travelTime: travelTime.activeEntries.map((entry) => ({
|
||||
mode: entry.mode,
|
||||
label: entry.label,
|
||||
min: entry.timeRange?.[0],
|
||||
max: entry.timeRange?.[1],
|
||||
})),
|
||||
};
|
||||
const hasContext =
|
||||
Object.keys(context.filters).length > 0 || context.travelTime.length > 0;
|
||||
|
||||
const result = await aiFilters.fetchAiFilters(query, hasContext ? context : undefined);
|
||||
if (!result) return;
|
||||
handleSetFilters(result.filters);
|
||||
// Apply travel time filters from AI
|
||||
if (result.travelTimeFilters.length > 0) {
|
||||
const newEntries = result.travelTimeFilters.map((tt) => ({
|
||||
mode: tt.mode,
|
||||
slug: tt.slug,
|
||||
label: tt.label,
|
||||
timeRange: [tt.min ?? 0, tt.max ?? 120] as [number, number],
|
||||
useBest: false,
|
||||
}));
|
||||
travelTime.handleSetEntries(newEntries);
|
||||
}
|
||||
// Always sync travel time entries — clear stale ones when AI returns none
|
||||
const newEntries = result.travelTimeFilters.map((tt) => ({
|
||||
mode: tt.mode,
|
||||
slug: tt.slug,
|
||||
label: tt.label,
|
||||
timeRange: [tt.min ?? 0, tt.max ?? 120] as [number, number],
|
||||
useBest: false,
|
||||
}));
|
||||
travelTime.handleSetEntries(newEntries);
|
||||
},
|
||||
[aiFilters.fetchAiFilters, handleSetFilters, travelTime.handleSetEntries]
|
||||
[aiFilters.fetchAiFilters, handleSetFilters, travelTime.handleSetEntries, travelTime.activeEntries, filters]
|
||||
);
|
||||
|
||||
const handleTravelTimeSetDestination = useCallback(
|
||||
|
|
@ -514,6 +525,7 @@ export default function MapPage({
|
|||
aiFilterError={aiFilters.error}
|
||||
aiFilterErrorType={aiFilters.errorType}
|
||||
aiFilterNotes={aiFilters.notes}
|
||||
aiFilterSummary={aiFilters.summary}
|
||||
onAiFilterSubmit={handleAiFilterSubmit}
|
||||
isLoggedIn={!!user}
|
||||
onLoginRequired={onRegisterClick ?? (() => {})}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue