initial commit: rocket.new export of broadcastbeat

This commit is contained in:
Ryan Salazar
2026-05-07 16:39:17 +00:00
commit 70aa1ad46e
302 changed files with 60710 additions and 0 deletions

View File

@@ -0,0 +1,616 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { hybridAI } from '@/lib/ai/hybridRouter';
// ─── Authorized BB Pen Names (Section 15H / Conflict 4 resolved) ─────────────
const BB_PEN_NAMES = [
{ name: 'Michael Strand', beat: 'broadcast-technology', title: 'Senior Technology Editor' },
{ name: 'David Harlow', beat: 'broadcast-engineering', title: 'Broadcast Engineering Correspondent' },
{ name: 'Karen Fielding', beat: 'post-production', title: 'Post Production Editor' },
{ name: 'James Mercer', beat: 'nab-show', title: 'NAB Show Senior Correspondent' },
{ name: 'Peter Calloway', beat: 'radio-transmission', title: 'Radio & Transmission Reporter' },
{ name: 'Sandra Voss', beat: 'production-technology', title: 'Production Technology Editor' },
{ name: 'Brian Kowalski', beat: 'streaming-ott', title: 'Streaming & OTT Correspondent' },
{ name: 'Laura Pennington', beat: 'industry-news', title: 'Industry News Editor' },
{ name: 'Thomas Reeves', beat: 'audio-technology', title: 'Audio Technology Correspondent' },
{ name: 'Christine Vale', beat: 'business-industry', title: 'Business & Industry Reporter' },
{ name: 'Marcus Webb', beat: 'live-production', title: 'Live Production Correspondent' },
{ name: 'Ellen Forsythe', beat: 'international-markets', title: 'International Markets Editor' },
];
// ─── AV Beat Pen Names (authorized roster) ───────────────────────────────────
const AV_PEN_NAMES = [
{ name: 'Rex Chandler', beat: 'av-integration', title: 'AV Integration Senior Correspondent' },
{ name: 'Dana Flux', beat: 'digital-signage', title: 'Digital Signage & Display Editor' },
{ name: 'Derek Wainwright', beat: 'unified-communications', title: 'Unified Communications Correspondent' },
{ name: 'Sloane Rigging', beat: 'sound-audio', title: 'Installed AV & Audio Editor' },
{ name: 'Chip Crosspoint', beat: 'av-networking', title: 'AV Networking & Technology Correspondent' },
{ name: 'Blair Presenter', beat: 'education-corporate-av', title: 'Education & Corporate AV Editor' },
{ name: 'Jordan Lumen', beat: 'display-visualization', title: 'Display & Visualization Correspondent' },
];
// Beat-to-story-type mapping for smart pen name selection
const BEAT_STORY_MAP: Record<string, string[]> = {
'nab-show': ['exhibitor_preview','show_preview_guide','show_floor_report','best_of_roundup','recap'],
'broadcast-technology': ['product_announcement','breaking_announcement','company_news'],
'broadcast-engineering': ['product_announcement','product_update'],
'post-production': ['product_announcement','thought_leadership'],
'audio-technology': ['product_announcement','event_coverage'],
'live-production': ['show_floor_report','event_coverage'],
'streaming-ott': ['product_announcement','company_news'],
'industry-news': ['executive_news','company_news','press_release_rewrite'],
'business-industry': ['thought_leadership','company_news'],
'international-markets': ['show_floor_report','recap','exhibitor_preview'],
'av-integration': ['product_announcement','company_news','exhibitor_preview'],
'digital-signage': ['product_announcement','product_update'],
'unified-communications':['product_announcement','company_news'],
'display-visualization': ['product_announcement','product_update'],
'education-corporate-av':['thought_leadership','company_news'],
};
function pickPenNameForShow(
siteId: number,
storyType: string,
recentNames: string[],
preferredName?: string
): string {
const pool = siteId === 2 ? AV_PEN_NAMES : BB_PEN_NAMES;
// Use preferred name if specified and not overused
if (preferredName) {
const preferred = pool.find(p => p.name === preferredName);
if (preferred && !recentNames.slice(-3).includes(preferredName)) {
return preferredName;
}
}
// Find writers whose beat matches this story type
const beatMatches = pool.filter(p => {
const beatStories = BEAT_STORY_MAP[p.beat] || [];
return beatStories.includes(storyType) && !recentNames.slice(-3).includes(p.name);
});
if (beatMatches.length > 0) {
return beatMatches[Math.floor(Math.random() * beatMatches.length)].name;
}
// Fallback: any available writer
const available = pool.filter(p => !recentNames.slice(-3).includes(p.name));
return available.length > 0
? available[Math.floor(Math.random() * available.length)].name
: pool[0].name;
}
// ─── Determine current show phase ────────────────────────────────────────────
function getShowPhase(event: any): string {
if (!event.start_date) return 'phase5_off_season';
const now = new Date();
const start = new Date(event.start_date);
const end = new Date(event.end_date || event.start_date);
const engineStart = new Date(event.story_engine_start_date || start);
const engineEnd = new Date(event.story_engine_end_date || end);
const daysToStart = Math.floor((start.getTime() - now.getTime()) / 86400000);
const daysFromEnd = Math.floor((now.getTime() - end.getTime()) / 86400000);
if (now < engineStart) return 'phase5_off_season';
if (daysToStart > 13) return 'phase1_buildup';
if (daysToStart > 0) return 'phase2_surge';
if (now >= start && now <= end) return 'phase3_show_week';
if (daysFromEnd <= 14) return 'phase4_post_show';
if (now > engineEnd) return 'phase5_off_season';
return 'phase5_off_season';
}
// ─── Generate show story via Hybrid AI ───────────────────────────────────────
async function generateShowStory(params: {
penName: string;
penTitle: string;
siteName: string;
eventName: string;
year: number;
storyType: string;
companyName?: string;
sourceContent?: string;
styleGuide?: string;
phase: string;
}): Promise<{ title: string; excerpt: string; content: string; metaDescription: string; confidence: number }> {
const { penName, penTitle, siteName, eventName, year, storyType, companyName, sourceContent, styleGuide, phase } = params;
const styleContext = styleGuide
? `\n\nSTYLE GUIDE (based on existing ${siteName} coverage):\n${styleGuide.slice(0, 800)}`
: '';
const storyInstructions: Record<string, string> = {
exhibitor_preview: `Write an exhibitor preview: "${companyName || 'Company'} to Showcase Solutions at ${eventName} ${year}". 400-600 words. SEO target: "${companyName} ${eventName.split(' ')[0]} ${year}".`,
show_preview_guide: `Write a comprehensive show preview guide: "${eventName} ${year}: What to Expect". 800-1200 words. This is pillar content — high SEO value. Target keyword: "${eventName} ${year}".`,
breaking_announcement: `Write a breaking news announcement: "${companyName || 'Company'} Launches at ${eventName} ${year}". 300-500 words. Publish within 2 hours of announcement. Lead with the news hook immediately.`,
show_floor_report: `Write a show floor report for ${eventName} ${year}. 800-1200 words. Aggregate the day's key announcements. Include product highlights, company news, and show atmosphere.`,
best_of_roundup: `Write a best-of roundup: "Best of ${eventName} ${year}: The Products That Stood Out". 1000-1500 words. Comprehensive post-show analysis.`,
recap: `Write a show recap: "${eventName} ${year} Recap: Key Takeaways". 800-1200 words. Synthesize the show's major themes and announcements.`,
award_announcement: `Write an award announcement story. 300-500 words. Professional tone. Include award significance and recipient background.`,
product_announcement: `Write a product announcement: "${companyName || 'Company'} Announces New Product for ${eventName} ${year}". 400-700 words.`,
keynote_preview: `Write a keynote preview story. 300-500 words. Include speaker background and session significance.`,
travel_logistics: `Write a travel and logistics guide for ${eventName} ${year}. 600-900 words. Registration, hotels, transportation, key dates.`,
product_availability: `Write a post-show product availability story. 300-500 words. Cover pricing, availability dates, and ordering information.`,
press_release_rewrite: `Rewrite the following press release as a real news story for ${siteName}. Remove marketing language, lead with the news hook, add industry context. Byline: ${penName}.`,
company_news: `Write a company news story. 300-500 words. Professional journalistic tone.`,
executive_news: `Write an executive appointment/news story. 300-500 words.`,
thought_leadership: `Write a thought leadership piece. 600-900 words. Frame as editorial analysis, not a press release.`,
};
const instruction = storyInstructions[storyType] || storyInstructions.company_news;
const systemPrompt = `You are ${penName}, ${penTitle}, writing for ${siteName}. You are covering ${eventName} ${year}.
Rules:
- Write in your own words — do not copy source text verbatim
- Write as a real journalist, not a press release
- Lead with the news hook, not company boilerplate
- Include the year ${year} in the title
- Professional journalistic tone appropriate for ${siteName}'s B2B trade audience
- Do not mention or link to any competitor publications
- End with a brief company boilerplate paragraph if company-specific
- Return ONLY valid JSON with no markdown code blocks${styleContext}`;
const userPrompt = `${instruction}
${sourceContent ? `Source material:\n${sourceContent.slice(0, 2000)}` : `Write based on your knowledge of ${eventName} and the ${siteName} industry.`}
Return JSON: {"title":"...","excerpt":"...","content":"<p>...</p><p>...</p>","metaDescription":"150-160 char SEO description","confidence":0.0-1.0}`;
try {
let result = await hybridAI(
[
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
{ maxTokens: 2000, isBackground: true, priority: 3 }
);
const match = result.text.match(/\{[\s\S]*\}/);
if (match) {
const parsed = JSON.parse(match[0]);
return {
title: parsed.title || `${eventName} ${year}: Industry Update`,
excerpt: parsed.excerpt || '',
content: parsed.content || `<p>Coverage of ${eventName} ${year}.</p>`,
metaDescription: parsed.metaDescription || `${eventName} ${year} coverage from ${siteName}.`,
confidence: Math.min(1, Math.max(0, parsed.confidence || 0.75)),
};
}
} catch (err) {
console.error('Show story generation error:', err);
}
return {
title: `${eventName} ${year}: Industry Update`,
excerpt: `Coverage of ${eventName} ${year}.`,
content: `<p>This is an AI-drafted story about ${eventName} ${year}.</p>`,
metaDescription: `${eventName} ${year} coverage from ${siteName}.`,
confidence: 0.5,
};
}
// ─── Extract Ryan Salazar style ───────────────────────────────────────────────
async function extractStyleProfile(articles: any[], eventName: string): Promise<string> {
if (articles.length === 0) return '';
const sampleContent = articles
.slice(0, 5)
.map(a => `TITLE: ${a.title}\n\nCONTENT: ${(a.content || '').slice(0, 500)}`)
.join('\n\n---\n\n');
try {
let result = await hybridAI(
[
{
role: 'system',
content: 'You are a writing style analyst. Analyze the provided articles and extract a concise style guide.',
},
{
role: 'user',
content: `Analyze these articles covering ${eventName}. Extract: (1) typical article structure, (2) tone and voice, (3) common section types, (4) typical word count range, (5) how companies/products are introduced, (6) how show context is woven in. Return a concise style guide in 300-400 words.\n\n${sampleContent}`,
},
],
{ maxTokens: 800, isBackground: true, priority: 6 }
);
return result.text;
} catch {
return '';
}
}
// ─── GET ──────────────────────────────────────────────────────────────────────
export async function GET(request: NextRequest) {
try {
const supabase = await createClient();
const { searchParams } = new URL(request.url);
const action = searchParams.get('action');
const tab = searchParams.get('tab') || 'calendar';
const siteFilter = searchParams.get('site') || '';
const showFilter = searchParams.get('show') || '';
const statusFilter = searchParams.get('status') || '';
// ── Event Calendar ──
if (tab === 'calendar' || action === 'calendar') {
let query = supabase
.from('event_calendar')
.select('*')
.order('start_date', { ascending: true, nullsFirst: false });
if (siteFilter) query = query.eq('site_id', parseInt(siteFilter));
const { data, error } = await query;
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
// Enrich with current phase
const enriched = (data || []).map(event => ({
...event,
current_phase: getShowPhase(event),
}));
return NextResponse.json({ events: enriched });
}
// ── Story Queue ──
if (tab === 'queue') {
let query = supabase
.from('auto_story_queue')
.select('*')
.not('event_id', 'is', null)
.order('created_at', { ascending: false });
if (siteFilter) query = query.eq('site_id', parseInt(siteFilter));
if (showFilter) query = query.eq('event_id', showFilter);
if (statusFilter && statusFilter !== 'all') query = query.eq('status', statusFilter);
const { data, error } = await query;
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ queue: data || [] });
}
// ── Exhibitor List ──
if (tab === 'exhibitors') {
let query = supabase
.from('nab_exhibitors')
.select('*, tracked_companies(company_website)')
.order('created_at', { ascending: false });
if (showFilter) query = query.eq('event_id', showFilter);
const { data, error } = await query;
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ exhibitors: data || [] });
}
// ── Style Profiles ──
if (tab === 'style-profiles' || action === 'style-profiles') {
const { data, error } = await supabase
.from('author_style_profiles')
.select('*')
.order('last_updated', { ascending: false });
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ profiles: data || [] });
}
// ── Settings ──
if (action === 'settings') {
const { data } = await supabase
.from('company_tracker_settings')
.select('*')
.limit(1)
.single();
return NextResponse.json({ settings: data || {} });
}
return NextResponse.json({ error: 'Unknown action' }, { status: 400 });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
// ─── POST ─────────────────────────────────────────────────────────────────────
export async function POST(request: NextRequest) {
try {
const supabase = await createClient();
const body = await request.json();
const { action } = body;
// ── Add / Update Event ──
if (action === 'upsert_event') {
const { id, ...eventData } = body;
let result;
if (id) {
result = await supabase
.from('event_calendar')
.update({ ...eventData, updated_at: new Date().toISOString() })
.eq('id', id)
.select()
.single();
} else {
result = await supabase
.from('event_calendar')
.insert(eventData)
.select()
.single();
}
if (result.error) return NextResponse.json({ error: result.error.message }, { status: 500 });
return NextResponse.json({ event: result.data });
}
// ── Toggle Engine Pause ──
if (action === 'toggle_engine') {
const { event_id, paused } = body;
const { error } = await supabase
.from('event_calendar')
.update({ engine_paused: paused, updated_at: new Date().toISOString() })
.eq('id', event_id);
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ success: true });
}
// ── Extract Ryan Salazar Style Profile ──
if (action === 'extract_style') {
const { event_name, author_username = 'ryansalazar' } = body;
// Fetch Ryan's articles mentioning this event
const keywords = event_name.split(' ').filter((w: string) => w.length > 3);
const searchTerm = keywords[0] || event_name;
const { data: articles } = await supabase
.from('native_articles')
.select('title, content, published_at')
.eq('author_name', author_username)
.ilike('content', `%${searchTerm}%`)
.eq('status', 'published')
.order('published_at', { ascending: false })
.limit(10);
const styleGuide = await extractStyleProfile(articles || [], event_name);
if (styleGuide) {
const { data: existing } = await supabase
.from('author_style_profiles')
.select('id')
.eq('author_username', author_username)
.eq('event_name', event_name)
.single();
if (existing?.id) {
await supabase
.from('author_style_profiles')
.update({
style_guide_text: styleGuide,
source_article_count: articles?.length || 0,
last_updated: new Date().toISOString(),
})
.eq('id', existing.id);
} else {
await supabase.from('author_style_profiles').insert({
author_username,
event_type: 'trade_show',
event_name,
style_guide_text: styleGuide,
source_article_count: articles?.length || 0,
});
}
}
return NextResponse.json({
success: true,
articleCount: articles?.length || 0,
styleGuideGenerated: !!styleGuide,
});
}
// ── Generate Show Story ──
if (action === 'generate_story') {
const {
event_id, story_type, company_name, source_content,
preferred_pen_name, site_id = 1,
} = body;
// Fetch event details
const { data: event } = await supabase
.from('event_calendar')
.select('*')
.eq('id', event_id)
.single();
if (!event) return NextResponse.json({ error: 'Event not found' }, { status: 404 });
// Fetch style guide
const { data: styleProfile } = await supabase
.from('author_style_profiles')
.select('style_guide_text')
.eq('event_name', event.event_name)
.limit(1)
.single();
// Get recent pen names to avoid repetition
const { data: recentStories } = await supabase
.from('auto_story_queue')
.select('assigned_pen_name')
.eq('event_id', event_id)
.order('created_at', { ascending: false })
.limit(6);
const recentNames = (recentStories || []).map((s: any) => s.assigned_pen_name).filter(Boolean);
const penName = pickPenNameForShow(site_id, story_type, recentNames, preferred_pen_name);
const penNameData = (site_id === 2 ? AV_PEN_NAMES : BB_PEN_NAMES).find(p => p.name === penName);
const phase = getShowPhase(event);
const siteName = site_id === 2 ? 'AV Beat' : 'Broadcast Beat';
const story = await generateShowStory({
penName,
penTitle: penNameData?.title || 'Correspondent',
siteName,
eventName: event.event_name,
year: event.year,
storyType: story_type,
companyName: company_name,
sourceContent: source_content,
styleGuide: styleProfile?.style_guide_text,
phase,
});
// Insert into queue
const { data: queued, error: queueError } = await supabase
.from('auto_story_queue')
.insert({
company_name: company_name || event.event_name,
story_type: story_type,
assigned_pen_name: penName,
site_id,
event_id,
show_phase: phase,
show_story_type: story_type,
source_show: event.event_name,
status: 'generated',
generated_title: story.title,
generated_content: story.content,
generated_excerpt: story.excerpt,
seo_meta_description: story.metaDescription,
quality_confidence: story.confidence,
})
.select()
.single();
if (queueError) return NextResponse.json({ error: queueError.message }, { status: 500 });
return NextResponse.json({ story: queued, penName, phase });
}
// ── Add Exhibitor ──
if (action === 'add_exhibitor') {
const { company_name, event_id, booth_number, is_av_eligible } = body;
// Find or create tracked company
const { data: existing } = await supabase
.from('tracked_companies')
.select('id')
.eq('company_name', company_name)
.single();
let companyId = existing?.id;
if (!companyId) {
const { data: newCompany } = await supabase
.from('tracked_companies')
.insert({ company_name, auto_story_enabled: true, site_scope: 'both' })
.select('id')
.single();
companyId = newCompany?.id;
}
const { data, error } = await supabase
.from('nab_exhibitors')
.upsert({
company_id: companyId,
company_name,
event_id,
booth_number,
is_av_eligible: is_av_eligible || false,
}, { onConflict: 'company_name,event_id' })
.select()
.single();
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ exhibitor: data });
}
return NextResponse.json({ error: 'Unknown action' }, { status: 400 });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
// ─── PATCH ────────────────────────────────────────────────────────────────────
export async function PATCH(request: NextRequest) {
try {
const supabase = await createClient();
const body = await request.json();
const { action, id } = body;
if (action === 'approve_story') {
const { data: story } = await supabase
.from('auto_story_queue')
.select('*')
.eq('id', id)
.single();
if (!story) return NextResponse.json({ error: 'Story not found' }, { status: 404 });
const slug = (story.generated_title || 'show-story')
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.trim()
.slice(0, 80) + '-' + Date.now();
const { data: published } = await supabase
.from('native_articles')
.insert({
slug,
title: story.generated_title,
excerpt: story.generated_excerpt,
content: story.generated_content,
author_name: story.assigned_pen_name,
status: 'published',
site_id: story.site_id,
published_at: new Date().toISOString(),
})
.select('id')
.single();
await supabase
.from('auto_story_queue')
.update({
status: 'published',
review_status: 'approved',
published_article_id: published?.id,
reviewed_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
})
.eq('id', id);
// Queue translations for all 9 languages
const languages = ['es', 'pt', 'fr', 'de', 'zh', 'ja', 'ar', 'hi'];
const translationJobs = languages.map((lang, idx) => ({
post_id: published?.id,
post_type: 'native',
language_code: lang,
priority: idx < 2 ? 8 : idx < 4 ? 6 : 4, // es/pt first, then fr/de, then rest
status: 'pending',
}));
if (published?.id) {
await supabase.from('translation_queue').insert(translationJobs);
}
return NextResponse.json({ success: true, articleId: published?.id });
}
if (action === 'reject_story') {
const { reason } = body;
await supabase
.from('auto_story_queue')
.update({
status: 'rejected',
review_status: 'rejected',
rejection_reason: reason,
reviewed_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
})
.eq('id', id);
return NextResponse.json({ success: true });
}
if (action === 'update_event_dates') {
const { event_id, start_date, end_date, story_engine_start_date, story_engine_end_date } = body;
await supabase
.from('event_calendar')
.update({
start_date, end_date, story_engine_start_date, story_engine_end_date,
status: 'upcoming', updated_at: new Date().toISOString(),
})
.eq('id', event_id);
return NextResponse.json({ success: true });
}
return NextResponse.json({ error: 'Unknown action' }, { status: 400 });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}