Add multi-tenant cloud mode: self-serve purchase, credit metering, core-decoupling
Introduces RECAP_MODE=multi alongside single-mode self-host: - Tenant auth + accounts (magic-link via System SMTP), per-tenant credit pool, anonymous trial minting with per-IP/-64 caps - Self-serve Pro/Max purchase: inline Lightning (BTCPay) + card (Zaprite), prepaid 30-day periods, expiry-reminder emails - Core-decoupling: relay owns cloud tier/expiry keyed by Recaps user-id - SQLite (better-sqlite3) schema for multi-mode; filesystem unchanged for single - StartOS actions/versions through 0.2.155
This commit is contained in:
+66
-12
@@ -9,12 +9,15 @@ import { formatTime } from "./util.js";
|
||||
// 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 },
|
||||
// The five Gemini models we support. Verified against Google's
|
||||
// official docs on 2026-05-12. Retired/never-existed IDs omitted.
|
||||
"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 },
|
||||
"gemini-2.5-pro": { input: 1.25, output: 10.00, thinking: 10.00 },
|
||||
"gemini-3-flash-preview": { input: 0.50, output: 3.00, thinking: 3.00 },
|
||||
"gemini-2.5-flash": { input: 0.15, output: 0.60, thinking: 0.60 },
|
||||
"gemini-3.1-flash-lite": { input: 0.10, output: 0.40, thinking: 0.40 },
|
||||
// Fallback for unknown / future models — better an estimate than nothing.
|
||||
"default": { input: 1.00, output: 5.00, thinking: 5.00 },
|
||||
"default": { input: 1.00, output: 5.00, thinking: 5.00 },
|
||||
};
|
||||
|
||||
// ── Cost calculation ────────────────────────────────────────────────────────
|
||||
@@ -46,16 +49,67 @@ export function calcCost(modelName, usage) {
|
||||
};
|
||||
}
|
||||
|
||||
// ── Section-count target by VIDEO duration ─────────────────────────────────
|
||||
// Mirrors recap-relay's computePerWindowTarget() (server/chunked-analyze.js).
|
||||
// Operator-tunable on the relay; baked into code defaults here on the
|
||||
// Recap-app direct path. The defaults match the relay's defaults so
|
||||
// segmentation density is consistent across both pipelines.
|
||||
//
|
||||
// Buckets are TOTAL video duration in minutes:
|
||||
// <30 → 6 sections / 30-60 → 8 / 60-90 → 9 / 90-120 → 10
|
||||
// 120-150 → 11 / 150-180 → 12 / >=180 → 12
|
||||
// Per-window target = total_target × window_sec / total_audio_sec
|
||||
// (clamped to ≥1 for single-shot runs).
|
||||
function pickTotalSectionsTarget(totalAudioSec) {
|
||||
const m = (totalAudioSec || 0) / 60;
|
||||
if (m < 30) return 6;
|
||||
if (m < 60) return 8;
|
||||
if (m < 90) return 9;
|
||||
if (m < 120) return 10;
|
||||
if (m < 150) return 11;
|
||||
if (m < 180) return 12;
|
||||
return 12;
|
||||
}
|
||||
function formatTargetSectionsLabel(avg) {
|
||||
if (avg <= 1.2) return "1 section";
|
||||
const lo = Math.max(1, Math.floor(avg));
|
||||
const hi = Math.max(lo, Math.ceil(avg));
|
||||
if (lo === hi) return "around " + lo + " sections";
|
||||
return lo + "–" + hi + " sections";
|
||||
}
|
||||
|
||||
// ── 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) {
|
||||
// Takes the parsed transcript entries for a WINDOW and builds the
|
||||
// JSON-output prompt fed to the analysis model. Indices in the response
|
||||
// are positional into the same window-entries array — the caller relies
|
||||
// on that contract.
|
||||
//
|
||||
// `opts.totalAudioSec` is the FULL audio duration (not just this window),
|
||||
// used to scale the section-count target via the per-video-duration table
|
||||
// above. When omitted, falls back to deriving from the windowEntries
|
||||
// themselves (legacy callers / unit tests / single-shot path).
|
||||
export function buildAnalysisPrompt(entries, opts = {}) {
|
||||
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.
|
||||
// Window length in minutes (this window's own transcript span).
|
||||
const windowSec = entries.length > 1
|
||||
? (entries[entries.length - 1].offset || 0) - (entries[0].offset || 0)
|
||||
: 0;
|
||||
const windowMin = Math.max(1, Math.round(windowSec / 60));
|
||||
const maxIndex = Math.max(0, entries.length - 1);
|
||||
|
||||
// Total audio duration drives the per-video-duration target picker.
|
||||
// If the caller didn't supply it, assume this is a single-shot run
|
||||
// and the window IS the whole audio.
|
||||
const totalAudioSec = opts.totalAudioSec || windowSec || 60;
|
||||
const totalTarget = pickTotalSectionsTarget(totalAudioSec);
|
||||
const numWindows = Math.max(1, totalAudioSec / Math.max(60, windowSec || 60));
|
||||
const avgPerWindow = totalTarget / numWindows;
|
||||
const targetSections = formatTargetSectionsLabel(avgPerWindow);
|
||||
|
||||
return `You are analyzing a ~${windowMin}-minute section of a longer transcript. Your job is to identify natural topic boundaries and group the transcript into discussion-based sections — aim for ${targetSections}.
|
||||
|
||||
TRANSCRIPT (each line is numbered with a timestamp):
|
||||
${numbered}
|
||||
@@ -67,13 +121,13 @@ INSTRUCTIONS:
|
||||
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)
|
||||
- The start and end segment indices (inclusive), counted as the bracketed [N] number at the start of each transcript line above.
|
||||
|
||||
IMPORTANT:
|
||||
- Sections must be chronological and non-overlapping.
|
||||
- Every segment index from 0 to ${entries.length - 1} must belong to exactly one section.
|
||||
- Every segment index from 0 to ${maxIndex} 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.
|
||||
- Create as many or as few sections as the content naturally requires — but lean toward broad, substantive topics rather than minute-by-minute breakdowns. A natural topic that spans several minutes of dialogue should be one section, not several.
|
||||
- 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:
|
||||
|
||||
Reference in New Issue
Block a user