Worklog for task "Redesign the website structure, refine it, and rebuild the content"

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

More info from ChatGPT

Yes, with this schema, the technical side is not a limitation. Moreover, you already have almost everything needed for a tourism knowledge graph β€” it's just currently described in universal terms.

The main observation: you don't need to create separate tables for each tourism entity. Provinces, cities, places, activities, needs, scenarios, and complexes can all be KBConcept, and the meaning of the relationships between them can be KBFact with participants via KBFactParticipation. This is precisely what your n-ary model is particularly good for.

How I read your architecture

You already have five very strong layers.

1. Entities

KBConcept is a universal semantic node:

  • Vietnam;
  • Khanh Hoa province;
  • Nha Trang;
  • VinWonders;
  • waterfall;
  • diving;
  • family vacation;
  • rainy weather;
  • three-day trip;
  • specific itinerary.

At the same time, it has:

  • type;
  • hierarchy via parentId;
  • root via rootId;
  • coordinates;
  • arbitrary data in data;
  • multilingual names via KBLabel;
  • files and images;
  • its own URL via SiteRoute.

This means it is already a ready-made foundation for both a catalog and a graph simultaneously.

2. Relationships and Assertions

KBFact is not just a graph edge, but a full-fledged assertion:

  • relation type;
  • textual representation;
  • temporal validity;
  • source;
  • confidence;
  • verification status;
  • importance;
  • fact provenance.

And KBFactParticipation allows a single fact to link any number of entities with different roles.

For example, the assertion:

VinWonders is suitable for a family with children for a two-day stay in dry weather.

Can be expressed as a single fact:

KBFact.type = "visit_suitability"

Participants:

VinWonders       role=destination
Family with children role=audience
Two days         role=recommended_duration
Dry weather      role=preferred_condition

This is already much more expressive than a standard place_tags table.

3. Temporality and Uncertainty

For tourism, this is critical.

In your model, a fact can have:

  • validFrom;
  • validTo;
  • knownSince;
  • confidence;
  • status;
  • source.

This means you can properly store:

  • seasonal closures;
  • temporary repairs;
  • price changes;
  • new schedules;
  • road deterioration;
  • jellyfish season;
  • cable car closures;
  • festivals;
  • temporary swimming restrictions.

Moreover, new knowledge does not have to overwrite old knowledge. This perfectly matches real-world tourism information.

4. Conflicts

KBConflict and KBConstraint allow you not to pretend that the database always knows the truth.

For example:

  • the official website says admission is 500,000 VND;
  • a recent review mentions a price of 600,000;
  • an aggregator shows 550,000.

Instead of making a random choice, you can store all three facts and open a value_mismatch conflict.

For the "What to know now" button, this is especially valuable:

The official price is 500,000 β‚«, but two recent sources indicate 600,000 β‚«. We recommend checking before you go.

This is much more honest than a regular database.

5. Contextual Knowledge Spaces

KBKnowledgeSpace and KBFactProjection allow the same fact to be interpreted differently in various contexts.

For example:

  • VietnamGuru editorial base;
  • user's personal space;
  • temporary web search result;
  • data from a local expert;
  • user messages;
  • space of a specific itinerary.

A single fact can have different:

  • visibility;
  • trustworthiness;
  • importance.

This prevents mixing editorially verified data with user tips.


How to map tourism onto this model

I wouldn't create dozens of Prisma models like Province, City, Place, Activity, Need, Scenario.

Instead, use a restricted dictionary of KBConcept.type.

For example:

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

This doesn't necessarily have to be a Prisma enum right away. At an early stage, a string type is even more useful β€” it allows evolving the ontology without schema migrations.

However, the dictionary of types itself should be centralized in code.


Geographic Hierarchy

For the administrative structure, parentId is sufficient:

Vietnam
└── Khanh Hoa
    β”œβ”€β”€ Nha Trang
    └── Cam Ranh

But parentId should only be used for a true hierarchy.

You shouldn't use it to express:

  • a place is located nearby;
  • a place is part of an itinerary;
  • a restaurant is located inside a complex;
  • a city is a base for trips.

For these, you need facts.

For example:

Fact.type = "located_in"
place       role=subject
city        role=container

Why not limit it to parentId? Because an entity can simultaneously be located:

  • in a district;
  • in a city;
  • in a province;
  • in a tourist zone;
  • on the territory of a complex.

This is no longer a tree, but a graph.


How to express place types

Your current "place type" is a separate KBConcept:

Waterfall
Cave
Grotto
Pagoda
Casino
Restaurant
Diving center

Relationship:

Fact.type = "has_type"

place      role=subject
placeType  role=type

A single object can have multiple types.

For example, a recreation center:

Eco-park
Camping site
Natural complex
Swimming zone
Restaurant complex

This is better than a typeId field because composite objects almost never fit into a single category.


How to express semantic type affinity

Your example:

If a person doesn't want caves, they don't need grottos.

You need to link the types themselves.

Fact.type = "semantic_subtype"

Grotto    role=child
Cave      role=parent

You can build an ontology like this:

Underground natural features
β”œβ”€β”€ Cave
β”œβ”€β”€ Grotto
β”œβ”€β”€ Underground river
β”œβ”€β”€ Karst tunnel
└── Speleological route

But there is an important distinction here.

parentId can be used for strict classification:

a grotto is a variety of an underground feature.

And KBFact for soft relationships:

similar_to
commonly_combined_with
may_trigger_same_avoidance
alternative_to

For example:

Cu Chi Tunnels are not a cave, but may be undesirable for someone with claustrophobia.

Taxonomy won't help here. You need a link to the concept:

Enclosed spaces

Fact:

Fact.type = "has_experience_attribute"

Cu Chi Tunnels          role=subject
Enclosed spaces         role=attribute

Then the user exclusion applies based on the experience attribute, not just the category.


Object type and experience character must be separated

This is a key point.

For example, "cave" describes a physical object. But to the user, what matters more is:

  • dark;
  • tight;
  • humid;
  • requires physical exertion;
  • adventurous nature;
  • long walk;
  • risk of getting dirty;
  • not suitable for an evening dress.

Therefore, you need concepts like experience_attribute:

indoor
outdoor
underground
water_based
physically_demanding
formal_friendly
muddy
crowded
quiet
romantic
family_friendly
weather_sensitive

Relationships:

Fact.type = "has_experience_attribute"
Fact.type = "requires_condition"
Fact.type = "conflicts_with_condition"

Example with an evening dress:

User context:
formal_clothing = true

Object:

Waterfall
has_experience_attribute = wet
has_experience_attribute = uneven_terrain
has_experience_attribute = outdoor

The system infers this not because "evening dress is incompatible with a waterfall" is hardcoded, but through attributes.

Meanwhile, a restaurant, casino, or theater has:

formal_friendly
indoor
evening_suitable


User needs

I would model needs as concepts:

Eat
Sleep
Swim
Buy clothes
Entertain children
Spend the evening
View nature
Avoid long travels
Spend a rainy day
Get an active experience
Relax

Relationship:

Fact.type = "satisfies_need"

place  role=provider
need   role=need

For participation, you can use value for strength:

value = "0.9"

Or localImportance, although semantically it's better not to mix fact importance and need satisfaction strength. For computed values, I would add structured data to the fact or a separate numeric field.

For example:

{
  "strength": 0.9,
  "capacity": "full",
  "notes": "You can spend a full day here"
}

Currently, KBFact doesn't have a general data field, and this is one of the few extensions I would actually consider.


Complexes and composite places

You definitely need composite object semantics, but not necessarily a separate Prisma model.

Concept:

KBConcept.type = "place_complex"

For example:

VinWonders Nha Trang
Recreation base X
Phong Nha National Park
Resort cluster

Components are regular places or services.

Relationship:

Fact.type = "part_of_complex"

waterpark    role=component
VinWonders   role=complex

Or you can create a richer fact:

Fact.type = "complex_composition"

Participants:

VinWonders       role=complex
Waterpark        role=component
Beach            role=component
Hotel            role=component
Restaurant       role=component
Show             role=component

I would still prefer a separate fact per component because:

  • components have different hours;
  • different tickets;
  • different validity periods;
  • different sources;
  • different confidence levels.

For example:

Fact.type = "complex_component"
subject = Waterpark
container = VinWonders
access_mode = included_ticket

Here statement can be human-readable, while the details reside in structured data.


What "can hang out for three days" means

This is not a single duration field.

You need to distinguish between:

  • minimum time for an introduction;
  • typical duration;
  • maximum useful duration;
  • availability of overnight stay;
  • sufficiency of internal activities;
  • necessity of going outside the object.

For example, for VinWonders:

minimum_meaningful_duration = 1 day
recommended_duration = 2 days
maximum_stay_without_repetition = 3 days
overnight_available = true
self_contained = true

This can be represented by multiple facts:

recommended_duration
supports_overnight_stay
self_sufficiency
activity_capacity

self_sufficiency is a particularly important metric.

For example:

0.1 β€” a standalone viewpoint
0.4 β€” an attraction with a cafe and parking
0.7 β€” a full-day recreation center
0.95 β€” a multi-day resort complex

It can be computed from:

  • food;
  • accommodation;
  • number of activities;
  • variety of activities;
  • evening program;
  • infrastructure;
  • internal transport;
  • weather protection.

I wouldn't store it solely manually. It's better to store the source facts and create the final score as a KBFactType.derived with derivedFrom.

You already have factType and derivedFrom for this.


Place pairings

Your model allows making them much richer than a simple placeAId/placeBId.

Simple pairwise link

Fact.type = "works_well_together"

Place A  role=place
Place B  role=place

But it's better to add context right away:

Place A           role=place
Place B           role=place
One day           role=duration
Family with kids  role=audience
Dry weather       role=condition

Then the fact means:

These places combine well into a single day for a family with kids in dry weather.

Multi-place set

The n-ary model is especially useful here:

Fact.type = "recommended_visit_cluster"

Mountain      role=anchor
Stream        role=optional_stop
Cave          role=optional_stop
Restaurant    role=meal_stop
Resort base   role=overnight_base
One and a half days role=recommended_duration

This is already a full itinerary fragment, though not yet a user itinerary.


I would separate cluster and itinerary

These are two different entities.

Cluster

An objective or editorial bundle:

These objects geographically and scenario-wise form a single visitation complex.

Type:

KBConcept.type = "visit_cluster"

Examples:

  • mountain + stream + cave + restaurant;
  • old town + market + waterfront;
  • island + beach + amusement park + hotel.

Itinerary

A specific sequence:

First the mountain, then the stream, then the restaurant, overnight at the base.

Type:

KBConcept.type = "route"

An itinerary needs ordered segments.

Your current KBFactParticipation does not store order by itself. value could be used as 1, 2, 3, but that's not very clean.

I would add to KBFactParticipation:

position Int?
data     Json?

Then a single route_composition fact can contain:

Mountain    role=stop position=1
Stream      role=stop position=2
Restaurant  role=stop position=3
Base        role=stop position=4

And in each participation's data:

{
  "arrivalTime": "09:00",
  "durationMinutes": 120,
  "optional": false
}

This is one of the most practically useful extensions to your schema.


User preferences

They can be stored in two ways.

As user facts

A user is also a concept or linked to a profile concept.

For example:

Fact.type = "user_preference"

UserConcept       role=subject
Caves             role=target
Dislikes          role=preference

But it's better to formalize:

likes
dislikes
avoids
requires
prefers
neutral_to

Example:

Fact.type = "avoids"

User                  role=subject
Enclosed spaces       role=target

Then the following are excluded:

  • caves;
  • grottos;
  • tunnels;
  • underground temples;

if they are linked to this experience attribute.

As an itinerary knowledge space

For a specific trip, you can create a KBKnowledgeSpace:

Trip to Vietnam, August 2026

Projected into it:

  • preferences;
  • constraints;
  • participants;
  • dates;
  • budget;
  • selected places;
  • current facts;
  • agent results.

This maps very naturally onto your model.


Not all preferences are global

For example:

  • the user generally likes waterfalls;
  • but today they are wearing evening clothes;
  • on this trip they have a toddler;
  • tomorrow they only have three hours;
  • it is raining right now.

Therefore, different contexts are needed:

Global user profile
Specific trip
Specific day
Current session

It is KnowledgeSpace that prevents turning the temporary condition "today I'm wearing an evening dress" into a permanent user property.


How to calculate place compatibility with a query

You can break down the final score into components:

intent_match
need_coverage
avoidance_conflict
context_fit
time_fit
geographic_fit
route_synergy
weather_fit
novelty
quality
confidence

Roughly:

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

But I wouldn't fix a single formula forever. The weights depend on the scenario.

For a "find a restaurant nearby" query:

  • geography β€” high;
  • open now β€” high;
  • gastronomic match β€” high;
  • uniqueness β€” secondary.

For a two-week itinerary:

  • variety;
  • logistics;
  • time capacity;
  • seasonality;
  • itinerary balance.

It is better to represent the scoring profile itself as a concept:

restaurant_now_profile
family_day_trip_profile
multi_day_route_profile
formal_evening_profile

And the weights as facts or data.


Hard and soft constraints

These must definitely be separated.

Hard constraints

The object must not appear in the results:

  • user excluded underground places;
  • object is closed;
  • does not fit time-wise;
  • unavailable for children of a certain age;
  • impossible to reach;
  • exceeds budget;
  • incompatible with physical limitations.

Soft constraints

The object can appear lower down:

  • further than desired;
  • slightly more expensive;
  • less relevant;
  • similar to an already selected place;
  • requires special clothing;
  • weather-dependent.

Your KBConstraint is suitable for declarative rules, and KBConflict for recording violations.

For example:

constraint:
"An itinerary for a user avoiding underground experiences
must not contain places with the underground attribute."

However, I wouldn't create a KBConflict for every candidate rejected by the recommendation system. Conflicts are needed for significant knowledge contradictions or an already formed itinerary, not for routine filtering.


Minimal schema additions

You already have the foundation. I wouldn't do a large refactoring.

1. Structured fact data

model KBFact {
  ...
  data Json?
}

Needed for:

  • numeric values;
  • units of measurement;
  • ranges;
  • relation parameters;
  • algorithmic features.

For example:

{
  "minMinutes": 180,
  "recommendedMinutes": 360,
  "maxMinutes": 720
}

2. Participation data and order

model KBFactParticipation {
  ...
  position Int?
  data     Json?
}

Needed for:

  • itineraries;
  • clusters;
  • sequences;
  • different parameters of participants in the same fact.

3. Index on status and time

For frequent real-time queries:

@@index([status])
@@index([validFrom, validTo])
@@index([type, status])

Currently, in the provided schema, only type is indexed on KBFact. For the "What to know now" button, queries by validity and status will be very frequent.

4. Spatial index

Currently, lat/lng are indexed with a standard composite B-tree index. For true "what's nearby", PostGIS is better:

geography(Point, 4326)
GIST index

You can keep lat/lng for Prisma convenience while maintaining the geographic point additionally or switching to a raw SQL extension.

With your experience, this is probably an obvious step: for radius and itinerary queries, a standard coordinate index will quickly become a bottleneck.

5. Stable type registry

The fields KBConcept.type, KBFact.type, KBFactParticipation.role are strings. This provides flexibility, but it's easy to end up with:

located_in
located-at
location
belongs_to_city

Therefore, a code-level registry is needed:

export const ConceptType = {
  Place: 'place',
  PlaceComplex: 'place_complex',
  City: 'geo_city',
  Need: 'need',
  Scenario: 'scenario',
} as const;

The same goes for fact types and roles.


What not to put in KBConcept.data

Technically, you can put everything there:

{
  "recommendedDuration": 180,
  "familyFriendly": true,
  "hasRestaurant": true
}

But then the graph stops properly explaining the origin of the data.

I would use concept data only for:

  • technical metadata;
  • display parameters;
  • rarely used structures;
  • cache;
  • denormalized results.

And assertions like:

  • suitable for kids;
  • has a restaurant;
  • recommended two days;
  • closed until September;

should be kept as facts.

Because only a fact has:

  • a source;
  • confidence;
  • time;
  • verification status;
  • conflicts;
  • provenance.

How an agent will assemble a place page

For a single card, the query can proceed in layers.

Base layer

Concept:

  • name;
  • description;
  • image;
  • coordinates;
  • URL.

Taxonomy

Facts:

  • place types;
  • region;
  • city;
  • complex;
  • activities.

User utility

  • satisfied needs;
  • audiences;
  • constraints;
  • recommended duration;
  • best time.

Related objects

  • complex parts;
  • nearby places;
  • compatible places;
  • alternatives;
  • itineraries;
  • clusters.

Relevance

  • active temporary facts;
  • fresh news;
  • weather;
  • events;
  • data conflicts.

Personalization

Via the current trip space:

  • what fits;
  • what to exclude;
  • where to place it in the itinerary;
  • how much time to allocate;
  • what places to add together.

And all of this can be shown on a single page without sending the user to browse through a catalog.


The strongest side of your schema

A regular tourism database says:

This is a cave in such-and-such province.

Your model can potentially say:

This is a natural complex near Da Nang, including a cave, a stream, a restaurant, and a camping site. For an active couple, it is suitable for a day and a half, but not recommended for someone avoiding enclosed spaces. In August, visiting the stream depends on precipitation. It makes logical sense to combine the complex with mountain X, but not with object Y on the same day.

Moreover, every assertion will have:

  • a source;
  • validity time;
  • confidence;
  • status;
  • participants;
  • context.

This is no longer a CMS or a regular catalog. It is truly a reasoning database.

My conclusion

You don't need to design a new tourism database on top of the existing one.

It is enough to:

  1. Fix the tourism ontology for KBConcept.type.
  2. Create a dictionary of fact types and roles.
  3. Add data to KBFact.
  4. Add position and data to KBFactParticipation.
  5. Introduce PostGIS for spatial relationships.
  6. Separate:
    • object type;
    • experience attributes;
    • needs;
    • constraints;
    • complexes;
    • clusters;
    • itineraries.
  7. Use derived facts for computed metrics like self-sufficiency, itinerary capacity, and utility density.

Most importantly: do not try to pre-record all pairwise connections between all places. You should store fundamental attributes and significant editorial links, while most compatibility should be computed from the graph for a specific user and a specific itinerary.

13.07.2026