From 1c78e46ebd642b55f8ffe7fb88eeccaa3ffe027c Mon Sep 17 00:00:00 2001 From: Keysat Date: Fri, 8 May 2026 16:50:34 -0500 Subject: [PATCH] Module split: extract Gemini-specific helpers to server/gemini-helpers.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • PRICING table — per-1M-token rates by model • calcCost(model, usage) — Gemini usage object → cost record • buildAnalysisPrompt(...) — JSON-output topic-analysis prompt These all share the Gemini contract — pricing schema, usage shape, and prompt format. When we add other providers, each gets its own provider-specific helpers file; this becomes the basis of the Gemini provider implementation. server/index.js: 2828 → 2758 lines. Smoke tested: server boots; /api/license-status, /api/health, and / (frontend) all respond. No behavior change. --- server/gemini-helpers.js | 90 ++++++++++++++++++++++++++++++++++++++++ server/index.js | 76 ++------------------------------- 2 files changed, 93 insertions(+), 73 deletions(-) create mode 100644 server/gemini-helpers.js diff --git a/server/gemini-helpers.js b/server/gemini-helpers.js new file mode 100644 index 0000000..def1aac --- /dev/null +++ b/server/gemini-helpers.js @@ -0,0 +1,90 @@ +// Gemini-specific helpers: pricing table, cost calculation, prompt +// builder. Pure module — no state, no I/O. When we add other providers, +// each provider gets its own equivalent of this file. + +import { formatTime } from "./util.js"; + +// ── Pricing (per 1M tokens) ───────────────────────────────────────────────── +// Only the models we actually use as analysis fallbacks. Keep flat — the +// numbers are operational data, not configuration. Update when Google +// changes published rates. +export const PRICING = { + "gemini-3-flash-preview": { input: 0.50, output: 3.00, thinking: 3.00 }, + "gemini-3-pro-preview": { input: 2.00, output: 12.00, thinking: 12.00 }, + "gemini-3.1-pro-preview": { input: 2.00, output: 12.00, thinking: 12.00 }, + "gemini-2.5-flash": { input: 0.15, output: 0.60, thinking: 0.60 }, + // Fallback for unknown / future models — better an estimate than nothing. + "default": { input: 1.00, output: 5.00, thinking: 5.00 }, +}; + +// ── Cost calculation ──────────────────────────────────────────────────────── +// Takes a Gemini SDK `usage` object (response.usageMetadata) and produces +// a structured cost record. Display strings are formatted at extraction +// time so callers don't reformat. Returns zeros for unknown models (uses +// the "default" rates). +export function calcCost(modelName, usage) { + const rates = PRICING[modelName] || PRICING["default"]; + const inputTokens = usage.promptTokenCount || 0; + const outputTokens = usage.candidatesTokenCount || 0; + const thinkingTokens = usage.thoughtsTokenCount || 0; + + const inputCost = (inputTokens / 1_000_000) * rates.input; + const outputCost = (outputTokens / 1_000_000) * rates.output; + const thinkingCost = (thinkingTokens / 1_000_000) * rates.thinking; + const totalCost = inputCost + outputCost + thinkingCost; + + return { + inputTokens, + outputTokens, + thinkingTokens, + totalTokens: usage.totalTokenCount || (inputTokens + outputTokens + thinkingTokens), + inputCost: inputCost.toFixed(6), + outputCost: outputCost.toFixed(6), + thinkingCost: thinkingCost.toFixed(6), + totalCost: totalCost.toFixed(6), + totalCostDisplay: totalCost < 0.01 ? `$${(totalCost * 100).toFixed(3)}¢` : `$${totalCost.toFixed(4)}`, + }; +} + +// ── Topic-analysis prompt builder ─────────────────────────────────────────── +// Takes the parsed transcript entries and builds the JSON-output prompt +// fed to the analysis model. Indices in the response are positional into +// the same `entries` array — the caller relies on that contract. +export function buildAnalysisPrompt(entries) { + const numbered = entries + .map((e, i) => `[${i}] (${formatTime(e.offset)}) ${e.text}`) + .join("\n"); + + return `You are analyzing a video transcript. Your job is to identify natural topic boundaries and group the transcript into discussion-based sections. + +TRANSCRIPT (each line is numbered with a timestamp): +${numbered} + +INSTRUCTIONS: +1. Read the entire transcript carefully. +2. Identify where the discussion naturally shifts from one topic to another. +3. Group consecutive transcript segments by topic. Some sections may be short (a quick aside) and some may be long (an extended deep-dive). Let the content dictate the length. +4. For each section, write: + - A short, specific topic title (3-8 words) + - A 1-3 sentence summary of what's discussed + - The start and end segment indices (inclusive) + +IMPORTANT: +- Sections must be chronological and non-overlapping. +- Every segment index from 0 to ${entries.length - 1} must belong to exactly one section. +- startIndex of section N+1 must equal endIndex of section N plus 1. +- Create as many or as few sections as the content naturally requires. +- Titles should be descriptive and specific, not generic like "Introduction" unless it truly is one. + +Respond with ONLY valid JSON in this exact format, no other text: +{ + "sections": [ + { + "title": "Brief Topic Title", + "summary": "1-3 sentence summary of this discussion section.", + "startIndex": 0, + "endIndex": 15 + } + ] +}`; +} diff --git a/server/index.js b/server/index.js index 7c90af1..a1caf07 100644 --- a/server/index.js +++ b/server/index.js @@ -18,6 +18,7 @@ import { safeText, retryGemini, } from "./util.js"; +import { calcCost, buildAnalysisPrompt } from "./gemini-helpers.js"; const execFileAsync = promisify(execFile); const app = express(); @@ -427,41 +428,7 @@ async function autoUpdateYtdlp() { } } -// ── Pricing (per 1M tokens) ─────────────────────────────────────────────── - -const PRICING = { - "gemini-3-flash-preview": { input: 0.50, output: 3.00, thinking: 3.00 }, - "gemini-3-pro-preview": { input: 2.00, output: 12.00, thinking: 12.00 }, - "gemini-3.1-pro-preview": { input: 2.00, output: 12.00, thinking: 12.00 }, - "gemini-2.5-flash": { input: 0.15, output: 0.60, thinking: 0.60 }, - // Fallback for unknown models - "default": { input: 1.00, output: 5.00, thinking: 5.00 }, -}; - -function calcCost(modelName, usage) { - const rates = PRICING[modelName] || PRICING["default"]; - const inputTokens = usage.promptTokenCount || 0; - const outputTokens = usage.candidatesTokenCount || 0; - const thinkingTokens = usage.thoughtsTokenCount || 0; - - const inputCost = (inputTokens / 1_000_000) * rates.input; - const outputCost = (outputTokens / 1_000_000) * rates.output; - const thinkingCost = (thinkingTokens / 1_000_000) * rates.thinking; - const totalCost = inputCost + outputCost + thinkingCost; - - return { - inputTokens, - outputTokens, - thinkingTokens, - totalTokens: usage.totalTokenCount || (inputTokens + outputTokens + thinkingTokens), - inputCost: inputCost.toFixed(6), - outputCost: outputCost.toFixed(6), - thinkingCost: thinkingCost.toFixed(6), - totalCost: totalCost.toFixed(6), - totalCostDisplay: totalCost < 0.01 ? `$${(totalCost * 100).toFixed(3)}¢` : `$${totalCost.toFixed(4)}`, - }; -} - +// PRICING + calcCost + buildAnalysisPrompt moved to ./gemini-helpers.js // safeText + retryGemini moved to ./util.js // ── Health check ─────────────────────────────────────────────────────────── @@ -2714,44 +2681,7 @@ async function splitAudioFile(inputPath, outputDir, chunkSeconds = 2700) { // sendEvent / extractVideoId / formatTime / parseTimestampedTranscript moved to ./util.js -function buildAnalysisPrompt(entries) { - const numbered = entries - .map((e, i) => `[${i}] (${formatTime(e.offset)}) ${e.text}`) - .join("\n"); - - return `You are analyzing a video transcript. Your job is to identify natural topic boundaries and group the transcript into discussion-based sections. - -TRANSCRIPT (each line is numbered with a timestamp): -${numbered} - -INSTRUCTIONS: -1. Read the entire transcript carefully. -2. Identify where the discussion naturally shifts from one topic to another. -3. Group consecutive transcript segments by topic. Some sections may be short (a quick aside) and some may be long (an extended deep-dive). Let the content dictate the length. -4. For each section, write: - - A short, specific topic title (3-8 words) - - A 1-3 sentence summary of what's discussed - - The start and end segment indices (inclusive) - -IMPORTANT: -- Sections must be chronological and non-overlapping. -- Every segment index from 0 to ${entries.length - 1} must belong to exactly one section. -- startIndex of section N+1 must equal endIndex of section N plus 1. -- Create as many or as few sections as the content naturally requires. -- Titles should be descriptive and specific, not generic like "Introduction" unless it truly is one. - -Respond with ONLY valid JSON in this exact format, no other text: -{ - "sections": [ - { - "title": "Brief Topic Title", - "summary": "1-3 sentence summary of this discussion section.", - "startIndex": 0, - "endIndex": 15 - } - ] -}`; -} +// buildAnalysisPrompt moved to ./gemini-helpers.js // ── Network mode ────────────────────────────────────────────────────────── // On StartOS (DATA_DIR=/data): always bind to 0.0.0.0 (container networking)