mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
425 lines
12 KiB
JavaScript
425 lines
12 KiB
JavaScript
// js/feedEngine/kidsScoring.js
|
|
|
|
import { normalizeHashtag } from "../utils/hashtagNormalization.js";
|
|
import { isPlainObject, toSet } from "./utils.js";
|
|
|
|
const DEFAULT_WEIGHTS = Object.freeze({
|
|
w_age: 0.35,
|
|
w_edu: 0.25,
|
|
w_author: 0.15,
|
|
w_pop: 0.1,
|
|
w_fresh: 0.1,
|
|
w_risk: 0.6,
|
|
});
|
|
|
|
const DEFAULT_FRESHNESS_HALF_LIFE_DAYS = 14;
|
|
|
|
const AGE_GROUP_DEFAULTS = Object.freeze({
|
|
toddler: {
|
|
maxDurationSeconds: 5 * 60,
|
|
preferredTags: ["toddler", "baby", "nursery", "colors", "shapes", "lullaby"],
|
|
educationalTags: ["abc", "numbers", "counting", "learning", "alphabet"],
|
|
},
|
|
preschool: {
|
|
maxDurationSeconds: 10 * 60,
|
|
preferredTags: ["preschool", "kindergarten", "storytime", "letters", "phonics"],
|
|
educationalTags: ["counting", "alphabet", "reading", "learning", "math"],
|
|
},
|
|
early: {
|
|
maxDurationSeconds: 15 * 60,
|
|
preferredTags: ["early", "kids", "reading", "science", "animals", "art"],
|
|
educationalTags: ["science", "math", "reading", "history", "geography"],
|
|
},
|
|
older: {
|
|
maxDurationSeconds: 20 * 60,
|
|
preferredTags: ["tween", "teens", "tutorial", "stem", "coding", "music"],
|
|
educationalTags: ["stem", "coding", "history", "geography", "tutorial"],
|
|
},
|
|
});
|
|
|
|
function clamp01(value) {
|
|
if (!Number.isFinite(value)) {
|
|
return 0;
|
|
}
|
|
if (value <= 0) {
|
|
return 0;
|
|
}
|
|
if (value >= 1) {
|
|
return 1;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function normalizeTagSetFromVideo(video) {
|
|
const normalized = new Set();
|
|
if (!video || typeof video !== "object") {
|
|
return normalized;
|
|
}
|
|
|
|
const addTag = (rawTag) => {
|
|
if (typeof rawTag !== "string") {
|
|
return;
|
|
}
|
|
const tag = normalizeHashtag(rawTag);
|
|
if (tag) {
|
|
normalized.add(tag);
|
|
}
|
|
};
|
|
|
|
if (Array.isArray(video.tags)) {
|
|
for (const tag of video.tags) {
|
|
if (Array.isArray(tag) && tag[0] === "t") {
|
|
addTag(tag[1]);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (Array.isArray(video.nip71?.hashtags)) {
|
|
for (const tag of video.nip71.hashtags) {
|
|
addTag(tag);
|
|
}
|
|
}
|
|
|
|
return normalized;
|
|
}
|
|
|
|
function normalizeTagSet(values) {
|
|
const normalized = new Set();
|
|
if (!values) {
|
|
return normalized;
|
|
}
|
|
|
|
for (const value of toSet(values)) {
|
|
if (typeof value !== "string") {
|
|
continue;
|
|
}
|
|
const tag = normalizeHashtag(value);
|
|
if (tag) {
|
|
normalized.add(tag);
|
|
}
|
|
}
|
|
|
|
return normalized;
|
|
}
|
|
|
|
function resolveAgeProfile(ageGroup) {
|
|
const key = typeof ageGroup === "string" ? ageGroup.trim().toLowerCase() : "";
|
|
return AGE_GROUP_DEFAULTS[key] || AGE_GROUP_DEFAULTS.preschool;
|
|
}
|
|
|
|
function resolveDuration(video) {
|
|
const candidates = [video?.duration, video?.metadata?.duration];
|
|
for (const candidate of candidates) {
|
|
const value = Number(candidate);
|
|
if (Number.isFinite(value) && value > 0) {
|
|
return value;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function resolveWeights(options, context) {
|
|
const weights = { ...DEFAULT_WEIGHTS };
|
|
const overrides = [options?.weights, context?.runtime?.kidsWeights];
|
|
for (const candidate of overrides) {
|
|
if (!isPlainObject(candidate)) {
|
|
continue;
|
|
}
|
|
for (const key of Object.keys(weights)) {
|
|
if (Number.isFinite(candidate[key])) {
|
|
weights[key] = candidate[key];
|
|
}
|
|
}
|
|
}
|
|
return weights;
|
|
}
|
|
|
|
function resolveFreshnessHalfLife(options, context) {
|
|
const candidates = [options?.freshnessHalfLifeDays, context?.runtime?.kidsFreshnessHalfLifeDays];
|
|
for (const candidate of candidates) {
|
|
const value = Number(candidate);
|
|
if (Number.isFinite(value) && value > 0) {
|
|
return value;
|
|
}
|
|
}
|
|
return DEFAULT_FRESHNESS_HALF_LIFE_DAYS;
|
|
}
|
|
|
|
function resolvePopularityMax(options, context) {
|
|
const candidates = [
|
|
options?.popularityMax,
|
|
context?.runtime?.kidsPopularityMax,
|
|
context?.runtime?.explorePopularityMax,
|
|
];
|
|
for (const candidate of candidates) {
|
|
const value = Number(candidate);
|
|
if (Number.isFinite(value) && value > 0) {
|
|
return value;
|
|
}
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
function resolveNow(context) {
|
|
const candidate = Number(context?.runtime?.now);
|
|
if (Number.isFinite(candidate) && candidate > 0) {
|
|
return Math.floor(candidate);
|
|
}
|
|
return Math.floor(Date.now() / 1000);
|
|
}
|
|
|
|
function resolveKidsPopularityValue(video, item) {
|
|
const candidates = [
|
|
video?.kidsViews,
|
|
video?.stats?.kidsViews,
|
|
video?.metrics?.kidsViews,
|
|
item?.metadata?.kidsViews,
|
|
];
|
|
|
|
for (const candidate of candidates) {
|
|
const value = Number(candidate);
|
|
if (Number.isFinite(value) && value >= 0) {
|
|
return value;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function resolveGlobalPopularityValue(video, item) {
|
|
const candidates = [
|
|
video?.viewCount,
|
|
video?.views,
|
|
video?.stats?.views,
|
|
video?.stats?.viewCount,
|
|
video?.metrics?.views,
|
|
item?.metadata?.viewCount,
|
|
];
|
|
|
|
for (const candidate of candidates) {
|
|
const value = Number(candidate);
|
|
if (Number.isFinite(value) && value >= 0) {
|
|
return value;
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
function resolveSafetyScore(item) {
|
|
const moderation = item?.metadata?.moderation || item?.video?.moderation || {};
|
|
if (moderation.hidden === true) {
|
|
return 0;
|
|
}
|
|
|
|
const trustedMuted = moderation.trustedMuted === true;
|
|
const trustedCount = Number(moderation.trustedCount ?? moderation.trustedReportCount);
|
|
let penalty = 0;
|
|
|
|
if (Number.isFinite(trustedCount) && trustedCount > 0) {
|
|
penalty = Math.min(1, trustedCount / 3) * 0.7;
|
|
}
|
|
|
|
if (trustedMuted) {
|
|
penalty = Math.max(penalty, 0.9);
|
|
}
|
|
|
|
return clamp01(1 - penalty);
|
|
}
|
|
|
|
function computeTagMatchScore(tags, preferredTags) {
|
|
if (!tags || tags.size === 0 || preferredTags.size === 0) {
|
|
return 0.4;
|
|
}
|
|
|
|
let matches = 0;
|
|
for (const tag of preferredTags) {
|
|
if (tags.has(tag)) {
|
|
matches += 1;
|
|
}
|
|
}
|
|
|
|
const divisor = Math.min(3, preferredTags.size);
|
|
return clamp01(matches / divisor);
|
|
}
|
|
|
|
export function createKidsScorerStage({
|
|
stageName = "kids-scorer",
|
|
ageGroup = "preschool",
|
|
educationalTags,
|
|
weights,
|
|
...options
|
|
} = {}) {
|
|
const resolveAgeGroupFromContext = (context) => {
|
|
const candidates = [context?.runtime?.ageGroup, context?.config?.ageGroup, ageGroup];
|
|
for (const candidate of candidates) {
|
|
if (typeof candidate !== "string") {
|
|
continue;
|
|
}
|
|
const trimmed = candidate.trim();
|
|
if (trimmed) {
|
|
return trimmed;
|
|
}
|
|
}
|
|
return ageGroup;
|
|
};
|
|
|
|
const resolveEducationalTagsFromContext = (context) => {
|
|
const candidates = [
|
|
context?.runtime?.educationalTags,
|
|
context?.config?.educationalTags,
|
|
educationalTags,
|
|
];
|
|
for (const candidate of candidates) {
|
|
if (candidate !== undefined && candidate !== null) {
|
|
return candidate;
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
return async function kidsScorerStage(items = [], context = {}) {
|
|
if (!Array.isArray(items) || items.length === 0) {
|
|
return items;
|
|
}
|
|
|
|
const resolvedAgeGroup = resolveAgeGroupFromContext(context);
|
|
const ageProfile = resolveAgeProfile(resolvedAgeGroup);
|
|
const preferredTags = normalizeTagSet(ageProfile.preferredTags);
|
|
const defaultEducationalTags = normalizeTagSet(ageProfile.educationalTags);
|
|
const resolvedEducationalTags = resolveEducationalTagsFromContext(context);
|
|
const educationalTagSet =
|
|
resolvedEducationalTags !== null
|
|
? normalizeTagSet(resolvedEducationalTags)
|
|
: defaultEducationalTags;
|
|
const resolvedWeights = resolveWeights({ weights, ...options }, context);
|
|
const freshnessHalfLifeDays = resolveFreshnessHalfLife(options, context);
|
|
const popularityMax = resolvePopularityMax(options, context);
|
|
const now = resolveNow(context);
|
|
|
|
const trustedAuthors = toSet(context?.runtime?.trustedAuthors);
|
|
const trustedAuthorSet = new Set();
|
|
for (const author of trustedAuthors) {
|
|
if (typeof author === "string") {
|
|
trustedAuthorSet.add(author.toLowerCase());
|
|
}
|
|
}
|
|
|
|
for (const item of items) {
|
|
if (!item || typeof item !== "object") {
|
|
continue;
|
|
}
|
|
|
|
const video = item.video;
|
|
if (!video || typeof video !== "object") {
|
|
continue;
|
|
}
|
|
|
|
const tags = normalizeTagSetFromVideo(video);
|
|
const duration = resolveDuration(video);
|
|
let durationScore = 0.5;
|
|
if (Number.isFinite(duration) && duration > 0) {
|
|
const ratio = duration / ageProfile.maxDurationSeconds;
|
|
durationScore = ratio <= 1 ? 1 : clamp01(1 / ratio);
|
|
}
|
|
|
|
const tagScore = computeTagMatchScore(tags, preferredTags);
|
|
const ageAppropriateness = clamp01(0.6 * durationScore + 0.4 * tagScore);
|
|
|
|
let educationalBoost = 0;
|
|
if (educationalTagSet.size > 0 && tags.size > 0) {
|
|
let matches = 0;
|
|
for (const tag of educationalTagSet) {
|
|
if (tags.has(tag)) {
|
|
matches += 1;
|
|
}
|
|
}
|
|
const divisor = Math.min(3, educationalTagSet.size);
|
|
educationalBoost = clamp01(matches / divisor);
|
|
}
|
|
|
|
let authorTrust = 0;
|
|
if (trustedAuthorSet.size > 0 && typeof video.pubkey === "string") {
|
|
authorTrust = trustedAuthorSet.has(video.pubkey.toLowerCase()) ? 1 : 0;
|
|
}
|
|
|
|
let popularityWithinKids = 0;
|
|
const kidsViews = resolveKidsPopularityValue(video, item);
|
|
const popularityValue = kidsViews === null ? resolveGlobalPopularityValue(video, item) : kidsViews;
|
|
if (popularityMax > 0) {
|
|
const normalized = Math.log1p(popularityValue) / Math.log1p(popularityMax);
|
|
popularityWithinKids = clamp01(normalized);
|
|
} else if (popularityValue > 0) {
|
|
popularityWithinKids = clamp01(Math.log1p(popularityValue) / Math.log1p(popularityValue + 100));
|
|
}
|
|
|
|
let freshness = 0;
|
|
const createdAt = Number(video?.rootCreatedAt ?? video?.nip71Source?.created_at ?? video?.created_at);
|
|
if (Number.isFinite(createdAt) && createdAt > 0 && freshnessHalfLifeDays > 0) {
|
|
const ageSeconds = Math.max(0, now - Math.floor(createdAt));
|
|
const ageDays = ageSeconds / 86400;
|
|
const decay = Math.exp(-ageDays / freshnessHalfLifeDays);
|
|
freshness = clamp01(decay);
|
|
}
|
|
|
|
const safetyScore = resolveSafetyScore(item);
|
|
|
|
const baseScore =
|
|
resolvedWeights.w_age * ageAppropriateness +
|
|
resolvedWeights.w_edu * educationalBoost +
|
|
resolvedWeights.w_author * authorTrust +
|
|
resolvedWeights.w_pop * popularityWithinKids +
|
|
resolvedWeights.w_fresh * freshness;
|
|
|
|
const riskMultiplier = clamp01(1 - resolvedWeights.w_risk * (1 - safetyScore));
|
|
const kidsScore = clamp01(baseScore * riskMultiplier);
|
|
|
|
if (!isPlainObject(item.metadata)) {
|
|
item.metadata = {};
|
|
}
|
|
|
|
item.metadata.kidsScore = kidsScore;
|
|
item.metadata.kidsScoreComponents = {
|
|
ageAppropriateness,
|
|
educationalBoost,
|
|
authorTrust,
|
|
popularityWithinKids,
|
|
freshness,
|
|
safetyScore,
|
|
};
|
|
|
|
const videoId = typeof video.id === "string" ? video.id : null;
|
|
const pubkey = typeof video.pubkey === "string" ? video.pubkey : null;
|
|
|
|
const positiveComponents = [
|
|
{ key: "age-appropriateness", value: ageAppropriateness },
|
|
{ key: "educational-boost", value: educationalBoost },
|
|
{ key: "author-trust", value: authorTrust },
|
|
{ key: "popularity", value: popularityWithinKids },
|
|
{ key: "freshness", value: freshness },
|
|
];
|
|
|
|
let dominantPositive = positiveComponents[0];
|
|
for (const component of positiveComponents) {
|
|
if (component.value > dominantPositive.value) {
|
|
dominantPositive = component;
|
|
}
|
|
}
|
|
|
|
if (dominantPositive.value > 0) {
|
|
context?.addWhy?.({
|
|
stage: stageName,
|
|
type: "score",
|
|
reason: dominantPositive.key,
|
|
value: dominantPositive.value,
|
|
score: kidsScore,
|
|
videoId,
|
|
pubkey,
|
|
});
|
|
}
|
|
}
|
|
|
|
return items;
|
|
};
|
|
}
|