Add voice-based place recommendations
Build and deploy Backend / build (push) Successful in 1m20s

This commit is contained in:
Ruslan Bakiev
2026-06-12 22:42:58 +07:00
parent 98bfb032c6
commit 4bc6fcbfeb
9 changed files with 353 additions and 159 deletions
+3
View File
@@ -1,5 +1,8 @@
DATABASE_URL=postgresql://mapflow:mapflow@localhost:5432/mapflow DATABASE_URL=postgresql://mapflow:mapflow@localhost:5432/mapflow
HATCHET_CLIENT_TOKEN= HATCHET_CLIENT_TOKEN=
DEEPGRAM_API_KEY=
DEEPGRAM_MODEL=nova-3
DEEPGRAM_LANGUAGE=ru
OPENROUTER_API_KEY= OPENROUTER_API_KEY=
OPENROUTER_MODEL=deepseek/deepseek-v4-flash OPENROUTER_MODEL=deepseek/deepseek-v4-flash
HOST=0.0.0.0 HOST=0.0.0.0
+110
View File
@@ -0,0 +1,110 @@
import { config } from '../config.js';
import { placeOntology, placeOntologyTags } from '../ontology/place-ontology.js';
type OpenRouterResponse = {
choices?: Array<{
finish_reason?: string;
message?: {
content?: string;
};
}>;
};
export type PlaceIntent = {
tags: string[];
summary: string;
};
export async function classifyPlaceIntent(input: {
transcript: string;
}): Promise<PlaceIntent> {
if (!config.openRouterApiKey) {
throw new Error('OPENROUTER_API_KEY is required for voice place filters.');
}
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: {
authorization: `Bearer ${config.openRouterApiKey}`,
'content-type': 'application/json',
'http-referer': config.webAppUrl,
'x-title': 'MapFlow',
},
body: JSON.stringify({
model: config.openRouterModel,
provider: { require_parameters: true },
temperature: 0.1,
max_tokens: 900,
response_format: {
type: 'json_schema',
json_schema: {
name: 'place_voice_filter',
strict: true,
schema: {
type: 'object',
additionalProperties: false,
required: ['tags', 'summary'],
properties: {
tags: {
type: 'array',
maxItems: 8,
items: { type: 'string', enum: placeOntologyTags },
},
summary: { type: 'string' },
},
},
},
},
messages: [
{
role: 'system',
content:
'You convert a user voice request for a place into MapFlow ontology tags. Return one JSON object. Use only allowed tags. Do not recommend places.',
},
{
role: 'user',
content: JSON.stringify({
task:
'Select tags explicitly implied by the request. If the request is vague, return an empty tags array.',
ontology: placeOntology,
transcript: input.transcript,
}),
},
],
}),
});
if (!response.ok) {
const errorBody = await response.text();
throw new Error(
`OpenRouter voice filter failed with ${response.status}: ${errorBody.slice(0, 500)}`,
);
}
const payload = (await response.json()) as OpenRouterResponse;
const choice = payload.choices?.[0];
const content = choice?.message?.content?.trim();
if (!content) {
throw new Error(
`OpenRouter returned an empty voice filter for ${config.openRouterModel}; finish_reason=${choice?.finish_reason ?? 'missing'}.`,
);
}
const intent = JSON.parse(content) as PlaceIntent;
assertValidPlaceIntent(intent);
return intent;
}
function assertValidPlaceIntent(intent: PlaceIntent) {
if (!Array.isArray(intent.tags)) {
throw new Error('OpenRouter voice filter tags must be an array.');
}
for (const tag of intent.tags) {
if (!placeOntologyTags.includes(tag)) {
throw new Error(`OpenRouter voice filter returned unsupported tag: ${tag}.`);
}
}
if (typeof intent.summary !== 'string') {
throw new Error('OpenRouter voice filter summary must be a string.');
}
}
+66
View File
@@ -0,0 +1,66 @@
import { config } from '../config.js';
type DeepgramResponse = {
results?: {
channels?: Array<{
alternatives?: Array<{
transcript?: string;
paragraphs?: {
transcript?: string;
};
}>;
}>;
};
};
export async function transcribeAudioContent(input: {
audioContentBase64: string;
audioMimeType: string;
}) {
if (!config.deepgramApiKey) {
throw new Error('DEEPGRAM_API_KEY is required for voice place filters.');
}
if (input.audioContentBase64.trim() === '') {
throw new Error('Voice filter audio is required.');
}
if (input.audioMimeType.trim() === '') {
throw new Error('Voice filter audio MIME type is required.');
}
const params = new URLSearchParams({
model: config.deepgramModel,
language: config.deepgramLanguage,
smart_format: 'true',
punctuate: 'true',
});
const response = await fetch(
`https://api.deepgram.com/v1/listen?${params.toString()}`,
{
method: 'POST',
headers: {
authorization: `Token ${config.deepgramApiKey}`,
'content-type': input.audioMimeType,
},
body: Buffer.from(input.audioContentBase64, 'base64'),
},
);
if (!response.ok) {
const body = await response.text();
throw new Error(
`Deepgram voice filter transcription failed with ${response.status}: ${body.slice(0, 500)}`,
);
}
const payload = (await response.json()) as DeepgramResponse;
const alternative = payload.results?.channels?.[0]?.alternatives?.[0];
const transcript =
alternative?.paragraphs?.transcript?.trim() ??
alternative?.transcript?.trim() ??
'';
if (!transcript) {
throw new Error('Deepgram returned an empty voice filter transcript.');
}
return transcript;
}
+3
View File
@@ -13,6 +13,9 @@ export const config = {
telegramBotUsername: process.env.TELEGRAM_BOT_USERNAME ?? 'carfteebot', telegramBotUsername: process.env.TELEGRAM_BOT_USERNAME ?? 'carfteebot',
telegramWebhookSecret: process.env.TELEGRAM_WEBHOOK_SECRET ?? '', telegramWebhookSecret: process.env.TELEGRAM_WEBHOOK_SECRET ?? '',
googlePlacesApiKey: process.env.GOOGLE_PLACES_API_KEY ?? '', googlePlacesApiKey: process.env.GOOGLE_PLACES_API_KEY ?? '',
deepgramApiKey: process.env.DEEPGRAM_API_KEY ?? '',
deepgramModel: process.env.DEEPGRAM_MODEL ?? 'nova-3',
deepgramLanguage: process.env.DEEPGRAM_LANGUAGE ?? 'ru',
openRouterApiKey: process.env.OPENROUTER_API_KEY ?? '', openRouterApiKey: process.env.OPENROUTER_API_KEY ?? '',
openRouterModel: process.env.OPENROUTER_MODEL ?? 'deepseek/deepseek-v4-flash', openRouterModel: process.env.OPENROUTER_MODEL ?? 'deepseek/deepseek-v4-flash',
webAppUrl: process.env.WEB_APP_URL ?? 'https://map.craftee.vn', webAppUrl: process.env.WEB_APP_URL ?? 'https://map.craftee.vn',
+49 -143
View File
@@ -1,6 +1,8 @@
import { prisma } from '../prisma.js'; import { prisma } from '../prisma.js';
import { config } from '../config.js'; import { config } from '../config.js';
import type { Prisma } from '../generated/prisma/client.js'; import type { Prisma } from '../generated/prisma/client.js';
import { classifyPlaceIntent } from '../ai/place-intent.js';
import { transcribeAudioContent } from '../audio/deepgram.js';
import { import {
fetchGooglePlaceDetails, fetchGooglePlaceDetails,
normalizeGooglePlaceDetails, normalizeGooglePlaceDetails,
@@ -25,8 +27,9 @@ type NearbyPlacesInput = {
radiusMeters: number; radiusMeters: number;
}; };
type SearchPlacesInput = { type VoicePlaceRecommendationsInput = {
query: string; audioContentBase64: string;
audioMimeType: string;
}; };
type GoogleNearbyPlace = { type GoogleNearbyPlace = {
@@ -66,20 +69,6 @@ type PlaceWithRecentExperiences = Prisma.PlaceGetPayload<{
}; };
}>; }>;
type AiSearchResponse = {
choices?: Array<{
finish_reason?: string;
message?: {
content?: string;
};
}>;
};
type AiSearchSelection = {
placeIds: string[];
message: string;
};
function serializeVoiceExperience( function serializeVoiceExperience(
experience: Awaited<ReturnType<typeof prisma.voiceExperience.findMany>>[number], experience: Awaited<ReturnType<typeof prisma.voiceExperience.findMany>>[number],
) { ) {
@@ -242,12 +231,12 @@ export async function listVoiceExperiences() {
return experiences.map(serializeVoiceExperience); return experiences.map(serializeVoiceExperience);
} }
export async function searchPlaces(input: SearchPlacesInput, userId: string) { export async function recommendPlacesByVoice(
const query = input.query.trim(); input: VoicePlaceRecommendationsInput,
if (query === '') { userId: string,
throw new Error('Place search query is required.'); ) {
} const transcript = await transcribeAudioContent(input);
const intent = await classifyPlaceIntent({ transcript });
const places = await prisma.place.findMany({ const places = await prisma.place.findMany({
where: { where: {
experiences: { some: {} }, experiences: { some: {} },
@@ -264,42 +253,13 @@ export async function searchPlaces(input: SearchPlacesInput, userId: string) {
take: 50, take: 50,
}); });
if (places.length === 0) {
return { message: '', places: [] };
}
const selection = await selectPlacesWithAi({
query,
places: places.map((place) => ({
id: place.id,
name: place.name,
googlePrimaryType: place.googlePrimaryType,
googleTypes: place.googleTypes,
traits: placeTraits(place),
businessStatus: place.googleBusinessStatus,
rating: place.googleRating,
userRatingCount: place.googleUserRatingCount,
currentOpeningHours: place.googleCurrentOpeningHours,
isFavorite: place.favoriteUsers.some(
(favorite) => favorite.userId === userId,
),
reviewSummaries: place.experiences
.map((experience) => {
const analysis = jsonObject(experience.analysis);
const summary = analysis?.summary;
return typeof summary === 'string' ? summary : null;
})
.filter((summary) => summary !== null),
})),
});
const byId = new Map(places.map((place) => [place.id, place]));
const selectedPlaces = selection.placeIds
.map((id) => byId.get(id))
.filter((place) => place !== undefined);
return { return {
message: selection.message, transcript,
places: selectedPlaces.map((place) => serializePlace(place, userId)), tags: intent.tags,
summary: intent.summary,
places: rankPlacesByVoiceTags(places, intent.tags, userId).map((place) =>
serializePlace(place, userId),
),
}; };
} }
@@ -548,95 +508,41 @@ function placeTraits(place: Pick<PlaceWithRecentExperiences, 'experiences'>) {
return [...tags]; return [...tags];
} }
function rankPlacesByVoiceTags(
places: PlaceWithRecentExperiences[],
tags: string[],
userId: string,
) {
if (tags.length === 0) {
return [];
}
const requestedTags = new Set(tags);
return places
.map((place) => {
const tagsForPlace = placeTraits(place);
const matchedTags = tagsForPlace.filter((tag) => requestedTags.has(tag));
const favoriteBoost = place.favoriteUsers.some(
(favorite) => favorite.userId === userId,
)
? 0.25
: 0;
const ratingBoost = place.googleRating ? place.googleRating / 10 : 0;
return {
place,
matchedCount: matchedTags.length,
score: matchedTags.length * 10 + favoriteBoost + ratingBoost,
};
})
.filter((item) => item.matchedCount > 0)
.sort((left, right) => right.score - left.score)
.slice(0, 12)
.map((item) => item.place);
}
function jsonObject(value: unknown): Record<string, unknown> | null { function jsonObject(value: unknown): Record<string, unknown> | null {
if (!value || Array.isArray(value) || typeof value !== 'object') { if (!value || Array.isArray(value) || typeof value !== 'object') {
return null; return null;
} }
return value as Record<string, unknown>; return value as Record<string, unknown>;
} }
async function selectPlacesWithAi(input: {
query: string;
places: Array<Record<string, unknown>>;
}): Promise<AiSearchSelection> {
if (!config.openRouterApiKey) {
throw new Error('OPENROUTER_API_KEY is required for AI place search.');
}
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: {
authorization: `Bearer ${config.openRouterApiKey}`,
'content-type': 'application/json',
'http-referer': config.webAppUrl,
'x-title': 'MapFlow',
},
body: JSON.stringify({
model: config.openRouterModel,
provider: { require_parameters: true },
temperature: 0.1,
max_tokens: 1200,
response_format: {
type: 'json_schema',
json_schema: {
name: 'place_search',
strict: true,
schema: {
type: 'object',
additionalProperties: false,
required: ['placeIds', 'message'],
properties: {
placeIds: {
type: 'array',
maxItems: 12,
items: { type: 'string' },
},
message: { type: 'string' },
},
},
},
},
messages: [
{
role: 'system',
content:
'You rank known places for a map UI. Use only provided place ids. Return one valid JSON object. Do not invent places.',
},
{
role: 'user',
content: JSON.stringify({
task:
'Select places that best match the user search. Prefer favorites and open places only when relevant to the query.',
query: input.query,
places: input.places,
}),
},
],
}),
});
if (!response.ok) {
const errorBody = await response.text();
throw new Error(
`OpenRouter place search failed with ${response.status}: ${errorBody.slice(0, 500)}`,
);
}
const payload = (await response.json()) as AiSearchResponse;
const choice = payload.choices?.[0];
const content = choice?.message?.content?.trim();
if (!content) {
throw new Error(
`OpenRouter returned an empty place search for ${config.openRouterModel}; finish_reason=${choice?.finish_reason ?? 'missing'}.`,
);
}
const selection = JSON.parse(content) as AiSearchSelection;
if (!Array.isArray(selection.placeIds)) {
throw new Error('OpenRouter place search placeIds must be an array.');
}
if (typeof selection.message !== 'string') {
throw new Error('OpenRouter place search message must be a string.');
}
return selection;
}
+10 -10
View File
@@ -17,8 +17,8 @@ import {
listNearbyPlaces, listNearbyPlaces,
listPlaces, listPlaces,
listVoiceExperiences, listVoiceExperiences,
recommendPlacesByVoice,
removeFavoritePlace, removeFavoritePlace,
searchPlaces,
} from './places.js'; } from './places.js';
import { createVoiceExperience } from './voice-experiences.js'; import { createVoiceExperience } from './voice-experiences.js';
@@ -57,15 +57,6 @@ export const resolvers = {
const user = await requireTelegramUser(graphqlContext); const user = await requireTelegramUser(graphqlContext);
return listNearbyPlaces(args.input, user.id); return listNearbyPlaces(args.input, user.id);
}, },
searchPlaces: async (
_: unknown,
args: { input: Parameters<typeof searchPlaces>[0] },
context: unknown,
) => {
const graphqlContext = context as GraphqlContext;
const user = await requireTelegramUser(graphqlContext);
return searchPlaces(args.input, user.id);
},
voiceExperiences: async (_: unknown, __: unknown, context: unknown) => { voiceExperiences: async (_: unknown, __: unknown, context: unknown) => {
const graphqlContext = context as GraphqlContext; const graphqlContext = context as GraphqlContext;
await requireAdminTelegramUser(graphqlContext); await requireAdminTelegramUser(graphqlContext);
@@ -93,6 +84,15 @@ export const resolvers = {
const user = await requireTelegramUser(graphqlContext); const user = await requireTelegramUser(graphqlContext);
return createVoiceExperience(args.input, user.id); return createVoiceExperience(args.input, user.id);
}, },
recommendPlacesByVoice: async (
_: unknown,
args: { input: Parameters<typeof recommendPlacesByVoice>[0] },
context: unknown,
) => {
const graphqlContext = context as GraphqlContext;
const user = await requireTelegramUser(graphqlContext);
return recommendPlacesByVoice(args.input, user.id);
},
addFavoritePlace: async ( addFavoritePlace: async (
_: unknown, _: unknown,
args: { placeId: string }, args: { placeId: string },
+8 -5
View File
@@ -79,8 +79,9 @@ export const typeDefs = /* GraphQL */ `
radiusMeters: Int! radiusMeters: Int!
} }
input SearchPlacesInput { input VoicePlaceRecommendationsInput {
query: String! audioContentBase64: String!
audioMimeType: String!
} }
input AuthenticateTelegramInput { input AuthenticateTelegramInput {
@@ -112,8 +113,10 @@ export const typeDefs = /* GraphQL */ `
user: User! user: User!
} }
type SearchPlacesPayload { type VoicePlaceRecommendationsPayload {
message: String! transcript: String!
tags: [String!]!
summary: String!
places: [Place!]! places: [Place!]!
} }
@@ -123,7 +126,6 @@ export const typeDefs = /* GraphQL */ `
places: [Place!]! places: [Place!]!
favoritePlaces: [Place!]! favoritePlaces: [Place!]!
nearbyPlaces(input: NearbyPlacesInput!): [Place!]! nearbyPlaces(input: NearbyPlacesInput!): [Place!]!
searchPlaces(input: SearchPlacesInput!): SearchPlacesPayload!
voiceExperiences: [VoiceExperience!]! voiceExperiences: [VoiceExperience!]!
} }
@@ -133,6 +135,7 @@ export const typeDefs = /* GraphQL */ `
authenticateTelegram(input: AuthenticateTelegramInput!): AuthPayload! authenticateTelegram(input: AuthenticateTelegramInput!): AuthPayload!
authenticateTelegramLogin(input: AuthenticateTelegramLoginInput!): AuthPayload! authenticateTelegramLogin(input: AuthenticateTelegramLoginInput!): AuthPayload!
createVoiceExperience(input: CreateVoiceExperienceInput!): VoiceExperience! createVoiceExperience(input: CreateVoiceExperienceInput!): VoiceExperience!
recommendPlacesByVoice(input: VoicePlaceRecommendationsInput!): VoicePlaceRecommendationsPayload!
addFavoritePlace(placeId: ID!): Place! addFavoritePlace(placeId: ID!): Place!
removeFavoritePlace(placeId: ID!): Place! removeFavoritePlace(placeId: ID!): Place!
} }
+103
View File
@@ -0,0 +1,103 @@
export type OntologyLeaf = {
id: string;
label: string;
keywords: string[];
};
export type OntologyAxis = {
id: string;
label: string;
leaves: OntologyLeaf[];
};
export const placeOntology: OntologyAxis[] = [
{
id: 'energy',
label: 'энергия',
leaves: [
{
id: 'calm',
label: 'спокойное',
keywords: ['тихо', 'спокойно', 'медленно', 'мягко', 'без шума'],
},
{
id: 'dynamic',
label: 'живое',
keywords: ['движ', 'громко', 'оживленно', 'толпа', 'активно'],
},
],
},
{
id: 'privacy',
label: 'приватность',
leaves: [
{
id: 'intimate',
label: 'камерное',
keywords: ['уютно', 'камерно', 'маленькое', 'спрятаться', 'приватно'],
},
{
id: 'open',
label: 'открытое',
keywords: ['просторно', 'открыто', 'видно', 'публично', 'воздух'],
},
],
},
{
id: 'sociality',
label: 'социальность',
leaves: [
{
id: 'solo',
label: 'для себя',
keywords: ['одному', 'поработать', 'почитать', 'фокус', 'ноутбук'],
},
{
id: 'group',
label: 'для компании',
keywords: ['друзья', 'компания', 'свидание', 'поговорить', 'вечер'],
},
],
},
{
id: 'function',
label: 'сценарий',
leaves: [
{
id: 'reset',
label: 'выдохнуть',
keywords: ['отдохнуть', 'выдохнуть', 'перезагрузиться', 'паузу'],
},
{
id: 'impress',
label: 'впечатлить',
keywords: ['красиво', 'особенно', 'вау', 'впечатлить', 'необычно'],
},
{
id: 'transit',
label: 'транзитное',
keywords: ['быстро', 'рядом', 'по пути', 'забежать', 'перекусить'],
},
],
},
{
id: 'aesthetic',
label: 'образ',
leaves: [
{
id: 'clean',
label: 'чистое',
keywords: ['чисто', 'минимально', 'светло', 'просто', 'аккуратно'],
},
{
id: 'expressive',
label: 'выразительное',
keywords: ['дизайн', 'арт', 'фактура', 'ярко', 'атмосферно'],
},
],
},
];
export const placeOntologyTags = placeOntology.flatMap((axis) =>
axis.leaves.map((leaf) => `${axis.id}:${leaf.id}`),
);