mission-control/lib/services/proposal.service.ts

738 lines
22 KiB
TypeScript

/**
* Voxyz Proposal Service
* Core service for mission proposal creation, validation, and management
* Phase 10: Autonomous Architecture
*/
import {
MissionProposal,
MissionProposalInsert,
CapGateCheckResult,
CapGateViolation,
AIAnalysisSummary,
ProposalStatus,
ProposalServiceConfig
} from '@/lib/supabase/voxyz.types';
import { createClient } from '@/lib/supabase/client';
import { Task } from '@/lib/data/tasks';
import { Project } from '@/lib/data/projects';
const DEFAULT_CONFIG: ProposalServiceConfig = {
maxProposalsPerDay: 3,
minProposalsPerDay: 1,
defaultProposalExpiryHours: 24,
priorityScoreThreshold: 50,
autoApproveThreshold: 85,
};
/**
* Priority scoring factors and their weights
*/
const PRIORITY_FACTORS = {
deadlineUrgency: 0.30,
taskPriority: 0.25,
projectImportance: 0.20,
blockedStatus: 0.15,
staleTask: 0.10,
};
/**
* Calculate priority score for a proposal
*/
export function calculatePriorityScore(
task: Task,
project: Project | undefined,
context: ProposalContext
): number {
let score = 50; // Base score
const factors: { factor: string; weight: number; contribution: number }[] = [];
// 1. Deadline urgency (30%)
const deadlineScore = calculateDeadlineScore(task, context);
const deadlineContribution = deadlineScore * PRIORITY_FACTORS.deadlineUrgency;
score += deadlineContribution;
factors.push({ factor: 'deadline_urgency', weight: PRIORITY_FACTORS.deadlineUrgency, contribution: deadlineContribution });
// 2. Task priority (25%)
const priorityScore = calculateTaskPriorityScore(task);
const priorityContribution = priorityScore * PRIORITY_FACTORS.taskPriority;
score += priorityContribution;
factors.push({ factor: 'task_priority', weight: PRIORITY_FACTORS.taskPriority, contribution: priorityContribution });
// 3. Project importance (20%)
const projectScore = calculateProjectImportanceScore(project);
const projectContribution = projectScore * PRIORITY_FACTORS.projectImportance;
score += projectContribution;
factors.push({ factor: 'project_importance', weight: PRIORITY_FACTORS.projectImportance, contribution: projectContribution });
// 4. Blocked status (15%)
const blockedScore = calculateBlockedScore(task, context);
const blockedContribution = blockedScore * PRIORITY_FACTORS.blockedStatus;
score += blockedContribution;
factors.push({ factor: 'blocked_status', weight: PRIORITY_FACTORS.blockedStatus, contribution: blockedContribution });
// 5. Stale task (10%)
const staleScore = calculateStaleScore(task, context);
const staleContribution = staleScore * PRIORITY_FACTORS.staleTask;
score += staleContribution;
factors.push({ factor: 'stale_task', weight: PRIORITY_FACTORS.staleTask, contribution: staleContribution });
return Math.min(100, Math.max(0, Math.round(score)));
}
/**
* Calculate deadline urgency score (0-100)
*/
function calculateDeadlineScore(task: Task, context: ProposalContext): number {
if (!task.dueDate) return 0;
const dueDate = new Date(task.dueDate);
const now = context.currentTime;
const hoursUntilDue = (dueDate.getTime() - now.getTime()) / (1000 * 60 * 60);
if (hoursUntilDue < 0) return 100; // Overdue
if (hoursUntilDue < 24) return 90; // Due within 24 hours
if (hoursUntilDue < 72) return 70; // Due within 3 days
if (hoursUntilDue < 168) return 50; // Due within a week
if (hoursUntilDue < 336) return 30; // Due within 2 weeks
return 10; // Due later
}
/**
* Calculate task priority score (0-100)
*/
function calculateTaskPriorityScore(task: Task): number {
const priorityMap: Record<string, number> = {
'urgent': 100,
'high': 75,
'medium': 50,
'low': 25,
};
return priorityMap[task.priority] || 50;
}
/**
* Calculate project importance score (0-100)
*/
function calculateProjectImportanceScore(project: Project | undefined): number {
if (!project) return 50;
// Check for high-priority keywords in project
const highPriorityKeywords = ['ios', 'revenue', 'critical', 'milestone'];
const projectNameLower = project.name.toLowerCase();
for (const keyword of highPriorityKeywords) {
if (projectNameLower.includes(keyword)) {
return 80;
}
}
return 50;
}
/**
* Calculate blocked status score (0-100)
*/
function calculateBlockedScore(task: Task, context: ProposalContext): number {
if (task.status === 'blocked') {
// Check how long it's been blocked
const blockedHours = task.comments?.reduce((hours, comment) => {
if (comment.text?.toLowerCase().includes('blocked')) {
const commentTime = new Date(comment.createdAt);
const blockedHours = (context.currentTime.getTime() - commentTime.getTime()) / (1000 * 60 * 60);
return Math.max(hours, blockedHours);
}
return hours;
}, 0) || 0;
if (blockedHours > 48) return 100;
if (blockedHours > 24) return 80;
return 60;
}
return 0;
}
/**
* Calculate stale task score (0-100)
*/
function calculateStaleScore(task: Task, context: ProposalContext): number {
const lastUpdate = new Date(task.updatedAt);
const daysSinceUpdate = (context.currentTime.getTime() - lastUpdate.getTime()) / (1000 * 60 * 60 * 24);
if (task.status === 'in-progress' || task.status === 'todo') {
if (daysSinceUpdate > 14) return 100;
if (daysSinceUpdate > 7) return 75;
if (daysSinceUpdate > 3) return 50;
}
return 0;
}
/**
* Generate AI analysis summary for a proposal
*/
export function generateAIAnalysis(
task: Task,
project: Project | undefined,
priorityScore: number,
factors: { factor: string; weight: number; contribution: number }[]
): AIAnalysisSummary {
const urgencyScore = calculateDeadlineScore(task, { currentTime: new Date() });
const impactScore = Math.round((priorityScore + urgencyScore) / 2);
// Determine effort estimate based on task complexity
const effortEstimate = estimateEffort(task);
// Generate reasoning
const reasoning = generateReasoning(task, project, factors);
// Identify risks
const risks = identifyRisks(task, project);
// Identify opportunities
const opportunities = identifyOpportunities(task, project);
return {
priority_factors: factors,
urgency_score: urgencyScore,
impact_score: impactScore,
effort_estimate: effortEstimate,
confidence: calculateConfidence(task, factors),
reasoning,
risks,
opportunities,
};
}
/**
* Estimate effort required for a task
*/
function estimateEffort(task: Task): 'low' | 'medium' | 'high' {
const description = (task.description || '').toLowerCase();
const title = task.title.toLowerCase();
// High effort indicators
const highEffortKeywords = ['refactor', 'architecture', 'redesign', 'migration', 'integrate', 'implement'];
for (const keyword of highEffortKeywords) {
if (description.includes(keyword) || title.includes(keyword)) {
return 'high';
}
}
// Medium effort indicators
const mediumEffortKeywords = ['update', 'modify', 'enhance', 'improve', 'fix', 'add'];
for (const keyword of mediumEffortKeywords) {
if (description.includes(keyword) || title.includes(keyword)) {
return 'medium';
}
}
return 'low';
}
/**
* Generate reasoning for the proposal
*/
function generateReasoning(
task: Task,
project: Project | undefined,
factors: { factor: string; weight: number; contribution: number }[]
): string {
const parts: string[] = [];
// Sort factors by contribution
const sortedFactors = [...factors].sort((a, b) => b.contribution - a.contribution);
// Add top factor
const topFactor = sortedFactors[0];
if (topFactor) {
parts.push(`Primary driver: ${topFactor.factor.replace('_', ' ')} (+${Math.round(topFactor.contribution)} points)`);
}
// Add deadline context
if (task.dueDate) {
const dueDate = new Date(task.dueDate);
const daysUntil = Math.ceil((dueDate.getTime() - Date.now()) / (1000 * 60 * 60 * 24));
if (daysUntil <= 3) {
parts.push(`Urgent: Due in ${daysUntil} day${daysUntil !== 1 ? 's' : ''}`);
}
}
// Add project context
if (project) {
parts.push(`Part of ${project.name}`);
}
return parts.join('. ');
}
/**
* Identify risks for the task
*/
function identifyRisks(task: Task, project: Project | undefined): string[] {
const risks: string[] = [];
if (task.status === 'blocked') {
risks.push('Task is currently blocked');
}
if (task.dueDate) {
const dueDate = new Date(task.dueDate);
const daysUntil = Math.ceil((dueDate.getTime() - Date.now()) / (1000 * 60 * 60 * 24));
if (daysUntil < 0) {
risks.push('Task is overdue');
} else if (daysUntil <= 2) {
risks.push('Very tight deadline');
}
}
if (task.priority === 'urgent') {
risks.push('High priority task may have dependencies');
}
return risks;
}
/**
* Identify opportunities for the task
*/
function identifyOpportunities(task: Task, project: Project | undefined): string[] {
const opportunities: string[] = [];
if (project?.name.toLowerCase().includes('ios')) {
opportunities.push('Contributes to iOS portfolio growth');
}
if (task.tags.some(tag => tag.toLowerCase().includes('revenue'))) {
opportunities.push('May impact revenue directly');
}
if (task.tags.some(tag => tag.toLowerCase().includes('milestone'))) {
opportunities.push('Advances key milestone');
}
return opportunities;
}
/**
* Calculate confidence in the proposal
*/
function calculateConfidence(task: Task, factors: { factor: string; weight: number; contribution: number }[]): number {
let confidence = 70; // Base confidence
// Boost confidence if we have clear signals
const hasDeadline = !!task.dueDate;
const hasClearPriority = task.priority === 'urgent' || task.priority === 'high';
const hasDescription = (task.description || '').length > 50;
if (hasDeadline) confidence += 10;
if (hasClearPriority) confidence += 10;
if (hasDescription) confidence += 5;
// Reduce confidence if factors are contradictory
const factorValues = factors.map(f => f.contribution);
const variance = calculateVariance(factorValues);
if (variance > 400) confidence -= 10; // High variance = conflicting signals
return Math.min(95, Math.max(50, confidence));
}
/**
* Calculate variance of an array
*/
function calculateVariance(values: number[]): number {
if (values.length === 0) return 0;
const mean = values.reduce((a, b) => a + b, 0) / values.length;
const squaredDiffs = values.map(v => Math.pow(v - mean, 2));
return squaredDiffs.reduce((a, b) => a + b, 0) / values.length;
}
/**
* Create a mission proposal from a task
*/
export function createProposalFromTask(
task: Task,
project: Project | undefined,
context: ProposalContext,
config: ProposalServiceConfig = DEFAULT_CONFIG
): MissionProposalInsert {
const priorityScore = calculatePriorityScore(task, project, context);
const factors = getPriorityFactors(task, project, context);
const aiAnalysis = generateAIAnalysis(task, project, priorityScore, factors);
// Estimate duration based on effort
const durationMap: Record<string, number> = {
'low': 60,
'medium': 180,
'high': 360,
};
const estimatedDuration = durationMap[aiAnalysis.effort_estimate || 'medium'] || 120;
// Calculate suggested time slot
const suggestedStartTime = calculateSuggestedStartTime(context, estimatedDuration);
const suggestedEndTime = suggestedStartTime
? new Date(suggestedStartTime.getTime() + estimatedDuration * 60000)
: null;
// Generate rationale
const rationale = aiAnalysis.reasoning || `High priority task: ${task.title}`;
return {
proposal_date: context.currentTime.toISOString().split('T')[0],
status: 'pending',
priority_score: priorityScore,
passes_cap_gates: false, // Will be validated later
cap_gate_violations: [],
source_task_ids: [task.id],
source_project_ids: project ? [project.id] : [],
title: task.title,
description: task.description,
rationale,
ai_analysis: aiAnalysis,
suggested_start_time: suggestedStartTime?.toISOString() || null,
suggested_end_time: suggestedEndTime?.toISOString() || null,
estimated_duration_minutes: estimatedDuration,
expires_at: new Date(context.currentTime.getTime() + config.defaultProposalExpiryHours * 60 * 60 * 1000).toISOString(),
};
}
/**
* Get priority factors for a task
*/
function getPriorityFactors(
task: Task,
project: Project | undefined,
context: ProposalContext
): { factor: string; weight: number; contribution: number }[] {
const factors: { factor: string; weight: number; contribution: number }[] = [];
const deadlineScore = calculateDeadlineScore(task, context);
factors.push({ factor: 'deadline_urgency', weight: PRIORITY_FACTORS.deadlineUrgency, contribution: deadlineScore * PRIORITY_FACTORS.deadlineUrgency });
const priorityScore = calculateTaskPriorityScore(task);
factors.push({ factor: 'task_priority', weight: PRIORITY_FACTORS.taskPriority, contribution: priorityScore * PRIORITY_FACTORS.taskPriority });
const projectScore = calculateProjectImportanceScore(project);
factors.push({ factor: 'project_importance', weight: PRIORITY_FACTORS.projectImportance, contribution: projectScore * PRIORITY_FACTORS.projectImportance });
const blockedScore = calculateBlockedScore(task, context);
factors.push({ factor: 'blocked_status', weight: PRIORITY_FACTORS.blockedStatus, contribution: blockedScore * PRIORITY_FACTORS.blockedStatus });
const staleScore = calculateStaleScore(task, context);
factors.push({ factor: 'stale_task', weight: PRIORITY_FACTORS.staleTask, contribution: staleScore * PRIORITY_FACTORS.staleTask });
return factors;
}
/**
* Calculate suggested start time for a proposal
*/
function calculateSuggestedStartTime(context: ProposalContext, durationMinutes: number): Date | null {
// Start with tomorrow at 9 AM
const tomorrow = new Date(context.currentTime);
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(9, 0, 0, 0);
// TODO: Consider calendar availability from context
return tomorrow;
}
/**
* Proposal context for calculations
*/
export interface ProposalContext {
currentTime: Date;
activeTasks?: Task[];
recentProposals?: MissionProposal[];
calendarAvailability?: unknown[];
}
/**
* Validate a proposal against cap gates
*/
export async function validateProposalAgainstCapGates(
proposal: MissionProposalInsert,
existingProposals: MissionProposal[],
config: ProposalServiceConfig = DEFAULT_CONFIG
): Promise<CapGateCheckResult> {
const violations: CapGateViolation[] = [];
const checks: CapGateCheckResult['checks'] = [];
// Check 1: Daily mission limit
const todayProposals = existingProposals.filter(p =>
p.proposal_date === proposal.proposal_date &&
p.status !== 'rejected' &&
p.status !== 'expired'
);
const dailyLimitGate = {
gate: {
id: 'daily_limit',
name: 'Daily Mission Limit',
description: 'Maximum number of missions per day',
gate_type: 'daily_mission_limit' as const,
max_value: config.maxProposalsPerDay,
min_value: config.minProposalsPerDay,
unit: 'count',
current_value: todayProposals.length,
last_evaluated_at: new Date().toISOString(),
policy_id: null,
is_active: true,
is_blocking: false,
evaluation_window_hours: 24,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
created_by: null,
updated_by: null,
},
passed: todayProposals.length < config.maxProposalsPerDay,
currentValue: todayProposals.length,
};
if (!dailyLimitGate.passed) {
dailyLimitGate.gate.is_blocking = true;
violations.push({
gate_name: dailyLimitGate.gate.name,
gate_type: dailyLimitGate.gate.gate_type,
current_value: dailyLimitGate.currentValue,
max_value: config.maxProposalsPerDay,
violation_message: `Daily mission limit of ${config.maxProposalsPerDay} reached`,
});
}
checks.push(dailyLimitGate);
// Check 2: Workload capacity
const totalWorkload = todayProposals.reduce((sum, p) =>
sum + (p.estimated_duration_minutes || 120), 0
);
const proposedWorkload = totalWorkload + (proposal.estimated_duration_minutes || 120);
const maxWorkloadMinutes = 480; // 8 hours
const workloadGate = {
gate: {
id: 'workload_capacity',
name: 'Workload Capacity',
description: 'Daily workload capacity in minutes',
gate_type: 'workload_capacity' as const,
max_value: maxWorkloadMinutes,
min_value: 0,
unit: 'minutes',
current_value: proposedWorkload,
last_evaluated_at: new Date().toISOString(),
policy_id: null,
is_active: true,
is_blocking: false,
evaluation_window_hours: 24,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
created_by: null,
updated_by: null,
},
passed: proposedWorkload <= maxWorkloadMinutes,
currentValue: proposedWorkload,
};
if (!workloadGate.passed) {
workloadGate.gate.is_blocking = true;
violations.push({
gate_name: workloadGate.gate.name,
gate_type: workloadGate.gate.gate_type,
current_value: proposedWorkload,
max_value: maxWorkloadMinutes,
violation_message: `Workload would exceed ${maxWorkloadMinutes} minutes (${Math.round(proposedWorkload)} proposed)`,
});
}
checks.push(workloadGate);
// Check 3: Priority threshold
if (proposal.priority_score < config.priorityScoreThreshold) {
violations.push({
gate_name: 'Priority Threshold',
gate_type: 'prioritization',
current_value: proposal.priority_score,
max_value: config.priorityScoreThreshold,
violation_message: `Priority score ${proposal.priority_score} below threshold ${config.priorityScoreThreshold}`,
});
}
return {
allPassed: violations.length === 0,
checks,
violations,
summary: violations.length === 0
? 'All cap gates passed'
: `${violations.length} cap gate violation${violations.length !== 1 ? 's' : ''}`,
};
}
/**
* Save a proposal to the database
*/
export async function saveProposal(
proposal: MissionProposalInsert,
capGateResult: CapGateCheckResult
): Promise<MissionProposal | null> {
const supabase = createClient();
const proposalWithCapGates: MissionProposalInsert = {
...proposal,
passes_cap_gates: capGateResult.allPassed,
cap_gate_violations: capGateResult.violations,
};
const { data, error } = await supabase
.from('ops_mission_proposals')
.insert(proposalWithCapGates)
.select()
.single();
if (error) {
console.error('Error saving proposal:', error);
return null;
}
return data as MissionProposal;
}
/**
* Get proposals for a specific date
*/
export async function getProposalsForDate(date: Date): Promise<MissionProposal[]> {
const supabase = createClient();
const dateString = date.toISOString().split('T')[0];
const { data, error } = await supabase
.from('ops_mission_proposals')
.select('*')
.eq('proposal_date', dateString)
.order('priority_score', { ascending: false });
if (error) {
console.error('Error fetching proposals:', error);
return [];
}
return (data || []) as MissionProposal[];
}
/**
* Approve a proposal
*/
export async function approveProposal(
proposalId: string,
userId: string
): Promise<boolean> {
const supabase = createClient();
const { error } = await supabase
.from('ops_mission_proposals')
.update({
status: 'approved',
approved_at: new Date().toISOString(),
approved_by: userId,
})
.eq('id', proposalId);
if (error) {
console.error('Error approving proposal:', error);
return false;
}
return true;
}
/**
* Reject a proposal
*/
export async function rejectProposal(
proposalId: string,
reason: string
): Promise<boolean> {
const supabase = createClient();
const { error } = await supabase
.from('ops_mission_proposals')
.update({
status: 'rejected',
rejected_at: new Date().toISOString(),
rejection_reason: reason,
})
.eq('id', proposalId);
if (error) {
console.error('Error rejecting proposal:', error);
return false;
}
return true;
}
/**
* Mark proposal as completed
*/
export async function completeProposal(
proposalId: string,
notes?: string
): Promise<boolean> {
const supabase = createClient();
const { error } = await supabase
.from('ops_mission_proposals')
.update({
status: 'completed',
executed_at: new Date().toISOString(),
execution_notes: notes || null,
})
.eq('id', proposalId);
if (error) {
console.error('Error completing proposal:', error);
return false;
}
return true;
}
/**
* Get top proposals for today
*/
export async function getTopProposalsForToday(limit: number = 3): Promise<MissionProposal[]> {
const today = new Date();
const proposals = await getProposalsForDate(today);
// Filter to pending and approved proposals
const activeProposals = proposals.filter(p =>
p.status === 'pending' || p.status === 'approved'
);
// Sort by priority score
activeProposals.sort((a, b) => b.priority_score - a.priority_score);
return activeProposals.slice(0, limit);
}
/**
* Expire old proposals
*/
export async function expireOldProposals(): Promise<number> {
const supabase = createClient();
const now = new Date().toISOString();
const { data, error } = await supabase
.from('ops_mission_proposals')
.update({ status: 'expired' })
.lt('expires_at', now)
.eq('status', 'pending')
.select('id');
if (error) {
console.error('Error expiring proposals:', error);
return 0;
}
return data?.length || 0;
}