Spaces:
Running
Running
| /** | |
| * UnifiedRuleBlock β renders ALL rules as ONE continuous JSON code block. | |
| * | |
| * The entire rules array is displayed as a single [...] JSON with: | |
| * - Continuous line numbers across all rules | |
| * - Copy button to copy the clean JSON | |
| * - Inline suggestion blocks for flagged rules: | |
| * β’ Red-highlighted lines on differing fields | |
| * β’ Green suggestion preview lines (with line numbers) | |
| * β’ Accept / Edit / Keep buttons | |
| * - When a suggestion is acted on, the suggestion lines disappear | |
| * - Edit mode shows an inline textarea for the specific rule | |
| */ | |
| import React, { useState, useMemo, useCallback } from 'react' | |
| // ββ Helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const STANDARD_ACTIONS = [ | |
| 'AUTO_APPROVE', | |
| 'ROUTE_TO_AP_CLERK', | |
| 'ROUTE_TO_DEPT_HEAD', | |
| 'ESCALATE_TO_FINANCE_CONTROLLER', | |
| 'ESCALATE_TO_CFO', | |
| 'HOLD', | |
| 'REJECT', | |
| 'FLAG', | |
| 'ROUTE_TO_PROCUREMENT', | |
| 'COMPLIANCE_HOLD', | |
| ] | |
| function valuesMatch(a, b) { | |
| return JSON.stringify(a) === JSON.stringify(b) | |
| } | |
| function getDiffKeys(rule, fix) { | |
| if (!fix) return new Set() | |
| const skip = new Set(['conflict_with', 'suggested_fix', 'review_status', 'section']) | |
| const allKeys = new Set([...Object.keys(rule), ...Object.keys(fix)]) | |
| const diffs = new Set() | |
| for (const k of allKeys) { | |
| if (skip.has(k)) continue | |
| if (!valuesMatch(rule[k], fix[k])) diffs.add(k) | |
| } | |
| return diffs | |
| } | |
| function isFlaggedRule(rule) { | |
| return rule.confidence_score < 0.9 || (rule.conflict_with?.length > 0) | |
| } | |
| function isResolvedRule(rule) { | |
| return ['accepted', 'modified', 'kept_original'].includes(rule.review_status) | |
| } | |
| function getConflictExplanation(rule, conflicts) { | |
| if (!conflicts) return '' | |
| return conflicts | |
| .filter((c) => c.rule_id_b === rule.rule_id || c.rule_id_a === rule.rule_id) | |
| .map((c) => c.explanation) | |
| .join(' ') | |
| } | |
| function syntaxHighlight(text) { | |
| let s = text | |
| .replace(/&/g, '&') | |
| .replace(/</, '<') | |
| .replace(/>/, '>') | |
| // JSON key β cyan | |
| s = s.replace( | |
| /("(?:[^"\\]|\\.)*")\s*:/g, | |
| '<span style="color:#67e8f9">$1</span>:' | |
| ) | |
| // String value β green | |
| s = s.replace( | |
| /:\s*("(?:[^"\\]|\\.)*")/g, | |
| (match, val) => match.replace(val, `<span style="color:#86efac">${val}</span>`) | |
| ) | |
| // Standalone strings in arrays | |
| s = s.replace( | |
| /(?<=[\[,]\s*)("(?:[^"\\]|\\.)*")/g, | |
| '<span style="color:#86efac">$1</span>' | |
| ) | |
| // Numbers β amber | |
| s = s.replace( | |
| /:\s*(-?\d+\.?\d*)/g, | |
| (match, num) => match.replace(num, `<span style="color:#fbbf24">${num}</span>`) | |
| ) | |
| // Booleans / null β purple | |
| s = s.replace( | |
| /\b(true|false|null)\b/g, | |
| '<span style="color:#c084fc">$1</span>' | |
| ) | |
| return s | |
| } | |
| // ββ Main Component ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| export default function UnifiedRuleBlock({ rules, conflicts, onUpdate }) { | |
| const [editingRuleId, setEditingRuleId] = useState(null) | |
| const [editJson, setEditJson] = useState('') | |
| const [editError, setEditError] = useState('') | |
| const [copied, setCopied] = useState(false) | |
| const [selectedFixActions, setSelectedFixActions] = useState({}) | |
| // ββ Copyable clean JSON (entire rules array) ββββββββββββββββββββββββββ | |
| const copyText = useMemo(() => JSON.stringify(rules, null, 2), [rules]) | |
| // ββ Build rendering items βββββββββββββββββββββββββββββββββββββββββββββ | |
| // Each item has a `type`: | |
| // 'line' β numbered code line (rule JSON or suggestion preview) | |
| // 'banner' β suggestion explanation (no line number) | |
| // 'actions' β Accept / Edit / Keep buttons (no line number) | |
| // 'edit-zone' β inline textarea for the rule being edited | |
| const renderItems = useMemo(() => { | |
| const items = [] | |
| let lineNum = 0 | |
| // Opening bracket | |
| items.push({ type: 'line', num: ++lineNum, text: '[', hl: false, sug: false }) | |
| rules.forEach((rule, ruleIdx) => { | |
| // ββ Edit zone placeholder βββββββββββββββββββββββββββββββββββββββ | |
| if (editingRuleId === rule.rule_id) { | |
| items.push({ type: 'edit-zone', ruleId: rule.rule_id }) | |
| return | |
| } | |
| // ββ Permanent rule header: always shows rule_id + live confidence badge β | |
| // This row is NEVER removed β even after Accept/Edit/Keep β so the user | |
| // can see the confidence score climb after taking action. | |
| items.push({ | |
| type: 'rule-header', | |
| ruleId: rule.rule_id, | |
| confidenceScore: rule.confidence_score ?? 0, | |
| reviewStatus: rule.review_status, | |
| }) | |
| // ββ Build annotated lines for this rule βββββββββββββββββββββββββ | |
| const ruleJson = JSON.stringify(rule, null, 2) | |
| const ruleLines = ruleJson.split('\n') | |
| const flagged = isFlaggedRule(rule) | |
| const resolved = isResolvedRule(rule) | |
| const diffKeys = getDiffKeys(rule, rule.suggested_fix) | |
| let currentTopKey = null | |
| ruleLines.forEach((line, lineIdx) => { | |
| // Track top-level key (2-space indent = top-level inside object) | |
| const topKeyMatch = line.match(/^ "([^"]+)"/) | |
| if (topKeyMatch) currentTopKey = topKeyMatch[1] | |
| const trimmed = line.trim() | |
| if (trimmed === '{' || trimmed === '}') currentTopKey = null | |
| // Indent by 2 for array nesting + comma between rules | |
| let text = ' ' + line | |
| if (lineIdx === ruleLines.length - 1 && ruleIdx < rules.length - 1) { | |
| text += ',' | |
| } | |
| const highlighted = | |
| flagged && !resolved && currentTopKey != null && diffKeys.has(currentTopKey) | |
| items.push({ type: 'line', num: ++lineNum, text, hl: highlighted, sug: false }) | |
| }) | |
| // ββ Inline suggestion (flagged + unresolved) ββββββββββββββββββββ | |
| if (flagged && !resolved) { | |
| const explanation = getConflictExplanation(rule, conflicts) | |
| let previewText = null | |
| if (rule.suggested_fix && diffKeys.size > 0) { | |
| const previewObj = {} | |
| for (const k of diffKeys) previewObj[k] = rule.suggested_fix[k] | |
| previewText = JSON.stringify(previewObj, null, 2) | |
| } | |
| // Banner (explanation only β confidence badge is now in the permanent header above) | |
| items.push({ | |
| type: 'banner', | |
| ruleId: rule.rule_id, | |
| explanation: | |
| explanation || | |
| (rule.confidence_score < 0.9 | |
| ? `Low confidence (${Math.round(rule.confidence_score * 100)}%). Review condition and action for correctness.` | |
| : ''), | |
| }) | |
| // Suggestion preview lines (green bg, numbered) | |
| if (previewText) { | |
| previewText.split('\n').forEach((sLine) => { | |
| items.push({ | |
| type: 'line', | |
| num: ++lineNum, | |
| text: ' ' + sLine, | |
| hl: false, | |
| sug: true, | |
| }) | |
| }) | |
| } | |
| // Action buttons | |
| const fixAction = rule.suggested_fix?.action | |
| const isCustomAction = fixAction && !STANDARD_ACTIONS.includes(fixAction) | |
| items.push({ | |
| type: 'actions', | |
| ruleId: rule.rule_id, | |
| hasFix: !!rule.suggested_fix, | |
| isCustomAction, | |
| fixAction, | |
| }) | |
| } | |
| }) | |
| // Closing bracket | |
| items.push({ type: 'line', num: ++lineNum, text: ']', hl: false, sug: false }) | |
| return items | |
| }, [rules, conflicts, editingRuleId]) | |
| // ββ Handlers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const handleCopy = useCallback(async () => { | |
| try { | |
| await navigator.clipboard.writeText(copyText) | |
| } catch { | |
| const ta = document.createElement('textarea') | |
| ta.value = copyText | |
| document.body.appendChild(ta) | |
| ta.select() | |
| document.execCommand('copy') | |
| document.body.removeChild(ta) | |
| } | |
| setCopied(true) | |
| setTimeout(() => setCopied(false), 2000) | |
| }, [copyText]) | |
| const handleAccept = useCallback( | |
| (ruleId) => { | |
| const rule = rules.find((r) => r.rule_id === ruleId) | |
| if (!rule?.suggested_fix) return | |
| let finalAction = rule.suggested_fix.action | |
| const isCustomAction = finalAction && !STANDARD_ACTIONS.includes(finalAction) | |
| if (isCustomAction) { | |
| if (!selectedFixActions[ruleId]) return // MUST select something | |
| finalAction = selectedFixActions[ruleId] | |
| } | |
| // Merge: preserve current fields, override with suggested_fix, clear fix. | |
| // A human accepted it, so it is now 100% verified. | |
| onUpdate(ruleId, { | |
| ...rule, | |
| ...rule.suggested_fix, | |
| action: finalAction, | |
| review_status: 'accepted', | |
| suggested_fix: null, | |
| confidence_score: 1.0, | |
| }) | |
| // Clean up selection | |
| setSelectedFixActions(prev => { | |
| const next = { ...prev } | |
| delete next[ruleId] | |
| return next | |
| }) | |
| }, | |
| [rules, onUpdate, selectedFixActions] | |
| ) | |
| const handleEdit = useCallback( | |
| (ruleId) => { | |
| const rule = rules.find((r) => r.rule_id === ruleId) | |
| if (!rule) return | |
| setEditJson(JSON.stringify(rule, null, 2)) | |
| setEditError('') | |
| setEditingRuleId(ruleId) | |
| }, | |
| [rules] | |
| ) | |
| const handleSaveEdit = useCallback(() => { | |
| try { | |
| const parsed = JSON.parse(editJson) | |
| // Manual edit shows human engagement β 100% verified. | |
| onUpdate(editingRuleId, { ...parsed, review_status: 'modified', confidence_score: 1.0 }) | |
| setEditingRuleId(null) | |
| } catch (e) { | |
| setEditError(`Invalid JSON: ${e.message}`) | |
| } | |
| }, [editJson, editingRuleId, onUpdate]) | |
| const handleCancelEdit = useCallback(() => { | |
| setEditingRuleId(null) | |
| setEditError('') | |
| }, []) | |
| const handleKeep = useCallback( | |
| (ruleId) => { | |
| const rule = rules.find((r) => r.rule_id === ruleId) | |
| if (!rule) return | |
| // Human confirmed the original is correct β 100% verified. | |
| onUpdate(ruleId, { ...rule, review_status: 'kept_original', confidence_score: 1.0 }) | |
| }, | |
| [rules, onUpdate] | |
| ) | |
| // ββ Render ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| return ( | |
| <div className="rounded-xl bg-slate-900/70 backdrop-blur-sm border border-slate-700/50 overflow-hidden"> | |
| {/* ββ Header bar with filename + copy button βββββββββββββββββββββ */} | |
| <div className="flex items-center justify-between px-4 py-2.5 border-b border-slate-700/40 bg-slate-800/50"> | |
| <div className="flex items-center gap-2"> | |
| <svg className="w-4 h-4 text-slate-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> | |
| <path strokeLinecap="round" strokeLinejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5" /> | |
| </svg> | |
| <span className="text-xs font-mono text-slate-400">ap_policy_rules.json</span> | |
| <span className="text-xs text-slate-600">Β·</span> | |
| <span className="text-xs text-slate-500">{rules.length} rules</span> | |
| </div> | |
| <button | |
| onClick={handleCopy} | |
| className={`text-xs px-3 py-1 rounded-md transition-all duration-200 ${ | |
| copied | |
| ? 'bg-emerald-500/20 text-emerald-400 border border-emerald-500/30' | |
| : 'bg-slate-700 text-slate-300 hover:bg-slate-600 border border-slate-600' | |
| }`} | |
| > | |
| {copied ? 'β Copied' : 'π Copy'} | |
| </button> | |
| </div> | |
| {/* ββ Single continuous code block ββββββββββββββββββββββββββββββββ */} | |
| <div className="overflow-x-hidden overflow-y-auto max-h-[80vh]"> | |
| <pre className="text-[13px] leading-[1.6] font-mono p-0 m-0 whitespace-pre-wrap break-words"> | |
| <code> | |
| {renderItems.map((item, idx) => { | |
| // ββ Numbered code line ββββββββββββββββββββββββββββββββββ | |
| if (item.type === 'line') { | |
| return ( | |
| <div | |
| key={`l-${idx}`} | |
| className={`px-4 ${ | |
| item.hl | |
| ? 'bg-red-500/12' | |
| : item.sug | |
| ? 'bg-emerald-500/10' | |
| : '' | |
| }`} | |
| > | |
| <span className="inline-block w-8 text-right mr-4 text-slate-600 select-none text-[11px]"> | |
| {item.num} | |
| </span> | |
| <span | |
| dangerouslySetInnerHTML={{ | |
| __html: syntaxHighlight(item.text), | |
| }} | |
| /> | |
| </div> | |
| ) | |
| } | |
| // ββ Permanent rule header with live confidence badge ββββ | |
| if (item.type === 'rule-header') { | |
| const confPct = Math.round((item.confidenceScore ?? 0) * 100) | |
| const confColor = | |
| confPct >= 90 ? '#34d399' | |
| : confPct >= 70 ? '#fbbf24' | |
| : '#f87171' | |
| const confBg = | |
| confPct >= 90 ? 'rgba(52,211,153,0.08)' | |
| : confPct >= 70 ? 'rgba(251,191,36,0.08)' | |
| : 'rgba(248,113,113,0.08)' | |
| const statusBadge = | |
| item.reviewStatus === 'accepted' ? { label: 'β Accepted', color: '#34d399', bg: 'rgba(52,211,153,0.1)' } | |
| : item.reviewStatus === 'modified' ? { label: 'β Modified', color: '#818cf8', bg: 'rgba(129,140,248,0.1)' } | |
| : item.reviewStatus === 'kept_original' ? { label: 'β Kept', color: '#94a3b8', bg: 'rgba(148,163,184,0.1)' } | |
| : null | |
| return ( | |
| <div | |
| key={`rh-${item.ruleId}`} | |
| className="flex items-center gap-2 px-4 py-1.5 border-t border-slate-700/40 bg-slate-800/40 select-none" | |
| > | |
| {/* Rule ID */} | |
| <span className="text-[10px] font-mono text-slate-500 mr-1">{item.ruleId}</span> | |
| {/* Live confidence badge */} | |
| <span | |
| className="text-[10px] font-bold font-mono px-2 py-0.5 rounded-full" | |
| style={{ | |
| color: confColor, | |
| background: confBg, | |
| border: `1px solid ${confColor}40`, | |
| transition: 'all 0.4s ease', | |
| }} | |
| title="LLM confidence score. Increases when you accept suggestions." | |
| > | |
| ⬀ {confPct}% | |
| </span> | |
| {/* Review status badge (shown only after action taken) */} | |
| {statusBadge && ( | |
| <span | |
| className="text-[10px] font-semibold px-2 py-0.5 rounded-full" | |
| style={{ color: statusBadge.color, background: statusBadge.bg }} | |
| > | |
| {statusBadge.label} | |
| </span> | |
| )} | |
| </div> | |
| ) | |
| } | |
| // ββ Suggestion banner (no line number) βββββββββββββββββ | |
| if (item.type === 'banner') { | |
| // Find the rule to read its live confidence score | |
| const ruleForBanner = rules.find(r => r.rule_id === item.ruleId) | |
| const confScore = ruleForBanner?.confidence_score ?? 0 | |
| const confPct = Math.round(confScore * 100) | |
| const confColor = | |
| confPct >= 90 ? '#34d399' | |
| : confPct >= 70 ? '#fbbf24' | |
| : '#f87171' | |
| const confBg = | |
| confPct >= 90 ? 'rgba(52,211,153,0.12)' | |
| : confPct >= 70 ? 'rgba(251,191,36,0.12)' | |
| : 'rgba(248,113,113,0.12)' | |
| return ( | |
| <div | |
| key={`b-${idx}`} | |
| id={`flagged-banner-${item.ruleId}`} | |
| className="px-4 py-2.5 bg-amber-950/25 border-y border-amber-500/15" | |
| > | |
| <div className="flex items-center gap-3"> | |
| <span className="text-[11px] font-semibold text-amber-400 tracking-wider uppercase"> | |
| ββ Suggested Fix ({item.ruleId}) ββ | |
| </span> | |
| {/* Per-rule confidence badge */} | |
| <span | |
| className="text-[10px] font-bold font-mono px-2 py-0.5 rounded-full" | |
| style={{ color: confColor, background: confBg, border: `1px solid ${confColor}40` }} | |
| title="LLM extraction confidence. Increases when you accept suggestions." | |
| > | |
| ⬀ {confPct}% confidence | |
| </span> | |
| </div> | |
| {item.explanation && ( | |
| <p className="text-xs text-amber-200/80 mt-1 leading-relaxed pl-12"> | |
| β {item.explanation} | |
| </p> | |
| )} | |
| </div> | |
| ) | |
| } | |
| // ββ Action buttons (no line number) ββββββββββββββββββββ | |
| if (item.type === 'actions') { | |
| const needsDropdown = item.hasFix && item.isCustomAction | |
| const selectedAction = selectedFixActions[item.ruleId] || '' | |
| const canAccept = item.hasFix && (!needsDropdown || selectedAction) | |
| return ( | |
| <div | |
| key={`a-${idx}`} | |
| className="px-4 py-2.5 bg-slate-800/30 border-b border-slate-700/30 flex items-center gap-4 pl-16 flex-wrap" | |
| > | |
| {needsDropdown && ( | |
| <div className="flex items-center gap-2 bg-red-500/10 border border-red-500/20 px-3 py-1.5 rounded-lg"> | |
| <span className="text-xs font-semibold text-red-400 uppercase tracking-wide"> | |
| Custom Action (Needs Review): | |
| </span> | |
| <span className="text-xs text-slate-300 font-mono line-through opacity-70"> | |
| {item.fixAction} | |
| </span> | |
| <select | |
| value={selectedAction} | |
| onChange={(e) => | |
| setSelectedFixActions((prev) => ({ ...prev, [item.ruleId]: e.target.value })) | |
| } | |
| className="ml-2 bg-slate-900 border border-slate-600 text-slate-200 text-xs rounded px-2 py-1 focus:outline-none focus:border-cyan-500" | |
| > | |
| <option value="" disabled>Select Standard Action...</option> | |
| {STANDARD_ACTIONS.map((a) => ( | |
| <option key={a} value={a}> | |
| {a} | |
| </option> | |
| ))} | |
| </select> | |
| </div> | |
| )} | |
| <div className="flex items-center gap-2"> | |
| {item.hasFix && ( | |
| <button | |
| onClick={() => handleAccept(item.ruleId)} | |
| disabled={!canAccept} | |
| className={`px-3 py-1 rounded-lg text-xs font-medium transition-colors ${ | |
| canAccept | |
| ? 'bg-emerald-600 hover:bg-emerald-500 text-white' | |
| : 'bg-emerald-600/30 text-emerald-100/50 cursor-not-allowed' | |
| }`} | |
| > | |
| β Accept Suggestion | |
| </button> | |
| )} | |
| <button | |
| onClick={() => handleEdit(item.ruleId)} | |
| className="px-3 py-1 rounded-lg bg-slate-700 hover:bg-slate-600 text-slate-200 text-xs font-medium transition-colors" | |
| > | |
| β Edit | |
| </button> | |
| <button | |
| onClick={() => handleKeep(item.ruleId)} | |
| className={`px-3 py-1 rounded-lg text-xs font-medium transition-colors border ${ | |
| !item.hasFix | |
| ? 'bg-emerald-600 hover:bg-emerald-500 text-white border-emerald-500' | |
| : 'bg-slate-800 hover:bg-slate-700 text-slate-400 border-slate-600' | |
| }`} | |
| > | |
| Keep Original | |
| </button> | |
| </div> | |
| </div> | |
| ) | |
| } | |
| // ββ Inline edit zone βββββββββββββββββββββββββββββββββββ | |
| if (item.type === 'edit-zone') { | |
| return ( | |
| <div | |
| key={`e-${idx}`} | |
| className="px-4 py-3 bg-slate-800/50 border-y border-cyan-500/20" | |
| > | |
| <div className="flex items-center gap-2 mb-2 pl-12"> | |
| <span className="text-xs font-mono text-cyan-400"> | |
| {item.ruleId} | |
| </span> | |
| <span className="text-xs text-slate-500">β editing</span> | |
| </div> | |
| <textarea | |
| className="w-full h-64 bg-slate-950 text-slate-100 font-mono text-[13px] leading-[1.6] p-4 rounded-lg border border-slate-600 focus:outline-none focus:border-cyan-500 resize-y" | |
| value={editJson} | |
| onChange={(e) => setEditJson(e.target.value)} | |
| spellCheck={false} | |
| /> | |
| {editError && ( | |
| <p className="text-red-400 text-xs mt-1 pl-12"> | |
| {editError} | |
| </p> | |
| )} | |
| <div className="flex gap-2 mt-2 pl-12"> | |
| <button | |
| onClick={handleSaveEdit} | |
| className="px-4 py-1.5 rounded-lg bg-cyan-600 hover:bg-cyan-500 text-white text-xs font-medium transition-colors" | |
| > | |
| Save Changes | |
| </button> | |
| <button | |
| onClick={handleCancelEdit} | |
| className="px-4 py-1.5 rounded-lg bg-slate-700 text-slate-400 text-xs transition-colors" | |
| > | |
| Cancel | |
| </button> | |
| </div> | |
| </div> | |
| ) | |
| } | |
| return null | |
| })} | |
| </code> | |
| </pre> | |
| </div> | |
| </div> | |
| ) | |
| } | |