Download web/index.html from Snapkitty/horn-intent-engine: direct link, hf CLI and curl.
- Browser
- Download file 15.8 kB
-
https://huggingface.co/Snapkitty/horn-intent-engine/resolve/main/web/index.html
- Command line
-
hf download hf://Snapkitty/horn-intent-engine/web/index.html
-
curl -L -o index.html https://huggingface.co/Snapkitty/horn-intent-engine/resolve/main/web/index.html
15.8 kB
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Horn Intent Engine — Demo</title> | |
| <style> | |
| :root { | |
| --bg: #0c0d0f; | |
| --paper: #eceae4; | |
| --steel: #9aabba; | |
| --steel-dim: #6a7a88; | |
| --accent: #c8d4de; | |
| --border: #2a2c30; | |
| --success: #7dcea0; | |
| --warn: #e8b86d; | |
| --error: #e07a7a; | |
| --mono: "SF Mono", "Cascadia Code", "Consolas", monospace; | |
| --sans: system-ui, -apple-system, sans-serif; | |
| } | |
| * { box-sizing: border-box; } | |
| body { | |
| margin: 0; | |
| background: var(--bg); | |
| color: var(--paper); | |
| font-family: var(--sans); | |
| font-size: 14px; | |
| line-height: 1.5; | |
| min-height: 100vh; | |
| } | |
| header { | |
| padding: 1.5rem 2rem; | |
| border-bottom: 1px solid var(--border); | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| } | |
| h1 { | |
| margin: 0; | |
| font-weight: 500; | |
| font-size: 1.25rem; | |
| letter-spacing: -0.02em; | |
| color: var(--accent); | |
| } | |
| h1 span { color: var(--steel); font-weight: 400; } | |
| .license { | |
| font-size: 0.75rem; | |
| color: var(--steel-dim); | |
| font-family: var(--mono); | |
| } | |
| main { | |
| display: grid; | |
| grid-template-columns: 1fr 1fr; | |
| gap: 1px; | |
| background: var(--border); | |
| min-height: calc(100vh - 70px); | |
| } | |
| @media (max-width: 900px) { | |
| main { grid-template-columns: 1fr; } | |
| } | |
| .panel { | |
| background: var(--bg); | |
| padding: 1.5rem; | |
| overflow: auto; | |
| } | |
| .panel h2 { | |
| margin: 0 0 1rem; | |
| font-size: 0.85rem; | |
| font-weight: 500; | |
| text-transform: uppercase; | |
| letter-spacing: 0.08em; | |
| color: var(--steel); | |
| } | |
| textarea, input[type="text"] { | |
| width: 100%; | |
| background: #141618; | |
| border: 1px solid var(--border); | |
| color: var(--paper); | |
| font-family: var(--mono); | |
| font-size: 12.5px; | |
| padding: 0.75rem; | |
| border-radius: 4px; | |
| resize: vertical; | |
| } | |
| textarea:focus, input:focus { | |
| outline: none; | |
| border-color: var(--steel); | |
| } | |
| button { | |
| background: transparent; | |
| border: 1px solid var(--steel); | |
| color: var(--accent); | |
| font-family: var(--sans); | |
| font-size: 13px; | |
| padding: 0.5rem 1rem; | |
| border-radius: 4px; | |
| cursor: pointer; | |
| margin-right: 0.5rem; | |
| margin-top: 0.5rem; | |
| } | |
| button:hover { | |
| background: rgba(154, 171, 186, 0.1); | |
| } | |
| button.primary { | |
| background: var(--steel); | |
| color: var(--bg); | |
| border-color: var(--steel); | |
| } | |
| button.primary:hover { | |
| background: var(--accent); | |
| } | |
| .results { | |
| margin-top: 1rem; | |
| font-family: var(--mono); | |
| font-size: 12px; | |
| background: #101214; | |
| border: 1px solid var(--border); | |
| border-radius: 4px; | |
| padding: 1rem; | |
| min-height: 120px; | |
| white-space: pre-wrap; | |
| color: var(--steel); | |
| } | |
| .results .ok { color: var(--success); } | |
| .results .fail { color: var(--error); } | |
| .results .bind { color: var(--warn); } | |
| .facts-list, .rules-list { | |
| font-family: var(--mono); | |
| font-size: 12px; | |
| color: var(--steel-dim); | |
| margin: 0.5rem 0 1rem; | |
| } | |
| .facts-list div, .rules-list div { | |
| padding: 0.25rem 0; | |
| border-bottom: 1px solid #1a1c1f; | |
| } | |
| .tabs { | |
| display: flex; | |
| gap: 0; | |
| margin-bottom: 1rem; | |
| border-bottom: 1px solid var(--border); | |
| } | |
| .tab { | |
| padding: 0.5rem 1rem; | |
| background: none; | |
| border: none; | |
| border-bottom: 2px solid transparent; | |
| color: var(--steel-dim); | |
| cursor: pointer; | |
| margin: 0; | |
| border-radius: 0; | |
| } | |
| .tab.active { | |
| color: var(--accent); | |
| border-bottom-color: var(--steel); | |
| } | |
| .tab-content { display: none; } | |
| .tab-content.active { display: block; } | |
| .example-btn { | |
| font-size: 12px; | |
| padding: 0.3rem 0.6rem; | |
| margin: 0.25rem 0.25rem 0 0; | |
| } | |
| footer { | |
| padding: 1rem 2rem; | |
| border-top: 1px solid var(--border); | |
| font-size: 0.75rem; | |
| color: var(--steel-dim); | |
| text-align: center; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <header> | |
| <h1>Horn Intent Engine <span>— interactive demo</span></h1> | |
| <div class="license">AGPL-3.0</div> | |
| </header> | |
| <main> | |
| <div class="panel"> | |
| <div class="tabs"> | |
| <button class="tab active" data-tab="horn">Horn Resolver</button> | |
| <button class="tab" data-tab="intent">Intent Matching</button> | |
| </div> | |
| <div id="horn" class="tab-content active"> | |
| <h2>Facts</h2> | |
| <div class="facts-list" id="factsList"></div> | |
| <textarea id="factInput" rows="2" placeholder="item item_1 color item_1 red stored_item item_1 storage_a location storage_a warehouse"></textarea> | |
| <button onclick="addFacts()">Add Facts</button> | |
| <button onclick="clearFacts()">Clear</button> | |
| <h2 style="margin-top:1.5rem">Rules</h2> | |
| <div class="rules-list" id="rulesList"></div> | |
| <textarea id="ruleInput" rows="3" placeholder="in_warehouse ?X :- stored_item ?X ?S, location ?S warehouse"></textarea> | |
| <button onclick="addRule()">Add Rule</button> | |
| <button onclick="clearRules()">Clear</button> | |
| <h2 style="margin-top:1.5rem">Query</h2> | |
| <input type="text" id="queryInput" placeholder="in_warehouse ?X" value="in_warehouse ?X"> | |
| <div> | |
| <button class="primary" onclick="runQuery()">Run Query</button> | |
| <button class="example-btn" onclick="loadExample()">Load Warehouse Example</button> | |
| </div> | |
| <div class="results" id="hornResults">Results will appear here.</div> | |
| </div> | |
| <div id="intent" class="tab-content"> | |
| <h2>Utterance</h2> | |
| <input type="text" id="utteranceInput" placeholder="Show me the inventory" value="Show me the inventory"> | |
| <div> | |
| <button class="primary" onclick="resolveIntent()">Resolve</button> | |
| <button class="example-btn" onclick="document.getElementById('utteranceInput').value='Go left'">Go left</button> | |
| <button class="example-btn" onclick="document.getElementById('utteranceInput').value='Cancel'">Cancel</button> | |
| <button class="example-btn" onclick="document.getElementById('utteranceInput').value='Find red items in the warehouse'">Find red…</button> | |
| </div> | |
| <div class="results" id="intentResults">Intent resolution will appear here.</div> | |
| </div> | |
| </div> | |
| <div class="panel"> | |
| <h2>Proof / Substitution</h2> | |
| <div class="results" id="proofView" style="min-height:300px">Select a query or intent to see bindings and proof steps.</div> | |
| <h2 style="margin-top:1.5rem">About</h2> | |
| <p style="color:var(--steel-dim);font-size:13px"> | |
| This is a pure-JavaScript reimplementation of the GDScript Horn engine for demonstration. | |
| Facts are ground atoms. Rules are Horn clauses. Queries use depth-first backward chaining with unification. | |
| Variables are prefixed with <code>?</code>. | |
| </p> | |
| <p style="color:var(--steel-dim);font-size:13px"> | |
| The original source lives in <code>nlp/*.gd</code> and runs inside Godot 4. | |
| This web demo is provided under the same AGPL-3.0 license. | |
| </p> | |
| </div> | |
| </main> | |
| <footer> | |
| Horn Intent Engine · SnapKitty Research Lab · AGPL-3.0 | |
| </footer> | |
| <script> | |
| // --- Horn Engine (JS port of the GDScript logic) --- | |
| class HornEngine { | |
| constructor() { | |
| this.facts = []; | |
| this.rules = []; | |
| this.freshCounter = 0; | |
| this.MAX_DEPTH = 64; | |
| this.MAX_RESULTS = 50; | |
| } | |
| addFact(pred, args) { | |
| this.facts.push({ pred, args: [...args] }); | |
| } | |
| addRule(head, body) { | |
| this.rules.push({ head, body }); | |
| } | |
| isVariable(term) { | |
| return typeof term === 'string' && term.startsWith('?'); | |
| } | |
| walk(term, env) { | |
| let current = term; | |
| let steps = 0; | |
| while (this.isVariable(current) && env.has(current)) { | |
| current = env.get(current); | |
| steps++; | |
| if (steps > this.MAX_DEPTH) break; | |
| } | |
| return current; | |
| } | |
| unify(a, b, env) { | |
| a = this.walk(a, env); | |
| b = this.walk(b, env); | |
| if (a === b) return env; | |
| if (this.isVariable(a)) { | |
| const next = new Map(env); | |
| next.set(a, b); | |
| return next; | |
| } | |
| if (this.isVariable(b)) { | |
| const next = new Map(env); | |
| next.set(b, a); | |
| return next; | |
| } | |
| if (Array.isArray(a) && Array.isArray(b)) { | |
| if (a.length !== b.length) return null; | |
| let current = env; | |
| for (let i = 0; i < a.length; i++) { | |
| current = this.unify(a[i], b[i], current); | |
| if (!current) return null; | |
| } | |
| return current; | |
| } | |
| return null; | |
| } | |
| freshen(value, mapping) { | |
| if (this.isVariable(value)) { | |
| if (!mapping.has(value)) { | |
| this.freshCounter++; | |
| mapping.set(value, '?_v' + this.freshCounter); | |
| } | |
| return mapping.get(value); | |
| } | |
| if (Array.isArray(value)) { | |
| return value.map(v => this.freshen(v, mapping)); | |
| } | |
| if (value && typeof value === 'object') { | |
| const result = {}; | |
| for (const k of Object.keys(value)) { | |
| result[k] = this.freshen(value[k], mapping); | |
| } | |
| return result; | |
| } | |
| return value; | |
| } | |
| solve(goals, env = new Map(), depth = 0) { | |
| if (goals.length === 0) return [env]; | |
| if (depth >= this.MAX_DEPTH) return []; | |
| const goal = goals[0]; | |
| const remaining = goals.slice(1); | |
| const answers = []; | |
| // Facts | |
| for (const fact of this.facts) { | |
| if (fact.pred !== goal.pred) continue; | |
| const nextEnv = this.unify(goal.args, fact.args, env); | |
| if (nextEnv) { | |
| answers.push(...this.solve(remaining, nextEnv, depth + 1)); | |
| } | |
| if (answers.length >= this.MAX_RESULTS) return answers.slice(0, this.MAX_RESULTS); | |
| } | |
| // Rules | |
| for (const stored of this.rules) { | |
| const mapping = new Map(); | |
| const rule = this.freshen(stored, mapping); | |
| const nextEnv = this.unify(goal.args, rule.head.args, env); | |
| if (!nextEnv) continue; | |
| const newGoals = rule.body.map(g => ({ pred: g.pred, args: g.args })).concat(remaining); | |
| answers.push(...this.solve(newGoals, nextEnv, depth + 1)); | |
| if (answers.length >= this.MAX_RESULTS) return answers.slice(0, this.MAX_RESULTS); | |
| } | |
| return answers; | |
| } | |
| query(pred, args) { | |
| return this.solve([{ pred, args }]); | |
| } | |
| } | |
| // --- Intent Engine (simplified) --- | |
| const INTENTS = [ | |
| { id: 'open_inventory', patterns: [/^open my inventory$/, /^show me the inventory$/, /^show inventory$/], handler: 'handle_open_inventory', priority: 100 }, | |
| { id: 'move_left', patterns: [/^move left$/, /^go left$/, /^turn left$/], handler: 'handle_move_left', priority: 90 }, | |
| { id: 'cancel_action', patterns: [/^cancel$/, /^cancel that action$/, /^stop what you are doing$/], handler: 'handle_cancel_action', priority: 110 }, | |
| { id: 'find_red_in_warehouse', patterns: [/^find red items in the warehouse$/, /^show red items in warehouse$/, /^find red items in warehouse$/], handler: 'handle_find_red_in_warehouse', priority: 80 } | |
| ]; | |
| function resolveIntentText(utterance) { | |
| const normalized = utterance.trim().toLowerCase(); | |
| const sorted = [...INTENTS].sort((a, b) => b.priority - a.priority); | |
| for (const intent of sorted) { | |
| for (const pat of intent.patterns) { | |
| if (pat.test(normalized)) { | |
| return { status: 'matched', intent: intent.id, handler: intent.handler, source: utterance }; | |
| } | |
| } | |
| } | |
| return { status: 'unmatched', source: utterance }; | |
| } | |
| // --- UI --- | |
| const engine = new HornEngine(); | |
| function parseTerm(s) { | |
| return s.trim(); | |
| } | |
| function parseArgs(str) { | |
| return str.trim().split(/\s+/).filter(Boolean).map(parseTerm); | |
| } | |
| function addFacts() { | |
| const text = document.getElementById('factInput').value.trim(); | |
| if (!text) return; | |
| const lines = text.split('\n').map(l => l.trim()).filter(Boolean); | |
| for (const line of lines) { | |
| const parts = line.split(/\s+/); | |
| if (parts.length < 1) continue; | |
| const pred = parts[0]; | |
| const args = parts.slice(1); | |
| engine.addFact(pred, args); | |
| } | |
| document.getElementById('factInput').value = ''; | |
| renderFacts(); | |
| } | |
| function clearFacts() { | |
| engine.facts = []; | |
| renderFacts(); | |
| } | |
| function addRule() { | |
| const text = document.getElementById('ruleInput').value.trim(); | |
| if (!text) return; | |
| // Format: headPred ?A ?B :- bodyPred1 ?X ?Y, bodyPred2 ?Z | |
| const m = text.match(/^(\S+)\s+(.*?)\s*:-\s*(.+)$/); | |
| if (!m) { | |
| alert('Rule format: headPred ?X :- bodyPred ?A ?B, otherPred ?C'); | |
| return; | |
| } | |
| const headPred = m[1]; | |
| const headArgs = parseArgs(m[2]); | |
| const bodyParts = m[3].split(',').map(s => s.trim()); | |
| const body = bodyParts.map(bp => { | |
| const p = bp.split(/\s+/); | |
| return { pred: p[0], args: p.slice(1) }; | |
| }); | |
| engine.addRule({ pred: headPred, args: headArgs }, body); | |
| document.getElementById('ruleInput').value = ''; | |
| renderRules(); | |
| } | |
| function clearRules() { | |
| engine.rules = []; | |
| renderRules(); | |
| } | |
| function renderFacts() { | |
| const el = document.getElementById('factsList'); | |
| if (engine.facts.length === 0) { | |
| el.innerHTML = '<div style="color:#555">(no facts)</div>'; | |
| return; | |
| } | |
| el.innerHTML = engine.facts.map(f => | |
| `<div>${f.pred}(${f.args.join(', ')})</div>` | |
| ).join(''); | |
| } | |
| function renderRules() { | |
| const el = document.getElementById('rulesList'); | |
| if (engine.rules.length === 0) { | |
| el.innerHTML = '<div style="color:#555">(no rules)</div>'; | |
| return; | |
| } | |
| el.innerHTML = engine.rules.map(r => { | |
| const head = `${r.head.pred}(${r.head.args.join(', ')})`; | |
| const body = r.body.map(g => `${g.pred}(${g.args.join(', ')})`).join(', '); | |
| return `<div>${head} :- ${body}</div>`; | |
| }).join(''); | |
| } | |
| function runQuery() { | |
| const q = document.getElementById('queryInput').value.trim(); | |
| if (!q) return; | |
| const parts = q.split(/\s+/); | |
| const pred = parts[0]; | |
| const args = parts.slice(1); | |
| const results = engine.query(pred, args); | |
| const out = document.getElementById('hornResults'); | |
| const proof = document.getElementById('proofView'); | |
| if (results.length === 0) { | |
| out.innerHTML = '<span class="fail">No solutions.</span>'; | |
| proof.textContent = 'No proof found.'; | |
| return; | |
| } | |
| let html = `<span class="ok">${results.length} solution(s)</span>\n\n`; | |
| results.forEach((env, i) => { | |
| html += `Solution ${i + 1}:\n`; | |
| for (const [k, v] of env) { | |
| if (k.startsWith('?')) { | |
| html += ` <span class="bind">${k}</span> = ${v}\n`; | |
| } | |
| } | |
| html += '\n'; | |
| }); | |
| out.innerHTML = html; | |
| proof.textContent = JSON.stringify( | |
| results.map(env => Object.fromEntries(env)), | |
| null, 2 | |
| ); | |
| } | |
| function loadExample() { | |
| engine.facts = []; | |
| engine.rules = []; | |
| engine.addFact('item', ['item_1']); | |
| engine.addFact('color', ['item_1', 'red']); | |
| engine.addFact('stored_item', ['item_1', 'storage_a']); | |
| engine.addFact('location', ['storage_a', 'warehouse']); | |
| engine.addRule( | |
| { pred: 'in_warehouse', args: ['?X'] }, | |
| [ | |
| { pred: 'stored_item', args: ['?X', '?S'] }, | |
| { pred: 'location', args: ['?S', 'warehouse'] } | |
| ] | |
| ); | |
| document.getElementById('queryInput').value = 'in_warehouse ?X'; | |
| renderFacts(); | |
| renderRules(); | |
| runQuery(); | |
| } | |
| function resolveIntent() { | |
| const text = document.getElementById('utteranceInput').value; | |
| const result = resolveIntentText(text); | |
| const out = document.getElementById('intentResults'); | |
| const proof = document.getElementById('proofView'); | |
| if (result.status === 'matched') { | |
| out.innerHTML = `<span class="ok">matched</span>\nintent: ${result.intent}\nhandler: ${result.handler}`; | |
| if (result.intent === 'find_red_in_warehouse') { | |
| proof.textContent = 'This intent maps to Horn goals:\n[\n color(?X, red),\n in_warehouse(?X)\n]\n\nLoad the warehouse example on the other tab and run the multi-goal query.'; | |
| } else { | |
| proof.textContent = JSON.stringify(result, null, 2); | |
| } | |
| } else { | |
| out.innerHTML = '<span class="fail">unmatched</span>'; | |
| proof.textContent = 'No matching intent.'; | |
| } | |
| } | |
| // Tabs | |
| document.querySelectorAll('.tab').forEach(tab => { | |
| tab.addEventListener('click', () => { | |
| document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); | |
| document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); | |
| tab.classList.add('active'); | |
| document.getElementById(tab.dataset.tab).classList.add('active'); | |
| }); | |
| }); | |
| renderFacts(); | |
| renderRules(); | |
| </script> | |
| </body> | |
| </html> | |