PHP Classes

File: tools/support-bot/bot_query.php

Recommend this page to a friend!
  Packages of Matthew Asham   Binkterm PHP   tools/support-bot/bot_query.php   Download  
File: tools/support-bot/bot_query.php
Role: Auxiliary script
Content type: text/plain
Description: Configuration script
Class: Binkterm PHP
Bulletin board system based on the Web
Author: By
Last change:
Date: 5 days ago
Size: 6,785 bytes
 

Contents

Class file image Download
#!/usr/bin/env php
<?php
/**
 * bot_query.php ? RAG-based support bot for BinktermPHP.
 *
 * Shells out to query_retrieve.py to embed the question and fetch the most
 * relevant documentation chunks via sqlite-vec, then calls Claude Haiku to
 * produce a grounded answer.
 *
 * CLI: php bot_query.php "How do I configure echomail?"
 * POST: curl -X POST -H 'Content-Type: application/json' \
 * -d '{"question":"How do I configure echomail?"}' \
 * http://your-host/bot_query.php
 *
 * Environment:
 * ANTHROPIC_API_KEY ? required
 */

// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------
define('DB_PATH', __DIR__ . '/binkterm_knowledge.db');
define('RETRIEVE_SCRIPT', __DIR__ . '/query_retrieve.py');
define('TOP_K', 4);
define('ANTHROPIC_MODEL', 'claude-haiku-4-5-20251001');

// ---------------------------------------------------------------------------
// Read the question
// ---------------------------------------------------------------------------
$question = '';

if (
PHP_SAPI === 'cli') {
    if (
$argc < 2) {
       
fwrite(STDERR, "Usage: php bot_query.php \"your question\"\n");
        exit(
1);
    }
   
$question = trim($argv[1]);
} else {
   
header('Content-Type: text/plain; charset=utf-8');
   
$body = file_get_contents('php://input');
   
$json = json_decode($body, true);
    if (isset(
$json['question'])) {
       
$question = trim((string)$json['question']);
    } elseif (isset(
$_POST['question'])) {
       
$question = trim((string)$_POST['question']);
    }
}

if (
$question === '') {
   
error_out("Error: question is empty.\n");
}

// ---------------------------------------------------------------------------
// Validate prerequisites
// ---------------------------------------------------------------------------
$apiKey = getenv('ANTHROPIC_API_KEY');
if (!
$apiKey) {
   
error_out("Error: ANTHROPIC_API_KEY environment variable is not set.\n");
}

if (!
file_exists(DB_PATH)) {
   
error_out("Error: knowledge base not found (" . DB_PATH . ").\n"
       
. "Run build_index.py first to build it.\n");
}

if (!
file_exists(RETRIEVE_SCRIPT)) {
   
error_out("Error: retrieval helper not found (" . RETRIEVE_SCRIPT . ").\n");
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

/**
 * Run a command and return [stdout, stderr].
 * @return array{string, string}
 */
function run_cmd(string $cmd): array
{
   
$proc = proc_open($cmd, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
    if (!
is_resource($proc)) {
        return [
'', 'proc_open failed'];
    }
   
$out = (string)stream_get_contents($pipes[1]);
   
$err = (string)stream_get_contents($pipes[2]);
   
fclose($pipes[1]);
   
fclose($pipes[2]);
   
proc_close($proc);
    return [
$out, $err];
}

/**
 * Run a Python command, trying python3 then python.
 * Returns [stdout, last_stderr].
 * @return array{string, string}
 */
function run_python(string $args): array
{
    [
$out, $err] = run_cmd("python3 $args");
    if (
trim($out) !== '') {
        return [
$out, $err];
    }
    return
run_cmd("python $args");
}

/**
 * Write an error message to stderr and exit with code 1.
 */
function error_out(string $message): never
{
   
fwrite(STDERR, $message);
    exit(
1);
}

// ---------------------------------------------------------------------------
// Retrieve relevant chunks via query_retrieve.py
// ---------------------------------------------------------------------------
$escapedScript = escapeshellarg(RETRIEVE_SCRIPT);
$escapedQ = escapeshellarg($question);
$escapedDb = escapeshellarg(DB_PATH);

[
$retrieveOutput, $retrieveError] = run_python("$escapedScript $escapedQ " . TOP_K . " $escapedDb");

if (!
$retrieveOutput || trim($retrieveOutput) === '') {
   
$hint = $retrieveError !== '' ? "\nPython error output:\n$retrieveError" : '';
   
error_out("Error: query_retrieve.py produced no output.$hint\n");
}

$chunks = json_decode(trim($retrieveOutput), true);
if (!
is_array($chunks)) {
   
error_out("Error: unexpected output from query_retrieve.py:\n$retrieveOutput\n");
}

if (empty(
$chunks)) {
    echo
"I don't know ? no relevant documentation was found for your question.\n";
    exit(
0);
}

// ---------------------------------------------------------------------------
// Build the system prompt with retrieved context
// ---------------------------------------------------------------------------
$contextParts = [];
foreach (
$chunks as $chunk) {
   
$source = $chunk['source'];
   
$heading = ($chunk['heading_context'] ?? '') !== '' ? " ({$chunk['heading_context']})" : '';
   
$contextParts[] = "--- Source: {$source}{$heading} ---\n{$chunk['content']}";
}
$context = implode("\n\n", $contextParts);

$systemPrompt = <<<PROMPT
You are a helpful support assistant for BinktermPHP, a Fidonet BBS web interface.

Answer the sysop's question using ONLY the documentation context provided below.
- If the answer is not clearly present in the context, respond with "I don't know."
- Do not guess, infer beyond what the context states, or invent configuration values.
- Keep answers concise and practical. Use numbered steps or bullet points when appropriate.
- If the context mentions a relevant config file or command, include it.

DOCUMENTATION CONTEXT:
{$context}
PROMPT;

// ---------------------------------------------------------------------------
// Call the Anthropic API
// ---------------------------------------------------------------------------
$payload = json_encode([
   
'model' => ANTHROPIC_MODEL,
   
'max_tokens' => 1024,
   
'system' => $systemPrompt,
   
'messages' => [
        [
'role' => 'user', 'content' => $question],
    ],
]);

$ch = curl_init('https://api.anthropic.com/v1/messages');
curl_setopt_array($ch, [
   
CURLOPT_RETURNTRANSFER => true,
   
CURLOPT_POST => true,
   
CURLOPT_POSTFIELDS => $payload,
   
CURLOPT_HTTPHEADER => [
       
'Content-Type: application/json',
       
'x-api-key: ' . $apiKey,
       
'anthropic-version: 2023-06-01',
    ],
   
CURLOPT_TIMEOUT => 30,
]);

$response = curl_exec($ch);
$httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);

if (
$curlError !== '') {
   
error_out("API request failed: $curlError\n");
}

if (
$httpCode !== 200) {
   
error_out("Anthropic API returned HTTP $httpCode: $response\n");
}

$data = json_decode($response, true);
$answer = $data['content'][0]['text'] ?? null;

if (
$answer === null) {
   
error_out("Unexpected API response format.\n");
}

echo
trim($answer) . "\n";