Docs/Skills and code/src/

src/analysis/channel-evidence.js

Code176 lines9.7 KBsrc/

import crypto from "node:crypto";
import { config } from "../../config.js";
import { isRecencyEligible } from "./temporal.js";

const labels = new Set(config.qualification.classification.labels);
const clean = value => String(value ?? "").replace(/\s+/g, " ").trim();
const numberOrNull = value => value !== null && value !== undefined && value !== "" && Number.isFinite(Number(value)) ? Number(value) : null;

function uniqueById(rows) {
  const seen = new Set();
  return rows.filter(row => {
    const key = clean(row.video_id || row.title).toLowerCase();
    if (!key || seen.has(key)) return false;
    seen.add(key);
    return true;
  });
}

/** A deterministic, bounded evidence packet shared by the LLM stage and queue scorer. */
export function buildQualificationInput(channel) {
  const titleLimit = config.qualification.llm.maxVideoTitles;
  const description = clean(channel.description).slice(0, config.qualification.llm.descriptionChars);
  const keywords = Array.isArray(channel.keywords)
    ? channel.keywords.map(clean).filter(Boolean).slice(0, 30)
    : parseJsonArray(channel.keywords_json);
  const videos = (channel.videos ?? []).map(video => ({
    video_id: clean(video.video_id) || null,
    title: clean(video.title).slice(0, 240),
    views: numberOrNull(video.view_count ?? video.views),
    published_at: clean(video.published_at_normalized ?? video.published_at) || null,
    published_at_precision: clean(video.published_at_precision) || "unknown"
  })).filter(video => video.title);
  // Always break ties deterministically. The qualification stage and queue scorer
  // obtain the same videos through different SQL paths, so relying on input order
  // makes equal-date/equal-view titles produce different fingerprints.
  const stableTitleOrder = (a, b) => a.title.localeCompare(b.title) || String(a.video_id ?? "").localeCompare(String(b.video_id ?? ""));
  const byPopularity = (a, b) => (b.views ?? -1) - (a.views ?? -1) || stableTitleOrder(a, b);
  const popular = [...videos].sort(byPopularity).slice(0, Math.ceil(titleLimit / 2));
  const dated = [...videos].filter(v => v.published_at && /^\d{4}-\d{2}-\d{2}/.test(v.published_at))
    .sort((a, b) => String(b.published_at).localeCompare(String(a.published_at)) || stableTitleOrder(a, b))
    .slice(0, Math.floor(titleLimit / 2));
  const selected = uniqueById([...popular, ...dated]);
  if (selected.length < titleLimit) {
    selected.push(...uniqueById([...videos].sort(byPopularity).filter(v => !selected.some(s => s.video_id && s.video_id === v.video_id || s.title === v.title))).slice(0, titleLimit - selected.length));
  }
  const boundedVideos = selected.slice(0, titleLimit).map(v => ({
    video_id: v.video_id, title: v.title,
    published_at: v.published_at, published_at_precision: v.published_at_precision
  }));
  const packet = {
    channel_url: clean(channel.channel_url),
    name: clean(channel.name),
    description,
    keywords,
    sample_note: "Bounded sample of captured linked-video titles selected across high-view and recent-dated candidates where available; not guaranteed to represent the full catalogue.",
    representative_videos: boundedVideos,
    sampled_video_count: videos.length
  };
  return { packet, inputHash: crypto.createHash("sha256").update(stableStringify(packet)).digest("hex") };
}

export function stableStringify(value) {
  if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
  if (value && typeof value === "object") {
    return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(",")}}`;
  }
  return JSON.stringify(value);
}

function parseJsonArray(value) {
  if (Array.isArray(value)) return value.map(clean).filter(Boolean).slice(0, 30);
  try { const parsed = JSON.parse(value ?? "[]"); return Array.isArray(parsed) ? parsed.map(clean).filter(Boolean).slice(0, 30) : []; }
  catch { return []; }
}

export function normalizeQualification(row, { source = "rule_based", minimumConfidence = config.qualification.minimumConfidenceToQualify } = {}) {
  const classification = String(row?.classification ?? row?.ecosystem_classification ?? "unknown").toLowerCase();
  const validClass = labels.has(classification) ? classification : "unknown";
  const rawConfidence = numberOrNull(row?.confidence ?? row?.qualification_confidence);
  const confidence = rawConfidence == null ? (source === "llm" ? 0 : 0.35) : rawConfidence > 1 ? rawConfidence / 100 : rawConfidence;
  const resolvedSource = row?.source ?? source;
  const accepted = (validClass === "core" || validClass === "adjacent") && confidence >= minimumConfidence;
  const eligibility = validClass === "irrelevant" ? "ineligible_irrelevant"
    : accepted ? (validClass === "core" ? "eligible_core" : "eligible_adjacent")
      : "qualification_pending";
  return {
    classification: validClass,
    confidence: Math.max(0, Math.min(1, confidence)),
    relevance_score: numberOrNull(row?.relevance_score),
    focus_specificity: numberOrNull(row?.focus_specificity),
    source: resolvedSource,
    prompt_version: row?.prompt_version ?? null,
    rationale: clean(row?.rationale ?? row?.reason) || null,
    focus_summary: clean(row?.focus_summary) || null,
    channel_type: clean(row?.channel_type) || null,
    supporting_evidence: arrayValue(row?.supporting_evidence ?? row?.supporting_evidence_json),
    counter_evidence: arrayValue(row?.counter_evidence ?? row?.counter_evidence_json),
    topic_tags: arrayValue(row?.topic_tags ?? row?.topic_tags_json),
    formats: arrayValue(row?.formats ?? row?.formats_json),
    uncertainty_flags: arrayValue(row?.uncertainty_flags ?? row?.uncertainty_flags_json),
    eligibility,
    eligible: accepted,
    minimum_confidence: minimumConfidence
  };
}

function arrayValue(value) {
  if (Array.isArray(value)) return value;
  if (value == null || value === "") return [];
  try { const parsed = typeof value === "string" ? JSON.parse(value) : value; return Array.isArray(parsed) ? parsed : []; }
  catch { return []; }
}

export function buildSharedChannelEvidence(channel, now = new Date()) {
  const videos = (channel.videos ?? []).map(video => {
    const views = numberOrNull(video.view_count ?? video.views);
    const dateText = video.published_at_normalized ?? video.published_at;
    const precision = String(video.published_at_precision ?? "").trim().toLowerCase();
    const precisionUsable = !precision || precision === "date" || isRecencyEligible(precision);
    const date = precisionUsable && dateText && /^\d{4}-\d{2}-\d{2}/.test(String(dateText)) ? new Date(dateText) : null;
    const ageDays = date && Number.isFinite(date.getTime()) ? (now.getTime() - date.getTime()) / 86400000 : null;
    return { video_id: video.video_id ?? null, title: video.title ?? null, views, date: ageDays != null && ageDays >= 0 ? date : null,
      age_days: ageDays != null && ageDays >= 0 ? ageDays : null,
      views_per_day: views != null && ageDays != null && ageDays > 0 ? views / ageDays : null,
      published_at_precision: video.published_at_precision ?? "unknown" };
  }).filter(v => v.views != null && v.views >= 0);
  const sortedViews = videos.map(v => v.views).sort((a, b) => a - b);
  const median = medianOf(sortedViews);
  const p90 = percentile(sortedViews, 0.9);
  const dated = videos.filter(v => v.age_days != null && v.age_days >= config.queueScoring.breakout.minimumAgeDaysForAgeAdjustedViews);
  const medianViewsPerDay = medianOf(dated.map(v => v.views_per_day).filter(Number.isFinite));
  const subs = numberOrNull(channel.subscriber_count);
  const prior = config.queueScoring.breakout.smallChannelSubscriberPrior;
  const smoothedReach = median != null && subs != null ? median / (subs + prior) : null;
  const threshold = Math.max(1000, subs == null ? 0 : subs * 0.25);
  const repeatableCount = videos.filter(v => v.views >= threshold).length;
  const repeatability = videos.length ? repeatableCount / videos.length * 100 : null;
  const consistency = median != null && p90 != null && p90 > 0 ? median / p90 * 100 : null;
  const channelAgeYears = channel.joined_date ? (now.getTime() - new Date(channel.joined_date).getTime()) / (365.2425 * 86400000) : null;
  const qualification = normalizeQualification(channel.qualification_assessment ?? {
    classification: channel.ecosystem_classification,
    confidence: channel.ecosystem_classification_confidence,
    rationale: channel.ecosystem_reason,
    source: channel.ecosystem_classification_source ?? "rule_based"
  }, { source: channel.qualification_assessment ? "llm" : (channel.ecosystem_classification_source ?? "rule_based") });
  return {
    qualification,
    video_count: (channel.videos ?? []).length,
    videos_with_views: videos.length,
    dated_video_count: dated.length,
    estimated_date_count: videos.filter(v => String(v.published_at_precision).startsWith("estimated_")).length,
    median_video_views: median,
    p90_video_views: p90,
    median_views_per_day_proxy: medianViewsPerDay,
    smoothed_views_per_subscriber: smoothedReach,
    repeatable_count: repeatableCount,
    repeatability_percent: repeatability,
    median_to_p90_percent: consistency,
    channel_age_years: Number.isFinite(channelAgeYears) && channelAgeYears >= 0 ? channelAgeYears : null,
    videos,
    topic_tags: qualification.topic_tags,
    focus_specificity: qualification.focus_specificity
  };
}

function medianOf(values) {
  const cleanValues = values.filter(Number.isFinite).sort((a, b) => a - b);
  if (!cleanValues.length) return null;
  const mid = Math.floor(cleanValues.length / 2);
  return cleanValues.length % 2 ? cleanValues[mid] : (cleanValues[mid - 1] + cleanValues[mid]) / 2;
}
function percentile(values, p) {
  if (!values.length) return null;
  return values[Math.min(values.length - 1, Math.max(0, Math.ceil(values.length * p) - 1))];
}

Shown as it is in the repository at build time (2026-11-03); the home directory appears as <home>. If this page and the file differ, the file is right. Back to the catalogue.