← 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;
}