ollamaGenerate.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265
  1. <?php
  2. /**
  3. * controllers/ollamaGenerate.php
  4. *
  5. * AJAX POST handler: sends soil test data to a local Ollama instance and
  6. * returns an AI-generated agronomic interpretation for the requested section.
  7. *
  8. * Expected POST params:
  9. * csrf_token string CSRF token
  10. * rid int soil_records.id
  11. * rand string soil_records.rand (ownership token)
  12. * section string overview | ai_interpretation | foliar | microbial
  13. *
  14. * Requires Ollama running on http://localhost:11434
  15. * Default model: llama3.2 — change OLLAMA_MODEL below to suit your setup.
  16. */
  17. if (session_status() === PHP_SESSION_NONE) {
  18. session_start();
  19. }
  20. require_once __DIR__ . '/../config/database.php';
  21. require_once __DIR__ . '/../lib/auth.php';
  22. require_once __DIR__ . '/../lib/csrf.php';
  23. header('Content-Type: application/json');
  24. // ── Auth + CSRF ─────────────────────────────────────────────────────────────
  25. if (!isLoggedIn()) {
  26. http_response_code(401);
  27. echo json_encode(['success' => false, 'error' => 'Not authenticated']);
  28. exit;
  29. }
  30. if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
  31. http_response_code(405);
  32. echo json_encode(['success' => false, 'error' => 'Method not allowed']);
  33. exit;
  34. }
  35. if (!verifyCsrfToken($_POST['csrf_token'] ?? '')) {
  36. http_response_code(403);
  37. echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
  38. exit;
  39. }
  40. // ── Input validation ────────────────────────────────────────────────────────
  41. $recordId = (int)trim($_POST['rid'] ?? '');
  42. $randId = trim($_POST['rand'] ?? '');
  43. $section = trim($_POST['section'] ?? '');
  44. $validSections = ['overview', 'ai_interpretation', 'foliar', 'microbial'];
  45. if (!$recordId || $randId === '' || !in_array($section, $validSections, true)) {
  46. http_response_code(400);
  47. echo json_encode(['success' => false, 'error' => 'Invalid parameters']);
  48. exit;
  49. }
  50. // ── Load data ───────────────────────────────────────────────────────────────
  51. try {
  52. $pdo = getDBConnection();
  53. $userId = getCurrentUserId();
  54. $stmt = $pdo->prepare('SELECT * FROM soil_records WHERE id = ? AND rand = ?');
  55. $stmt->execute([$recordId, $randId]);
  56. $row = $stmt->fetch(PDO::FETCH_ASSOC);
  57. if (!$row) {
  58. http_response_code(404);
  59. echo json_encode(['success' => false, 'error' => 'Record not found']);
  60. exit;
  61. }
  62. $spec = [];
  63. if (!empty($row['soil_type'])) {
  64. $stmtSpec = $pdo->prepare('SELECT * FROM soil_specifications WHERE soil_type = ? LIMIT 1');
  65. $stmtSpec->execute([$row['soil_type']]);
  66. $spec = $stmtSpec->fetch(PDO::FETCH_ASSOC) ?: [];
  67. }
  68. } catch (PDOException $e) {
  69. error_log('DB error in ollamaGenerate.php: ' . $e->getMessage());
  70. http_response_code(500);
  71. echo json_encode(['success' => false, 'error' => 'Database error']);
  72. exit;
  73. }
  74. // ── Build soil data summary for the prompt ──────────────────────────────────
  75. /** Helper: format a numeric value safely, '' → 'N/A' */
  76. function fv(mixed $v, int $dp = 2): string
  77. {
  78. if ($v === null || $v === '') return 'N/A';
  79. return is_numeric($v) ? number_format((float)$v, $dp) : (string)$v;
  80. }
  81. /** Compare a value to a min/max range, return status string */
  82. function rangeStatus(mixed $value, mixed $min, mixed $max): string
  83. {
  84. if (!is_numeric($value)) return '';
  85. $v = (float)$value;
  86. $lo = is_numeric($min) ? (float)$min : null;
  87. $hi = is_numeric($max) ? (float)$max : null;
  88. if ($lo !== null && $v < $lo) return 'DEFICIENT';
  89. if ($hi !== null && $v > $hi) return 'EXCESS';
  90. return 'IDEAL';
  91. }
  92. $r = $row;
  93. $s = $spec;
  94. $soilSummary = <<<TEXT
  95. SOIL TEST RESULTS
  96. =================
  97. Client: {$r['client_name']}
  98. Location: {$r['site_address']}, {$r['state_postcode']}
  99. Crop: {$r['sample_id']}
  100. Soil Type: {$r['soil_type']}
  101. Lab Number: {$r['lab_no']}
  102. Date Sampled: {$r['date_sampled']}
  103. Batch: {$r['batch_no']}
  104. PHYSICAL / GENERAL
  105. ------------------
  106. pH (H2O): {fv($r['ph_h2o'], 1)} [target: 6.0–7.0] {rangeStatus($r['ph_h2o'], 6.0, 7.0)}
  107. pH (CaCl2): {fv($r['ph_cacl2'], 1)}
  108. EC (mS/cm): {fv($r['ec'], 2)}
  109. Organic Carbon (%): {fv($r['ocarbon'], 1)}
  110. Organic Matter (%): {fv($r['omatter'], 1)}
  111. CEC: {fv($r['cec'], 2)}
  112. TEC: {fv($r['tec'], 2)}
  113. MAJOR ELEMENTS (ppm)
  114. ---------------------
  115. 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)}
  116. 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)}
  117. 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)}
  118. 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)}
  119. Nitrate-N: {fv($r['NO3_N'], 0)} ppm
  120. Ammonium-N: {fv($r['NH3_N'], 0)} ppm
  121. Phosphate (P): {fv($r['p_colwell'],0)} ppm (Colwell)
  122. TRACE ELEMENTS (ppm)
  123. --------------------
  124. Sulfur (S): {fv($r['s_morgan'], 2)}
  125. Boron (B): {fv($r['b_cacl2'], 2)}
  126. Manganese (Mn): {fv($r['mn_dtpa'], 2)}
  127. Copper (Cu): {fv($r['cu_dtpa'], 2)}
  128. Zinc (Zn): {fv($r['zn_dtpa'], 2)}
  129. Iron (Fe): {fv($r['fe_dtpa'], 2)}
  130. Aluminium (Al): {fv($r['al'], 2)}
  131. Silicon (Si): {fv($r['sl_cacl2'], 2)}
  132. BASE SATURATIONS (%)
  133. --------------------
  134. 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)}
  135. 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)}
  136. 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)}
  137. Sodium (Na): {fv($r['BS_na'], 2)}%
  138. Other Bases: {fv($r['BS_ob'], 2)}%
  139. Hydrogen: {fv($r['BS_h'], 2)}%
  140. RATIOS
  141. ------
  142. 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)}]
  143. C:N ratio: {fv($r['c_n_ratio'], 1)}
  144. TEXT;
  145. // ── Section-specific prompts ────────────────────────────────────────────────
  146. $prompts = [
  147. 'overview' =>
  148. "You are an experienced Australian agronomist writing a professional soil analysis report.\n\n"
  149. . $soilSummary
  150. . "\n\nWrite a concise executive overview (3–4 paragraphs) suitable for a farmer. "
  151. . "Summarise the overall soil health, the most important deficiencies or imbalances, "
  152. . "and their likely impact on crop performance. Use plain language — avoid jargon. "
  153. . "Do not include specific product recommendations in this section.",
  154. 'ai_interpretation' =>
  155. "You are an experienced Australian agronomist.\n\n"
  156. . $soilSummary
  157. . "\n\nProvide a detailed technical interpretation of these soil test results. "
  158. . "For each element that is DEFICIENT or in EXCESS, explain: "
  159. . "(1) the agronomic significance, "
  160. . "(2) the likely cause in Australian soils, "
  161. . "(3) the interactions with other nutrients shown in this test. "
  162. . "Also comment on the base saturation balance, pH implications, and organic matter. "
  163. . "Write in a professional tone suitable for an agronomist's report.",
  164. 'foliar' =>
  165. "You are an experienced Australian agronomist.\n\n"
  166. . $soilSummary
  167. . "\n\nDesign a foliar spray program to correct the nutrient deficiencies shown in these soil test results. "
  168. . "List recommended applications by growth stage, including product types, rates (L/ha or kg/ha), "
  169. . "and timing. Focus on elements that are DEFICIENT. "
  170. . "Note any antagonisms to avoid (e.g. Ca and Mg competing). "
  171. . "Write as a practical program a farmer can follow.",
  172. 'microbial' =>
  173. "You are an experienced Australian agronomist with expertise in soil biology.\n\n"
  174. . $soilSummary
  175. . "\n\nDesign a microbial and biological soil program suited to these test results. "
  176. . "Consider: the organic matter level, pH, and nutrient imbalances shown. "
  177. . "Recommend specific microbial inoculants, compost teas, or biological amendments "
  178. . "that would improve soil biology and nutrient cycling for the crop type listed. "
  179. . "Include timing and application rates where possible.",
  180. ];
  181. // ── Call Ollama ─────────────────────────────────────────────────────────────
  182. define('OLLAMA_URL', 'http://localhost:11434/api/generate');
  183. define('OLLAMA_MODEL', 'llama3.2'); // change to match your installed model
  184. define('OLLAMA_TIMEOUT', 120); // seconds — LLM can be slow
  185. $prompt = $prompts[$section];
  186. $payload = json_encode([
  187. 'model' => OLLAMA_MODEL,
  188. 'prompt' => $prompt,
  189. 'stream' => false,
  190. ]);
  191. $ch = curl_init(OLLAMA_URL);
  192. curl_setopt_array($ch, [
  193. CURLOPT_POST => true,
  194. CURLOPT_POSTFIELDS => $payload,
  195. CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
  196. CURLOPT_RETURNTRANSFER => true,
  197. CURLOPT_TIMEOUT => OLLAMA_TIMEOUT,
  198. CURLOPT_CONNECTTIMEOUT => 5,
  199. ]);
  200. $response = curl_exec($ch);
  201. $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
  202. $curlErr = curl_error($ch);
  203. curl_close($ch);
  204. if ($curlErr || $response === false) {
  205. http_response_code(502);
  206. echo json_encode([
  207. 'success' => false,
  208. 'error' => 'Could not connect to Ollama: ' . ($curlErr ?: 'no response'),
  209. ]);
  210. exit;
  211. }
  212. if ($httpCode !== 200) {
  213. http_response_code(502);
  214. echo json_encode([
  215. 'success' => false,
  216. 'error' => 'Ollama returned HTTP ' . $httpCode,
  217. ]);
  218. exit;
  219. }
  220. $ollamaData = json_decode($response, true);
  221. $text = trim($ollamaData['response'] ?? '');
  222. if ($text === '') {
  223. http_response_code(502);
  224. echo json_encode(['success' => false, 'error' => 'Ollama returned an empty response']);
  225. exit;
  226. }
  227. echo json_encode(['success' => true, 'text' => $text]);
  228. exit;