| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265 |
- <?php
- /**
- * controllers/ollamaGenerate.php
- *
- * AJAX POST handler: sends soil test data to a local Ollama instance and
- * returns an AI-generated agronomic interpretation for the requested section.
- *
- * Expected POST params:
- * csrf_token string CSRF token
- * rid int soil_records.id
- * rand string soil_records.rand (ownership token)
- * section string overview | ai_interpretation | foliar | microbial
- *
- * Requires Ollama running on http://localhost:11434
- * Default model: llama3.2 — change OLLAMA_MODEL below to suit your setup.
- */
- if (session_status() === PHP_SESSION_NONE) {
- session_start();
- }
- require_once __DIR__ . '/../config/database.php';
- require_once __DIR__ . '/../lib/auth.php';
- require_once __DIR__ . '/../lib/csrf.php';
- header('Content-Type: application/json');
- // ── Auth + CSRF ─────────────────────────────────────────────────────────────
- if (!isLoggedIn()) {
- http_response_code(401);
- echo json_encode(['success' => false, 'error' => 'Not authenticated']);
- exit;
- }
- if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
- http_response_code(405);
- echo json_encode(['success' => false, 'error' => 'Method not allowed']);
- exit;
- }
- if (!verifyCsrfToken($_POST['csrf_token'] ?? '')) {
- http_response_code(403);
- echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
- exit;
- }
- // ── Input validation ────────────────────────────────────────────────────────
- $recordId = (int)trim($_POST['rid'] ?? '');
- $randId = trim($_POST['rand'] ?? '');
- $section = trim($_POST['section'] ?? '');
- $validSections = ['overview', 'ai_interpretation', 'foliar', 'microbial'];
- if (!$recordId || $randId === '' || !in_array($section, $validSections, true)) {
- http_response_code(400);
- echo json_encode(['success' => false, 'error' => 'Invalid parameters']);
- exit;
- }
- // ── Load data ───────────────────────────────────────────────────────────────
- try {
- $pdo = getDBConnection();
- $userId = getCurrentUserId();
- $stmt = $pdo->prepare('SELECT * FROM soil_records WHERE id = ? AND rand = ?');
- $stmt->execute([$recordId, $randId]);
- $row = $stmt->fetch(PDO::FETCH_ASSOC);
- if (!$row) {
- http_response_code(404);
- echo json_encode(['success' => false, 'error' => 'Record not found']);
- exit;
- }
- $spec = [];
- if (!empty($row['soil_type'])) {
- $stmtSpec = $pdo->prepare('SELECT * FROM soil_specifications WHERE soil_type = ? LIMIT 1');
- $stmtSpec->execute([$row['soil_type']]);
- $spec = $stmtSpec->fetch(PDO::FETCH_ASSOC) ?: [];
- }
- } catch (PDOException $e) {
- error_log('DB error in ollamaGenerate.php: ' . $e->getMessage());
- http_response_code(500);
- echo json_encode(['success' => false, 'error' => 'Database error']);
- exit;
- }
- // ── Build soil data summary for the prompt ──────────────────────────────────
- /** Helper: format a numeric value safely, '' → 'N/A' */
- function fv(mixed $v, int $dp = 2): string
- {
- if ($v === null || $v === '') return 'N/A';
- return is_numeric($v) ? number_format((float)$v, $dp) : (string)$v;
- }
- /** Compare a value to a min/max range, return status string */
- function rangeStatus(mixed $value, mixed $min, mixed $max): string
- {
- if (!is_numeric($value)) return '';
- $v = (float)$value;
- $lo = is_numeric($min) ? (float)$min : null;
- $hi = is_numeric($max) ? (float)$max : null;
- if ($lo !== null && $v < $lo) return 'DEFICIENT';
- if ($hi !== null && $v > $hi) return 'EXCESS';
- return 'IDEAL';
- }
- $r = $row;
- $s = $spec;
- $soilSummary = <<<TEXT
- SOIL TEST RESULTS
- =================
- Client: {$r['client_name']}
- Location: {$r['site_address']}, {$r['state_postcode']}
- Crop: {$r['sample_id']}
- Soil Type: {$r['soil_type']}
- Lab Number: {$r['lab_no']}
- Date Sampled: {$r['date_sampled']}
- Batch: {$r['batch_no']}
- PHYSICAL / GENERAL
- ------------------
- pH (H2O): {fv($r['ph_h2o'], 1)} [target: 6.0–7.0] {rangeStatus($r['ph_h2o'], 6.0, 7.0)}
- pH (CaCl2): {fv($r['ph_cacl2'], 1)}
- EC (mS/cm): {fv($r['ec'], 2)}
- Organic Carbon (%): {fv($r['ocarbon'], 1)}
- Organic Matter (%): {fv($r['omatter'], 1)}
- CEC: {fv($r['cec'], 2)}
- TEC: {fv($r['tec'], 2)}
- MAJOR ELEMENTS (ppm)
- ---------------------
- Calcium (Ca): {fv($r['BS_ca_ppm'], 0)} [min: {fv($s['ca_ppm_min'] ?? $r['ca_ppm_min'], 0)}, max: {fv($s['ca_ppm_max'] ?? $r['ca_ppm_max'], 0)}] {rangeStatus($r['BS_ca_ppm'], $s['ca_ppm_min'] ?? $r['ca_ppm_min'] ?? null, $s['ca_ppm_max'] ?? $r['ca_ppm_max'] ?? null)}
- Magnesium (Mg): {fv($r['BS_mg_ppm'], 0)} [min: {fv($s['mg_ppm_min'] ?? $r['mg_ppm_min'], 0)}, max: {fv($s['mg_ppm_max'] ?? $r['mg_ppm_max'], 0)}] {rangeStatus($r['BS_mg_ppm'], $s['mg_ppm_min'] ?? $r['mg_ppm_min'] ?? null, $s['mg_ppm_max'] ?? $r['mg_ppm_max'] ?? null)}
- Potassium (K): {fv($r['BS_k_ppm'], 0)} [min: {fv($s['k_ppm_min'] ?? $r['k_ppm_min'], 0)}, max: {fv($s['k_ppm_max'] ?? $r['k_ppm_max'], 0)}] {rangeStatus($r['BS_k_ppm'], $s['k_ppm_min'] ?? $r['k_ppm_min'] ?? null, $s['k_ppm_max'] ?? $r['k_ppm_max'] ?? null)}
- Sodium (Na): {fv($r['BS_na_ppm'], 0)} [min: {fv($s['na_ppm_min'] ?? $r['na_ppm_min'], 0)}, max: {fv($s['na_ppm_max'] ?? $r['na_ppm_max'], 0)}] {rangeStatus($r['BS_na_ppm'], $s['na_ppm_min'] ?? $r['na_ppm_min'] ?? null, $s['na_ppm_max'] ?? $r['na_ppm_max'] ?? null)}
- Nitrate-N: {fv($r['NO3_N'], 0)} ppm
- Ammonium-N: {fv($r['NH3_N'], 0)} ppm
- Phosphate (P): {fv($r['p_colwell'],0)} ppm (Colwell)
- TRACE ELEMENTS (ppm)
- --------------------
- Sulfur (S): {fv($r['s_morgan'], 2)}
- Boron (B): {fv($r['b_cacl2'], 2)}
- Manganese (Mn): {fv($r['mn_dtpa'], 2)}
- Copper (Cu): {fv($r['cu_dtpa'], 2)}
- Zinc (Zn): {fv($r['zn_dtpa'], 2)}
- Iron (Fe): {fv($r['fe_dtpa'], 2)}
- Aluminium (Al): {fv($r['al'], 2)}
- Silicon (Si): {fv($r['sl_cacl2'], 2)}
- BASE SATURATIONS (%)
- --------------------
- Calcium (Ca): {fv($r['BS_ca2'], 2)}% [min: {fv($s['cabs_min'] ?? null)}, max: {fv($s['cabs_max'] ?? null)}] {rangeStatus($r['BS_ca2'], $s['cabs_min'] ?? null, $s['cabs_max'] ?? null)}
- Magnesium (Mg): {fv($r['BS_mg2'], 2)}% [min: {fv($s['mgbs_min'] ?? null)}, max: {fv($s['mgbs_max'] ?? null)}] {rangeStatus($r['BS_mg2'], $s['mgbs_min'] ?? null, $s['mgbs_max'] ?? null)}
- Potassium (K): {fv($r['BS_k'], 2)}% [min: {fv($s['kbs_min'] ?? null)}, max: {fv($s['kbs_max'] ?? null)}] {rangeStatus($r['BS_k'], $s['kbs_min'] ?? null, $s['kbs_max'] ?? null)}
- Sodium (Na): {fv($r['BS_na'], 2)}%
- Other Bases: {fv($r['BS_ob'], 2)}%
- Hydrogen: {fv($r['BS_h'], 2)}%
- RATIOS
- ------
- Ca:Mg ratio: {fv(is_numeric($r['ca_mehlick3']) && is_numeric($r['mg_mehlick3']) && (float)$r['mg_mehlick3'] != 0 ? (float)$r['ca_mehlick3'] / (float)$r['mg_mehlick3'] : null, 1)} [recommended: {fv($s['ca_mg_ratio'] ?? null, 1)}]
- C:N ratio: {fv($r['c_n_ratio'], 1)}
- TEXT;
- // ── Section-specific prompts ────────────────────────────────────────────────
- $prompts = [
- 'overview' =>
- "You are an experienced Australian agronomist writing a professional soil analysis report.\n\n"
- . $soilSummary
- . "\n\nWrite a concise executive overview (3–4 paragraphs) suitable for a farmer. "
- . "Summarise the overall soil health, the most important deficiencies or imbalances, "
- . "and their likely impact on crop performance. Use plain language — avoid jargon. "
- . "Do not include specific product recommendations in this section.",
- 'ai_interpretation' =>
- "You are an experienced Australian agronomist.\n\n"
- . $soilSummary
- . "\n\nProvide a detailed technical interpretation of these soil test results. "
- . "For each element that is DEFICIENT or in EXCESS, explain: "
- . "(1) the agronomic significance, "
- . "(2) the likely cause in Australian soils, "
- . "(3) the interactions with other nutrients shown in this test. "
- . "Also comment on the base saturation balance, pH implications, and organic matter. "
- . "Write in a professional tone suitable for an agronomist's report.",
- 'foliar' =>
- "You are an experienced Australian agronomist.\n\n"
- . $soilSummary
- . "\n\nDesign a foliar spray program to correct the nutrient deficiencies shown in these soil test results. "
- . "List recommended applications by growth stage, including product types, rates (L/ha or kg/ha), "
- . "and timing. Focus on elements that are DEFICIENT. "
- . "Note any antagonisms to avoid (e.g. Ca and Mg competing). "
- . "Write as a practical program a farmer can follow.",
- 'microbial' =>
- "You are an experienced Australian agronomist with expertise in soil biology.\n\n"
- . $soilSummary
- . "\n\nDesign a microbial and biological soil program suited to these test results. "
- . "Consider: the organic matter level, pH, and nutrient imbalances shown. "
- . "Recommend specific microbial inoculants, compost teas, or biological amendments "
- . "that would improve soil biology and nutrient cycling for the crop type listed. "
- . "Include timing and application rates where possible.",
- ];
- // ── Call Ollama ─────────────────────────────────────────────────────────────
- define('OLLAMA_URL', 'http://localhost:11434/api/generate');
- define('OLLAMA_MODEL', 'llama3.2'); // change to match your installed model
- define('OLLAMA_TIMEOUT', 120); // seconds — LLM can be slow
- $prompt = $prompts[$section];
- $payload = json_encode([
- 'model' => OLLAMA_MODEL,
- 'prompt' => $prompt,
- 'stream' => false,
- ]);
- $ch = curl_init(OLLAMA_URL);
- curl_setopt_array($ch, [
- CURLOPT_POST => true,
- CURLOPT_POSTFIELDS => $payload,
- CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
- CURLOPT_RETURNTRANSFER => true,
- CURLOPT_TIMEOUT => OLLAMA_TIMEOUT,
- CURLOPT_CONNECTTIMEOUT => 5,
- ]);
- $response = curl_exec($ch);
- $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
- $curlErr = curl_error($ch);
- curl_close($ch);
- if ($curlErr || $response === false) {
- http_response_code(502);
- echo json_encode([
- 'success' => false,
- 'error' => 'Could not connect to Ollama: ' . ($curlErr ?: 'no response'),
- ]);
- exit;
- }
- if ($httpCode !== 200) {
- http_response_code(502);
- echo json_encode([
- 'success' => false,
- 'error' => 'Ollama returned HTTP ' . $httpCode,
- ]);
- exit;
- }
- $ollamaData = json_decode($response, true);
- $text = trim($ollamaData['response'] ?? '');
- if ($text === '') {
- http_response_code(502);
- echo json_encode(['success' => false, 'error' => 'Ollama returned an empty response']);
- exit;
- }
- echo json_encode(['success' => true, 'text' => $text]);
- exit;
|