Ворклог по задаче "Переосмыслить структуру сайта, доработать ее и пересобрать контент"
Еще информация от чатгпт
Да, с такой схемой техническая сторона не является ограничением. Более того, у тебя уже есть почти всё необходимое для туристического knowledge graph — просто сейчас он описан в универсальных терминах.
Главное наблюдение: не нужно создавать отдельные таблицы под каждую туристическую сущность. Провинции, города, места, активности, потребности, сценарии и комплексы могут быть KBConcept, а смысл отношений между ними — KBFact с участниками через KBFactParticipation. Именно для этого твоя n-арная модель подходит особенно хорошо.
Как я читаю твою архитектуру
У тебя уже есть пять очень сильных уровней.
1. Сущности
KBConcept — универсальный семантический узел:
- Вьетнам;
- провинция Кханьхоа;
- Нячанг;
- VinWonders;
- водопад;
- дайвинг;
- семейный отдых;
- дождливая погода;
- поездка на три дня;
- конкретный маршрут.
При этом есть:
type;- иерархия через
parentId; - корень через
rootId; - координаты;
- произвольные данные в
data; - мультиязычные названия через
KBLabel; - файлы и изображения;
- собственный URL через
SiteRoute.
То есть это уже готовая основа для каталога и графа одновременно.
2. Отношения и утверждения
KBFact — это не просто ребро графа, а полноценное утверждение:
- тип отношения;
- текстовое представление;
- временная валидность;
- источник;
- уверенность;
- статус проверки;
- важность;
- происхождение факта.
А KBFactParticipation позволяет одному факту связать любое число объектов с разными ролями.
Например, утверждение:
VinWonders подходит семье с детьми для двухдневного отдыха в сухую погоду.
Можно выразить одним фактом:
KBFact.type = "visit_suitability"
Участники:
VinWonders role=destination
Семья с детьми role=audience
Два дня role=recommended_duration
Сухая погода role=preferred_condition
Уже это намного выразительнее обычной таблицы place_tags.
3. Временность и неопределённость
Для туризма это критично.
У тебя факт может иметь:
validFrom;validTo;knownSince;confidence;status;source.
Значит, можно нормально хранить:
- сезонное закрытие;
- временный ремонт;
- изменение цены;
- новое расписание;
- ухудшение дороги;
- сезон медуз;
- закрытие канатной дороги;
- фестиваль;
- временное ограничение купания.
Причём новое знание не обязано перезаписывать старое. Это идеально соответствует реальной туристической информации.
4. Противоречия
KBConflict и KBConstraint позволяют не притворяться, что база всегда знает истину.
Например:
- официальный сайт говорит, что вход стоит 500 000 донгов;
- свежий отзыв говорит о цене 600 000;
- агрегатор показывает 550 000.
Вместо случайного выбора можно сохранить три факта и открыть конфликт value_mismatch.
Для кнопки «Что важно знать сейчас» это особенно ценно:
Официальная цена — 500 000 ₫, но в двух свежих источниках указана цена 600 000 ₫. Рекомендуем проверить перед поездкой.
Это гораздо честнее обычной базы данных.
5. Контекстные пространства знаний
KBKnowledgeSpace и KBFactProjection позволяют один и тот же факт по-разному интерпретировать в разных контекстах.
Например:
- редакционная база VietnamGuru;
- личное пространство пользователя;
- временный результат веб-поиска;
- данные от местного эксперта;
- пользовательские сообщения;
- пространство конкретного маршрута.
Один факт может иметь разную:
- видимость;
- доверенность;
- важность.
Это позволяет не смешивать редакционно подтверждённые данные с пользовательскими советами.
Как переложить туризм на эту модель
Я бы не создавал десятки Prisma-моделей вроде Province, City, Place, Activity, Need, Scenario.
Вместо этого — ограниченный словарь типов KBConcept.type.
Например:
geo_country
geo_province
geo_city
geo_district
place
place_complex
place_component
transport_hub
place_type
activity
service
need
interest
avoidance
audience
condition
season
time_window
duration
scenario
route
route_segment
route_day
trip
Это не обязательно сразу делать enum в Prisma. На раннем этапе строковый type даже полезнее — он позволяет развивать онтологию без миграции схемы.
Но сам словарь типов стоит централизовать в коде.
Географическая иерархия
Для административной структуры достаточно parentId:
Вьетнам
└── Кханьхоа
├── Нячанг
└── Камрань
Но parentId следует использовать только для настоящей иерархии.
Не стоит через него выражать:
- место находится рядом;
- место входит в маршрут;
- ресторан расположен в комплексе;
- город является базой для поездок.
Для этого нужны факты.
Например:
Fact.type = "located_in"
place role=subject
city role=container
Почему не ограничиться parentId? Потому что объект может одновременно находиться:
- в районе;
- в городе;
- в провинции;
- в туристической зоне;
- на территории комплекса.
Это уже не дерево, а граф.
Как выразить типы мест
Твой текущий «тип места» — это отдельный KBConcept:
Водопад
Пещера
Грот
Пагода
Казино
Ресторан
Дайвинг-центр
Связь:
Fact.type = "has_type"
place role=subject
placeType role=type
Один объект может иметь несколько типов.
Например, база отдыха:
Экопарк
Кемпинг
Природный комплекс
Зона купания
Ресторанный комплекс
Это лучше, чем поле typeId, потому что составные объекты почти никогда не укладываются в одну категорию.
Как выразить семантическое родство типов
Твой пример:
Если человек не хочет пещеры, ему не нужны гроты.
Нужно связать сами типы.
Fact.type = "semantic_subtype"
Грот role=child
Пещера role=parent
Можно построить такую онтологию:
Подземные природные объекты
├── Пещера
├── Грот
├── Подземная река
├── Карстовый тоннель
└── Спелеологический маршрут
Но здесь есть важное разделение.
parentId можно использовать для строгой классификации:
грот является разновидностью подземного объекта.
А KBFact — для мягких отношений:
similar_to
commonly_combined_with
may_trigger_same_avoidance
alternative_to
Например:
Тоннели Кути — не пещера, но могут быть нежелательны человеку с боязнью замкнутых пространств.
Здесь таксономия не поможет. Нужна связь с концептом:
Замкнутые пространства
Факт:
Fact.type = "has_experience_attribute"
Тоннели Кути role=subject
Замкнутые пространства role=attribute
Тогда пользовательское исключение применяется по атрибуту опыта, а не только по категории.
Тип объекта и характер опыта нужно разделить
Это ключевой момент.
Например, «пещера» описывает физический объект. Но пользователю важнее:
- темно;
- тесно;
- влажно;
- требуется физическая нагрузка;
- приключенческий характер;
- длительная прогулка;
- риск испачкаться;
- не подходит в вечернем платье.
Поэтому нужны концепты типа experience_attribute:
indoor
outdoor
underground
water_based
physically_demanding
formal_friendly
muddy
crowded
quiet
romantic
family_friendly
weather_sensitive
Связи:
Fact.type = "has_experience_attribute"
Fact.type = "requires_condition"
Fact.type = "conflicts_with_condition"
Пример с вечерним платьем:
Пользовательский контекст:
formal_clothing = true
Объект:
Водопад
has_experience_attribute = wet
has_experience_attribute = uneven_terrain
has_experience_attribute = outdoor
Система делает вывод не потому, что «вечернее платье несовместимо с водопадом» записано вручную, а через признаки.
А вот ресторан, казино или театр имеют:
formal_friendly
indoor
evening_suitable
Потребности пользователя
Я бы оформил потребности как концепты:
Поесть
Переночевать
Искупаться
Купить одежду
Развлечь детей
Провести вечер
Посмотреть природу
Избежать переездов
Провести день в дождь
Получить активный опыт
Отдохнуть
Связь:
Fact.type = "satisfies_need"
place role=provider
need role=need
У участия можно использовать value для силы:
value = "0.9"
Или localImportance, хотя семантически лучше не смешивать важность факта и силу удовлетворения потребности. Для вычисляемых значений я бы добавил структурированный data в факт либо отдельное числовое поле.
Например:
{
"strength": 0.9,
"capacity": "full",
"notes": "Можно провести полный день"
}
Сейчас у KBFact нет общего data, и это одно из немногих расширений, которое я бы действительно рассмотрел.
Комплексы и составные места
Тебе точно нужна семантика составного объекта, но необязательно отдельная Prisma-модель.
Концепт:
KBConcept.type = "place_complex"
Например:
VinWonders Nha Trang
База отдыха X
Национальный парк Phong Nha
Курортный кластер
Компоненты — обычные места или сервисы.
Связь:
Fact.type = "part_of_complex"
аквапарк role=component
VinWonders role=complex
Но можно сделать более богатый факт:
Fact.type = "complex_composition"
Участники:
VinWonders role=complex
Аквапарк role=component
Пляж role=component
Отель role=component
Ресторан role=component
Шоу role=component
Я всё же предпочёл бы отдельный факт на компонент, потому что:
- у компонентов разные часы;
- разные билеты;
- разные сроки действия;
- разные источники;
- разные уровни уверенности.
Например:
Fact.type = "complex_component"
subject = Аквапарк
container = VinWonders
access_mode = included_ticket
При этом statement может быть человекочитаемым, а детали — в структурированных данных.
Что означает «можно зависнуть на три дня»
Это не одно поле duration.
Нужно различать:
- минимальное время знакомства;
- типичную длительность;
- максимальную полезную длительность;
- наличие проживания;
- достаточность внутренних активностей;
- необходимость выхода за пределы объекта.
Например, для VinWonders:
minimum_meaningful_duration = 1 day
recommended_duration = 2 days
maximum_stay_without_repetition = 3 days
overnight_available = true
self_contained = true
Это можно представить несколькими фактами:
recommended_duration
supports_overnight_stay
self_sufficiency
activity_capacity
self_sufficiency — особенно важная метрика.
Например:
0.1 — отдельная смотровая площадка
0.4 — достопримечательность с кафе и парковкой
0.7 — база отдыха на полный день
0.95 — курортный комплекс на несколько дней
Её можно вычислять из:
- еды;
- проживания;
- числа активностей;
- разнообразия активностей;
- вечерней программы;
- бытовой инфраструктуры;
- внутреннего транспорта;
- защищённости от погоды.
Я бы не хранил её только вручную. Лучше хранить исходные факты, а итоговую оценку создавать как KBFactType.derived с derivedFrom.
У тебя для этого уже есть factType и derivedFrom.
Связки мест
Твоя модель позволяет сделать их намного богаче, чем простое placeAId/placeBId.
Простая парная связка
Fact.type = "works_well_together"
Место A role=place
Место B role=place
Но лучше сразу добавить контекст:
Место A role=place
Место B role=place
Один день role=duration
Семья с детьми role=audience
Сухая погода role=condition
Тогда факт означает:
Эти места хорошо объединяются в один день для семьи с детьми в сухую погоду.
Набор из нескольких мест
N-арная модель особенно полезна:
Fact.type = "recommended_visit_cluster"
Гора role=anchor
Ручей role=optional_stop
Пещера role=optional_stop
Ресторан role=meal_stop
База отдыха role=overnight_base
Полтора дня role=recommended_duration
Это уже полноценный фрагмент маршрута, но ещё не пользовательский маршрут.
Я бы разделил кластер и маршрут
Это две разные сущности.
Кластер
Объективная или редакционная связка:
Эти объекты географически и сценарно образуют единый комплекс посещения.
Тип:
KBConcept.type = "visit_cluster"
Примеры:
- гора + ручей + пещера + ресторан;
- старый город + рынок + набережная;
- остров + пляж + парк развлечений + отель.
Маршрут
Конкретная последовательность:
Сначала гора, затем ручей, потом ресторан, ночёвка на базе.
Тип:
KBConcept.type = "route"
Маршруту нужны упорядоченные сегменты.
Текущая KBFactParticipation сама по себе не хранит порядок. value можно использовать как 1, 2, 3, но это будет не очень чисто.
Я бы добавил в KBFactParticipation:
position Int?
data Json?
Тогда один факт типа route_composition может содержать:
Гора role=stop position=1
Ручей role=stop position=2
Ресторан role=stop position=3
База role=stop position=4
И у каждого участия в data:
{
"arrivalTime": "09:00",
"durationMinutes": 120,
"optional": false
}
Это одно из наиболее практически полезных расширений твоей схемы.
Предпочтения пользователя
Их можно хранить двумя способами.
Как факты пользователя
Пользователь — тоже концепт либо связан с концептом профиля.
Например:
Fact.type = "user_preference"
UserConcept role=subject
Пещеры role=target
Не нравится role=preference
Но лучше формализовать:
likes
dislikes
avoids
requires
prefers
neutral_to
Пример:
Fact.type = "avoids"
Пользователь role=subject
Замкнутые пространства role=target
Тогда исключаются:
- пещеры;
- гроты;
- тоннели;
- подземные храмы;
если они связаны с этим experience attribute.
Как пространство знаний маршрута
Для конкретной поездки можно создать KBKnowledgeSpace:
Поездка во Вьетнам, август 2026
Туда проектируются:
- предпочтения;
- ограничения;
- участники;
- даты;
- бюджет;
- выбранные места;
- текущие факты;
- результаты агента.
Это очень естественно ложится на твою модель.
Не все предпочтения глобальны
Например:
- пользователь вообще любит водопады;
- но сегодня он в вечерней одежде;
- в этой поездке он с маленьким ребёнком;
- завтра у него всего три часа;
- сейчас идёт дождь.
Поэтому нужны разные контексты:
Глобальный профиль пользователя
Конкретная поездка
Конкретный день
Текущая сессия
Именно KnowledgeSpace позволяет не превращать временное условие «сегодня я в вечернем платье» в постоянное свойство пользователя.
Как считать совместимость места с запросом
Можно разложить итоговый score на компоненты:
intent_match
need_coverage
avoidance_conflict
context_fit
time_fit
geographic_fit
route_synergy
weather_fit
novelty
quality
confidence
Примерно:
score =
0.22 * intent_match +
0.18 * need_coverage +
0.15 * geographic_fit +
0.12 * time_fit +
0.12 * route_synergy +
0.08 * weather_fit +
0.08 * quality +
0.05 * novelty
- hard_conflicts
Но я бы не фиксировал единую формулу навсегда. Вес зависит от сценария.
Для запроса «найти ресторан рядом»:
- география — высокая;
- открыто сейчас — высокая;
- гастрономическое соответствие — высокая;
- уникальность — вторична.
Для двухнедельного маршрута:
- разнообразие;
- логистика;
- временная ёмкость;
- сезонность;
- баланс маршрута.
Лучше представить сам профиль оценки как концепт:
restaurant_now_profile
family_day_trip_profile
multi_day_route_profile
formal_evening_profile
А веса — как факты или data.
Жёсткие и мягкие ограничения
Это стоит разделять обязательно.
Жёсткие
Объект не должен попасть в результат:
- пользователь исключил подземные места;
- объект закрыт;
- не помещается по времени;
- недоступен детям нужного возраста;
- невозможно добраться;
- превышает бюджет;
- несовместим с физическими ограничениями.
Мягкие
Объект может попасть ниже:
- дальше желаемого;
- немного дороже;
- менее релевантен;
- похож на уже выбранное место;
- требует дополнительной одежды;
- зависит от погоды.
Твоя KBConstraint подходит для декларативных правил, а KBConflict — для фиксации нарушений.
Например:
constraint:
"Маршрут пользователя, избегающего underground experience,
не должен содержать места с атрибутом underground."
Но я бы не создавал KBConflict для каждого кандидата, отклонённого рекомендательной системой. Конфликт нужен для значимых противоречий знаний или уже сформированного маршрута, а не для обычной фильтрации.
Что добавить в схему минимально
У тебя фундамент уже есть. Я бы не делал большой рефакторинг.
1. Структурированные данные факта
model KBFact {
...
data Json?
}
Нужно для:
- численных значений;
- единиц измерения;
- диапазонов;
- параметров связи;
- алгоритмических признаков.
Например:
{
"minMinutes": 180,
"recommendedMinutes": 360,
"maxMinutes": 720
}
2. Данные и порядок участия
model KBFactParticipation {
...
position Int?
data Json?
}
Нужно для:
- маршрутов;
- кластеров;
- последовательностей;
- разных параметров участников одного факта.
3. Индекс по статусу и времени
Для частых актуальных запросов:
@@index([status])
@@index([validFrom, validTo])
@@index([type, status])
Сейчас у KBFact в приведённой схеме индексируется только type. Для кнопки «Что важно знать сейчас» запросы по валидности и статусу будут очень частыми.
4. Пространственный индекс
Сейчас lat/lng индексируются обычным составным B-tree индексом. Для реального «что рядом» лучше PostGIS:
geography(Point, 4326)
GIST index
Можно сохранить lat/lng для Prisma-удобства, а географическую точку держать дополнительно либо перейти на raw SQL extension.
С твоим опытом это, вероятно, очевидный шаг: для радиусных и маршрутных запросов обычный индекс координат быстро станет ограничением.
5. Стабильный словарь типов
Поля KBConcept.type, KBFact.type, KBFactParticipation.role строковые. Это даёт гибкость, но легко получить:
located_in
located-at
location
belongs_to_city
Поэтому нужен кодовый registry:
export const ConceptType = {
Place: 'place',
PlaceComplex: 'place_complex',
City: 'geo_city',
Need: 'need',
Scenario: 'scenario',
} as const;
То же самое для fact types и roles.
Что не стоит класть в KBConcept.data
Технически можно положить туда всё:
{
"recommendedDuration": 180,
"familyFriendly": true,
"hasRestaurant": true
}
Но тогда граф перестанет нормально объяснять происхождение данных.
Я бы использовал data концепта только для:
- технических метаданных;
- параметров отображения;
- редко используемых структур;
- кэша;
- денормализованных результатов.
А утверждения вроде:
- подходит детям;
- есть ресторан;
- рекомендуется два дня;
- закрыто до сентября;
держал бы как факты.
Потому что только факт имеет:
- источник;
- уверенность;
- время;
- статус проверки;
- конфликты;
- provenance.
Как агент будет собирать страницу места
Для одной карточки запрос может идти слоями.
Базовый слой
Концепт:
- название;
- описание;
- изображение;
- координаты;
- URL.
Таксономия
Факты:
- типы места;
- регион;
- город;
- комплекс;
- активности.
Пользовательская полезность
- закрываемые потребности;
- аудитории;
- ограничения;
- рекомендуемая длительность;
- лучшее время.
Связанные объекты
- части комплекса;
- места рядом;
- совместимые места;
- альтернативы;
- маршруты;
- кластеры.
Актуальность
- действующие временные факты;
- свежие новости;
- погода;
- события;
- конфликты данных.
Персонализация
Через пространство текущей поездки:
- что подходит;
- что исключить;
- куда поставить в маршрут;
- сколько времени выделить;
- какие места добавить вместе.
И всё это можно показать на одной странице, не отправляя пользователя ходить по каталогу.
Сильнейшая сторона твоей схемы
Обычная туристическая база говорит:
Это пещера в такой-то провинции.
Твоя модель потенциально может сказать:
Это природный комплекс возле Дананга, включающий пещеру, ручей, ресторан и кемпинг. Для активной пары он подходит на полтора дня, но не рекомендуется человеку, избегающему замкнутых пространств. В августе посещение ручья зависит от осадков. Комплекс логично объединить с горой X, но не с объектом Y в тот же день.
Причём каждое утверждение будет иметь:
- источник;
- время актуальности;
- уверенность;
- статус;
- участников;
- контекст.
Это уже не CMS и не обычный каталог. Это действительно база рассуждений.
Мой итог
Тебе не нужно проектировать новую туристическую базу поверх существующей.
Достаточно:
- Зафиксировать туристическую онтологию
KBConcept.type. - Создать словарь типов фактов и ролей.
- Добавить
dataвKBFact. - Добавить
positionиdataвKBFactParticipation. - Ввести PostGIS для пространственных связей.
- Разделить:
- тип объекта;
- свойства опыта;
- потребности;
- ограничения;
- комплексы;
- кластеры;
- маршруты.
- Использовать
derived-факты для вычисляемых метрик вроде самодостаточности, маршрутной ёмкости и плотности пользы.
Самое важное: не пытаться заранее записать все парные связи между всеми местами. Хранить стоит фундаментальные признаки и значимые редакционные связки, а большая часть совместимости должна вычисляться из графа под конкретного пользователя и конкретный маршрут.