OverviewFilesCommitsIssuesDocsReleasesPeers

knowledge-ratchet / atomspace / review-rule-candidates.js

146 lines · javascript ref:
#!/usr/bin/env node

/**
 * review-rule-candidates.js — Interactive review of proposed rules
 *
 * Reads data/rule-candidates.jsonl (written by stream-correction on corrections).
 * For each pending candidate: show symptom, trigger files, suggested rule.
 * User accepts, refines, or rejects.
 * Accepted rules are appended to data/learned-rules.json (or stored as atoms).
 *
 * Usage:
 *   node atomspace/review-rule-candidates.js           # review all pending
 *   node atomspace/review-rule-candidates.js --list    # list without reviewing
 *   node atomspace/review-rule-candidates.js --clear   # clear all pending (reject all)
 */

const fs = require('fs')
const path = require('path')
const readline = require('readline')

const DATA_DIR = path.join(__dirname, '..', 'data')
const CANDIDATES_FILE = path.join(DATA_DIR, 'rule-candidates.jsonl')
const LEARNED_RULES_FILE = path.join(DATA_DIR, 'learned-rules.json')
const ARCHIVE_FILE = path.join(DATA_DIR, 'rule-candidates-archive.jsonl')

function readCandidates () {
  if (!fs.existsSync(CANDIDATES_FILE)) return []
  const content = fs.readFileSync(CANDIDATES_FILE, 'utf8')
  return content.trim().split('\n').filter(Boolean).map(l => {
    try { return JSON.parse(l) } catch { return null }
  }).filter(Boolean)
}

function writeCandidates (candidates) {
  fs.writeFileSync(CANDIDATES_FILE, candidates.map(c => JSON.stringify(c)).join('\n') + (candidates.length > 0 ? '\n' : ''))
}

function archive (candidate) {
  fs.appendFileSync(ARCHIVE_FILE, JSON.stringify(candidate) + '\n')
}

function promptUser (prompt) {
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
  return new Promise(resolve => rl.question(prompt, answer => {
    rl.close()
    resolve(answer)
  }))
}

async function main () {
  const args = process.argv.slice(2)
  const candidates = readCandidates()

  if (candidates.length === 0) {
    console.log('No pending rule candidates.')
    return
  }

  if (args.includes('--list')) {
    console.log(`${candidates.length} pending candidates:\n`)
    for (const c of candidates) {
      console.log(`  [${c.timestamp.slice(0, 19)}]`)
      console.log(`    symptom: ${c.symptom.slice(0, 120)}`)
      console.log(`    files: ${c.triggerFiles.slice(0, 3).join(', ')}`)
      console.log(`    suggested: ${c.suggestedRule}`)
      console.log()
    }
    return
  }

  if (args.includes('--clear')) {
    console.log(`Rejecting all ${candidates.length} pending candidates...`)
    for (const c of candidates) {
      archive({ ...c, status: 'rejected-bulk', reviewedAt: new Date().toISOString() })
    }
    writeCandidates([])
    console.log('Done.')
    return
  }

  console.log(`Reviewing ${candidates.length} pending rule candidates.`)
  console.log('Commands: [a]ccept  [r]eject  [e]dit  [s]kip  [q]uit\n')

  const remaining = []
  for (let i = 0; i < candidates.length; i++) {
    const c = candidates[i]
    console.log(`─── Candidate ${i + 1} of ${candidates.length} ───`)
    console.log(`Timestamp: ${c.timestamp}`)
    console.log(`Symptom:   ${c.symptom}`)
    console.log(`Files:     ${c.triggerFiles.join(', ')}`)
    console.log(`Suggested: ${c.suggestedRule}`)
    console.log()

    const choice = (await promptUser('Action [a/r/e/s/q]: ')).trim().toLowerCase()

    if (choice === 'q') {
      remaining.push(...candidates.slice(i))
      break
    } else if (choice === 'a') {
      archive({ ...c, status: 'accepted', reviewedAt: new Date().toISOString() })
      // Append to learned-rules.json
      try {
        const rules = fs.existsSync(LEARNED_RULES_FILE)
          ? JSON.parse(fs.readFileSync(LEARNED_RULES_FILE, 'utf8'))
          : { rules: [] }
        const list = Array.isArray(rules) ? rules : (rules.rules || [])
        list.push({
          id: `rule-user-${Date.now().toString(36)}`,
          source: 'user-accepted-candidate',
          trigger: { files: c.triggerFiles, symptom: c.symptom },
          action: { description: c.suggestedRule },
          quality: 'specific',
          reviewedAt: new Date().toISOString()
        })
        if (Array.isArray(rules)) fs.writeFileSync(LEARNED_RULES_FILE, JSON.stringify(list, null, 2))
        else { rules.rules = list; rules.totalRules = list.length; fs.writeFileSync(LEARNED_RULES_FILE, JSON.stringify(rules, null, 2)) }
        console.log('✓ Accepted and added to learned-rules.json\n')
      } catch (e) {
        console.log('✗ Failed to persist: ' + e.message + '\n')
      }
    } else if (choice === 'r') {
      archive({ ...c, status: 'rejected', reviewedAt: new Date().toISOString() })
      console.log('✗ Rejected\n')
    } else if (choice === 'e') {
      const newRule = await promptUser('Refined rule: ')
      if (newRule.trim()) {
        archive({ ...c, suggestedRule: newRule, status: 'accepted-edited', reviewedAt: new Date().toISOString() })
        console.log('✎ Accepted with edits\n')
      } else {
        remaining.push(c)
        console.log('(skipped — no edit entered)\n')
      }
    } else {
      remaining.push(c)  // skip/unknown -> stays in queue
      console.log('(skipped)\n')
    }
  }

  writeCandidates(remaining)
  console.log(`Review complete. ${remaining.length} candidates remain in queue.`)
}

if (require.main === module) {
  main().catch(err => { console.error(err.message); process.exit(1) })
}