← back to Dw Boardroom Governance

src/engine/decisionEngine.ts

218 lines

/**
 * Decision Engine — Scoring, dedup, approval logic
 * Ported from:
 *   - /root/DW-Agents/boardroom-agent/routes/api-approvals.js (dedup + decision logging)
 *   - /root/DW-Agents/boardroom-agent/lib/task-reaper.js (0-10 scoring)
 *
 * Scoring:
 *   0-2: VETO (auto-cancel as vague/duplicate/phantom)
 *   3-6: RESOLVE (auto-approve if autonomy_level >= 4)
 *   7-10: ESCALATE (always requires human decision)
 */

import { query, queryOne, execute } from '../db/db';
import { broadcast } from '../ws/broadcast';
import { Decision, DecisionType, DecisionStatus, HumanDecision, SCORE_THRESHOLDS } from '../types';
import { v4 as uuidv4 } from 'uuid';

// ─── Deduplication (ported from api-approvals.js normalizeTitle) ──

function normalizeTitle(title: string): string {
  return (title || '').toLowerCase().replace(/[^a-z0-9\s]/g, '').replace(/\s+/g, ' ').trim();
}

async function isDuplicate(topic: string): Promise<{ duplicate: boolean; reason?: string }> {
  const key = normalizeTitle(topic);
  if (!key || key.length < 3) return { duplicate: false };

  // Check for rejected decisions in last 30 days
  const rejected = await queryOne<Decision>(
    `SELECT id, topic FROM gov_decision_log
     WHERE status = 'rejected'
     AND LOWER(REPLACE(topic, '-', '')) LIKE $1
     AND created_at > NOW() - INTERVAL '30 days'
     LIMIT 1`,
    ['%' + key.substring(0, 20) + '%']
  );
  if (rejected) {
    return { duplicate: true, reason: `Previously rejected: "${rejected.topic}" (#${rejected.id})` };
  }

  // Check for duplicate pending decisions
  const pending = await queryOne<Decision>(
    `SELECT id, topic FROM gov_decision_log
     WHERE status = 'pending'
     AND LOWER(REPLACE(topic, '-', '')) LIKE $1
     LIMIT 1`,
    ['%' + key.substring(0, 20) + '%']
  );
  if (pending) {
    return { duplicate: true, reason: `Already pending: "${pending.topic}" (#${pending.id})` };
  }

  return { duplicate: false };
}

// ─── Scoring (ported from task-reaper.js) ──

function scoreDecision(decision: Partial<Decision>): { score: number; reasoning: string } {
  let score = 5; // Start neutral
  const reasons: string[] = [];

  // Topic quality
  const topicLen = (decision.topic || '').trim().length;
  if (topicLen < 10) {
    score -= 3;
    reasons.push('Topic too vague');
  } else if (topicLen > 20) {
    score += 1;
    reasons.push('Well-described topic');
  }

  // Has recommendation
  if (decision.recommendation && decision.recommendation.length > 10) {
    score += 2;
    reasons.push('Includes recommendation');
  } else {
    score -= 1;
    reasons.push('Missing recommendation');
  }

  // Decision type weighting
  if (decision.decision_type === 'C') {
    score += 1; // Controlled = high importance
    reasons.push('Controlled decision (human-required)');
  } else if (decision.decision_type === 'A') {
    score -= 1; // Autonomous = lower importance
    reasons.push('Autonomous decision');
  }

  // Has deadline
  if (decision.deadline) {
    score += 1;
    reasons.push('Has deadline');
  }

  // Clamp to 0-10
  score = Math.max(0, Math.min(10, score));

  return { score, reasoning: reasons.join('; ') };
}

// ─── CRUD ─────────────────────────────────────────

export async function createDecision(data: {
  topic: string;
  decisionType: DecisionType;
  recommendation?: string;
  owner: string;
  autonomyLevel?: number;
  deadline?: string;
  source?: string;
}): Promise<Decision> {
  // Dedup check
  const dupeCheck = await isDuplicate(data.topic);
  if (dupeCheck.duplicate) {
    throw new Error(`Duplicate: ${dupeCheck.reason}`);
  }

  const id = uuidv4();
  const autonomyLevel = data.autonomyLevel || 1;

  // Score it
  const { score, reasoning } = scoreDecision({
    topic: data.topic,
    recommendation: data.recommendation,
    decision_type: data.decisionType,
    deadline: data.deadline,
  });

  // Auto-approve logic
  let status: DecisionStatus = 'pending';
  if (score <= SCORE_THRESHOLDS.VETO_CEILING) {
    status = 'rejected'; // Auto-veto: too vague
  } else if (data.decisionType === 'A' && autonomyLevel >= 4 && score <= SCORE_THRESHOLDS.RESOLVE_CEILING) {
    status = 'auto_approved'; // Autonomous + high autonomy + moderate score
  }

  await query(
    `INSERT INTO gov_decision_log
     (id, topic, decision_type, recommendation, owner, autonomy_level, deadline, status, source, score, reasoning)
     VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`,
    [id, data.topic, data.decisionType, data.recommendation || null, data.owner,
     autonomyLevel, data.deadline || null, status, data.source || null, score, reasoning]
  );

  const decision = (await queryOne<Decision>('SELECT * FROM gov_decision_log WHERE id = $1', [id]))!;

  broadcast('new_decision', decision);
  console.log(`[Decision] Created #${id} — score=${score} status=${status}`);

  return decision;
}

export async function resolveDecision(
  id: string,
  humanDecision: HumanDecision,
  notes?: string
): Promise<Decision> {
  const statusMap: Record<HumanDecision, DecisionStatus> = {
    approve: 'approved',
    reject: 'rejected',
    modify: 'modified',
    defer: 'deferred',
  };

  await execute(
    `UPDATE gov_decision_log
     SET human_decision = $1, status = $2, notes = COALESCE($3, notes), resolved_at = NOW()
     WHERE id = $4`,
    [humanDecision, statusMap[humanDecision], notes || null, id]
  );

  const decision = await queryOne<Decision>('SELECT * FROM gov_decision_log WHERE id = $1', [id]);
  if (!decision) throw new Error('Decision not found');

  broadcast('decision_resolved', decision);
  console.log(`[Decision] Resolved #${id} → ${humanDecision}`);

  return decision;
}

export async function listDecisions(filters?: {
  status?: string;
  owner?: string;
  limit?: number;
}): Promise<Decision[]> {
  let sql = 'SELECT * FROM gov_decision_log WHERE 1=1';
  const params: any[] = [];
  let idx = 1;

  if (filters?.status) {
    sql += ` AND status = $${idx++}`;
    params.push(filters.status);
  }
  if (filters?.owner) {
    sql += ` AND owner = $${idx++}`;
    params.push(filters.owner);
  }

  sql += ` ORDER BY created_at DESC LIMIT $${idx}`;
  params.push(filters?.limit || 50);

  return query<Decision>(sql, params);
}

export async function getDecision(id: string): Promise<Decision | null> {
  return queryOne<Decision>('SELECT * FROM gov_decision_log WHERE id = $1', [id]);
}

export async function getDecisionStats(): Promise<Record<string, number>> {
  const rows = await query<{ status: string; count: string }>(
    'SELECT status, COUNT(*) as count FROM gov_decision_log GROUP BY status'
  );
  const stats: Record<string, number> = {};
  rows.forEach(r => { stats[r.status] = parseInt(r.count); });
  return stats;
}