Ворклог по задаче "Переосмыслить структуру сайта, доработать ее и пересобрать контент"

13 июл. 2026 г., 19:09:09

Еще информация от чатгпт

Да, с такой схемой техническая сторона не является ограничением. Более того, у тебя уже есть почти всё необходимое для туристического 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 и не обычный каталог. Это действительно база рассуждений.

Мой итог

Тебе не нужно проектировать новую туристическую базу поверх существующей.

Достаточно:

  1. Зафиксировать туристическую онтологию KBConcept.type.
  2. Создать словарь типов фактов и ролей.
  3. Добавить data в KBFact.
  4. Добавить position и data в KBFactParticipation.
  5. Ввести PostGIS для пространственных связей.
  6. Разделить:
    • тип объекта;
    • свойства опыта;
    • потребности;
    • ограничения;
    • комплексы;
    • кластеры;
    • маршруты.
  7. Использовать derived-факты для вычисляемых метрик вроде самодостаточности, маршрутной ёмкости и плотности пользы.

Самое важное: не пытаться заранее записать все парные связи между всеми местами. Хранить стоит фундаментальные признаки и значимые редакционные связки, а большая часть совместимости должна вычисляться из графа под конкретного пользователя и конкретный маршрут.