Files
jarvis/api/lib/kb_engine.php
T
myron 2f57908a50 fix: storeFact always bumps updated_at; fix $fresh() wrong column name
ON DUPLICATE KEY UPDATE was not touching updated_at, so if a site's
status didn't change MySQL never fired the ON UPDATE trigger and the
row timestamp stayed 6 days stale. do_server.php's 15-min freshness
window then returned empty sites.

Also fixes $fresh() querying WHERE fact_category= (non-existent column)
instead of WHERE category=, which always returned no rows.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 14:24:17 +00:00

155 lines
6.0 KiB
PHP

<?php
/**
* JARVIS Knowledge Base + Intent Engine
* Matches user input against intent patterns, substitutes live facts from kb_facts.
* Returns a response if matched, or null to escalate to Ollama/Claude.
*/
class KBEngine {
/**
* Try to match the input against known intents.
* Returns ['reply' => string, 'intent' => string] or null if no match.
*/
public static function match(string $input): ?array {
$intents = JarvisDB::query(
'SELECT * FROM kb_intents WHERE active=1 ORDER BY priority DESC, id ASC'
);
if (!$intents) return null;
foreach ($intents as $intent) {
$pat = '~' . $intent['pattern'] . '~';
if (@preg_match($pat, $input)) {
$reply = self::fillTemplate(
$intent['response_template'],
$intent['fact_category']
);
return [
'reply' => $reply,
'intent' => $intent['intent_name'],
'action' => $intent['action_type'],
];
}
}
return null;
}
/**
* Replace {placeholder} tokens in a template with values from kb_facts,
* plus built-in dynamic tokens like {current_time}.
*/
private static function fillTemplate(string $template, ?string $category): string {
// Built-in tokens
$builtins = [
'current_time' => date('g:i A'),
'current_date' => date('l, F j Y'),
];
// Load user address preference
$prefRows = JarvisDB::query(
"SELECT pref_key, pref_value FROM kb_preferences WHERE pref_key IN ('user_name','user_title')"
);
$prefs = [];
foreach ($prefRows ?? [] as $p) { $prefs[$p['pref_key']] = $p['pref_value']; }
$builtins['user_title'] = $prefs['user_title'] ?? $prefs['user_name'] ?? 'Sir';
$builtins['user_name'] = $prefs['user_name'] ?? 'Myron';
// Computed builtins
$pendingRow = JarvisDB::single("SELECT COUNT(*) cnt FROM tasks WHERE status NOT IN ('done','cancelled')");
$builtins['pending_count'] = (string)($pendingRow['cnt'] ?? 0);
$overdueRow = JarvisDB::single("SELECT COUNT(*) cnt FROM tasks WHERE due_date < CURDATE() AND status NOT IN ('done','cancelled')");
$builtins['overdue_count'] = (string)($overdueRow['cnt'] ?? 0);
// Fetch all facts for this category
$facts = [];
if ($category) {
$rows = JarvisDB::query(
'SELECT fact_key, fact_value FROM kb_facts
WHERE category = ? AND (expires_at IS NULL OR expires_at > NOW())',
[$category]
);
foreach ($rows ?? [] as $r) {
$facts[$r['fact_key']] = $r['fact_value'];
}
}
// Pull network facts if template uses them
if (strpos($template, '{online_count}') !== false || strpos($template, '{total_count}') !== false) {
$netRows = JarvisDB::query("SELECT fact_key, fact_value FROM kb_facts WHERE category='network' AND (expires_at IS NULL OR expires_at > NOW())");
foreach ($netRows ?? [] as $r) { $facts[$r['fact_key']] = $r['fact_value']; }
}
// Pull system facts if template uses them
if (preg_match('/\{(cpu_usage|mem_|disk_|uptime|load_)\w*\}/', $template)) {
$sysRows = JarvisDB::query("SELECT fact_key, fact_value FROM kb_facts WHERE category='system' AND (expires_at IS NULL OR expires_at > NOW())");
foreach ($sysRows ?? [] as $r) { $facts[$r['fact_key']] = $r['fact_value']; }
}
$allTokens = array_merge($builtins, $facts);
// Replace placeholders
return preg_replace_callback('/\{([a-z0-9_]+)\}/', function ($m) use ($allTokens) {
return $allTokens[$m[1]] ?? '';
}, $template);
}
/**
* Store a fact in kb_facts (upsert).
*/
public static function storeFact(
string $category,
string $key,
string $value,
string $host = 'local',
?int $ttlSeconds = null
): void {
$expires = $ttlSeconds ? gmdate('Y-m-d H:i:s', time() + $ttlSeconds) : null;
JarvisDB::execute(
'INSERT INTO kb_facts (category, fact_key, fact_value, host, expires_at)
VALUES (?,?,?,?,?)
ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value), expires_at=VALUES(expires_at), updated_at=NOW()',
[$category, $key, $value, $host, $expires]
);
}
/**
* Learn from conversation — store interesting facts the user mentions.
*/
public static function learnFromConversation(string $input, string $reply): void {
// Preference learning: user states a preference
if (preg_match('/(?i)i (prefer|like|want|always)\s+(.+?)(?:\.|$)/', $input, $m)) {
$pref = trim($m[2]);
if (strlen($pref) < 120) {
JarvisDB::execute(
'INSERT INTO kb_preferences (pref_key, pref_value)
VALUES (?,?)
ON DUPLICATE KEY UPDATE pref_value=VALUES(pref_value)',
['learned_' . md5($pref), $pref]
);
}
}
}
/**
* Return a summary of what the KB knows (for system prompt injection).
*/
public static function getContextSummary(): string {
// Exclude entity_map — too large for Ollama 1B tokenizer
$facts = JarvisDB::query(
"SELECT category, fact_key, fact_value FROM kb_facts
WHERE fact_key != 'entity_map'
ORDER BY category, updated_at DESC"
);
if (!$facts) return '';
$byCategory = [];
foreach ($facts as $f) {
$byCategory[$f['category']][] = "{$f['fact_key']}={$f['fact_value']}";
}
$lines = [];
foreach ($byCategory as $cat => $items) {
$lines[] = strtoupper($cat) . ': ' . implode(', ', array_slice($items, 0, 8));
}
return implode("\n", $lines);
}
}