inc/ai.php
Snapshot 2026.09.17-a2894de (current) · 117 lines · 7,575 bytes · tree · plain text
SHA-256
8903577c20328e7acff479c720d7e3787e3ffa40b477ed2e1eb7ea1352e7bc5eFunctions: ai_mode L11-L12 · ai_catalogue L13-L16 · ai_validate_spec L17-L25 · ai_stats_text L26-L43 · ai_deterministic L44-L62 · ai_http_post L63-L79 · ai_api L80-L108 · ai_ask L109-L117
| 1 | <?php |
| 2 | /* Ask Grímnir: stateless analysis adapter. |
| 3 | Modes (config AI_MODE): 'deterministic' (no model, works offline, default), |
| 4 | 'api' (hosted or self-hosted model via AI_PROVIDER: 'anthropic' or any |
| 5 | OpenAI-compatible endpoint, which covers Ollama/vLLM/LM Studio and most hosts), |
| 6 | 'off'. STATELESS BY DESIGN: no conversation is stored or resent; every request |
| 7 | carries only the current prompt and the public stream catalogue. Every answer |
| 8 | is logged to analysis_runs and labelled machine-generated, exploratory. */ |
| 9 | require_once __DIR__.'/helpers.php'; |
| 10 | |
| 11 | function ai_mode(): string { return defined('AI_MODE') ? AI_MODE : 'deterministic'; } |
| 12 | |
| 13 | function ai_catalogue(): array { |
| 14 | return db()->query("SELECT s.code,s.label,s.unit FROM streams s WHERE s.visibility='public' ORDER BY s.label")->fetchAll(); |
| 15 | } |
| 16 | |
| 17 | function ai_validate_spec(array $spec): array { |
| 18 | $valid=array_column(ai_catalogue(),'code'); |
| 19 | $streams=array_values(array_intersect((array)($spec['streams']??[]),$valid)); |
| 20 | $streams=array_slice($streams,0,4); |
| 21 | $from=preg_match('/^\d{4}-\d{2}-\d{2}$/',$spec['from']??'')?$spec['from']:date('Y-m-d',strtotime('-30 days')); |
| 22 | $to=preg_match('/^\d{4}-\d{2}-\d{2}$/',$spec['to']??'')?$spec['to']:date('Y-m-d'); |
| 23 | return ['streams'=>$streams,'from'=>$from,'to'=>$to,'normalize'=>count($streams)>1]; |
| 24 | } |
| 25 | |
| 26 | function ai_stats_text(array $spec): string { |
| 27 | $out=[]; |
| 28 | foreach($spec['streams'] as $code){ |
| 29 | $s=stream_by_code($code); if(!$s) continue; |
| 30 | $rows=series($s,$spec['from'].' 00:00:00',$spec['to'].' 23:59:59',5000); |
| 31 | if(!$rows){ $out[]=$s['label'].': no data in range.'; continue; } |
| 32 | $vs=array_map(fn($r)=>(float)$r['value'],$rows); |
| 33 | $n=count($vs); $mean=array_sum($vs)/$n; |
| 34 | $half=(int)floor($n/2); |
| 35 | $m1=array_sum(array_slice($vs,0,$half))/max(1,$half); |
| 36 | $m2=array_sum(array_slice($vs,$half))/max(1,$n-$half); |
| 37 | $trend=$m2>$m1*1.03?'rising':($m2<$m1*0.97?'falling':'stable'); |
| 38 | $out[]=sprintf('%s: %d readings, min %.2f, mean %.2f, max %.2f %s, %s across the period (%s).', |
| 39 | $s['label'],$n,min($vs),$mean,max($vs),$s['unit'],$trend,$s['mode']==='live'?'live data':'placeholder data'); |
| 40 | } |
| 41 | return implode(' ',$out); |
| 42 | } |
| 43 | |
| 44 | function ai_deterministic(string $prompt): array { |
| 45 | $p=mb_strtolower($prompt); |
| 46 | $hits=[]; |
| 47 | foreach(ai_catalogue() as $c){ |
| 48 | $words=array_filter(preg_split('/[^a-zà-ÿ0-9]+/u', mb_strtolower($c['label'])), fn($w)=>mb_strlen($w)>3); |
| 49 | $score=0; foreach($words as $w) if(str_contains($p,$w)) $score++; |
| 50 | foreach(['temperature'=>'temp','birds'=>'activity','bird'=>'activity','sky'=>'brightness','dark'=>'brightness','rain'=>'rain','wind'=>'wind','water'=>'lagoon','footfall'=>'passes','visitors'=>'passes','energy'=>'energy','occupancy'=>'occupancy','control'=>'control','noise'=>'sound'] as $k=>$syn) |
| 51 | if(str_contains($p,$k)&&str_contains(mb_strtolower($c['label']),$syn)) $score++; |
| 52 | if($score>0) $hits[$c['code']]=$score; |
| 53 | } |
| 54 | arsort($hits); |
| 55 | $streams=array_slice(array_keys($hits),0, str_contains($p,'correlat')||str_contains($p,' vs ')||str_contains($p,'against')||str_contains($p,'compare') ? 3 : 2); |
| 56 | if(!$streams) $streams=['ac_impact_activity','ac_control_activity']; |
| 57 | $days = str_contains($p,'year')?365:(str_contains($p,'quarter')||str_contains($p,'90')?90:(str_contains($p,'week')?7:30)); |
| 58 | $spec=ai_validate_spec(['streams'=>$streams,'from'=>date('Y-m-d',strtotime("-$days days")),'to'=>date('Y-m-d')]); |
| 59 | $text='Deterministic mode (no language model configured). Matched your question to the streams below and computed summary statistics. '.ai_stats_text($spec); |
| 60 | return ['spec'=>$spec,'text'=>$text,'model'=>'deterministic-v0']; |
| 61 | } |
| 62 | |
| 63 | function ai_http_post(string $url, array $headers, string $body): array { |
| 64 | /* returns [status:int, body:string, err:string]. curl first (works even when allow_url_fopen is off), stream fallback. */ |
| 65 | if(function_exists('curl_init')){ |
| 66 | $ch=curl_init($url); |
| 67 | curl_setopt_array($ch,[CURLOPT_POST=>true,CURLOPT_POSTFIELDS=>$body,CURLOPT_HTTPHEADER=>$headers, |
| 68 | CURLOPT_RETURNTRANSFER=>true,CURLOPT_TIMEOUT=>30,CURLOPT_CONNECTTIMEOUT=>10]); |
| 69 | $res=curl_exec($ch); $st=(int)curl_getinfo($ch,CURLINFO_RESPONSE_CODE); $err=curl_error($ch); curl_close($ch); |
| 70 | return [$st, $res===false?'':$res, $res===false?('curl: '.$err):'']; |
| 71 | } |
| 72 | if(!ini_get('allow_url_fopen')) return [0,'','allow_url_fopen is off and curl is missing: enable one in the hosting panel']; |
| 73 | $ctx=stream_context_create(['http'=>['method'=>'POST','timeout'=>30,'ignore_errors'=>true, |
| 74 | 'header'=>implode("\r\n",$headers)."\r\n",'content'=>$body]]); |
| 75 | $res=@file_get_contents($url,false,$ctx); |
| 76 | $st=0; foreach(($http_response_header??[]) as $h){ if(preg_match('#^HTTP/\S+\s+(\d+)#',$h,$m)){ $st=(int)$m[1]; } } |
| 77 | return [$st, $res===false?'':$res, $res===false?'no response (outbound HTTPS may be blocked by the host)':'']; |
| 78 | } |
| 79 | |
| 80 | function ai_api(string $prompt): array { |
| 81 | $cat=json_encode(array_map(fn($c)=>$c['code'].' = '.$c['label'].' ('.$c['unit'].')',ai_catalogue())); |
| 82 | $system="You are Grímnir, the research analyst of Districthive Research Labs. You answer ONLY from the open research data catalogue given. Reply with STRICT JSON: {\"streams\":[up to 4 stream codes],\"from\":\"YYYY-MM-DD\",\"to\":\"YYYY-MM-DD\",\"commentary\":\"3-5 plain sentences, exploratory tone, no findings claims\"}. Catalogue: ".$cat." Today: ".date('Y-m-d').". You have no memory: this is a single, stateless exchange."; |
| 83 | $provider=defined('AI_PROVIDER')?AI_PROVIDER:'openai'; |
| 84 | if($provider==='anthropic'){ |
| 85 | $body=json_encode(['model'=>AI_MODEL,'max_tokens'=>600,'system'=>$system,'messages'=>[['role'=>'user','content'=>mb_substr($prompt,0,1000)]]]); |
| 86 | [$st,$res,$err]=ai_http_post('https://api.anthropic.com/v1/messages', |
| 87 | ['Content-Type: application/json','x-api-key: '.AI_API_KEY,'anthropic-version: 2023-06-01'],$body); |
| 88 | $txt=$st===200?(json_decode($res,true)['content'][0]['text']??''):''; |
| 89 | } else { |
| 90 | $body=json_encode(['model'=>AI_MODEL,'messages'=>[['role'=>'system','content'=>$system],['role'=>'user','content'=>mb_substr($prompt,0,1000)]],'temperature'=>0.2]); |
| 91 | [$st,$res,$err]=ai_http_post(rtrim(AI_ENDPOINT,'/').'/chat/completions', |
| 92 | ['Content-Type: application/json','Authorization: Bearer '.AI_API_KEY],$body); |
| 93 | $txt=$st===200?(json_decode($res,true)['choices'][0]['message']['content']??''):''; |
| 94 | } |
| 95 | if(!$txt){ |
| 96 | $why = $err ?: ($st>0 ? ('provider returned HTTP '.$st.(($e=json_decode($res,true)['error']['message']??'')!==''?': '.mb_substr($e,0,160):'')) : 'no response'); |
| 97 | log_activity('system','ai_api_fail',mb_substr($why,0,300)); |
| 98 | $fb=ai_deterministic($prompt); |
| 99 | $fb['text']='Model call failed ('.$why.'); deterministic fallback. '.$fb['text']; |
| 100 | return $fb; |
| 101 | } |
| 102 | $j=json_decode(trim(preg_replace('/^```json|```$/m','',trim($txt))),true); |
| 103 | if(!is_array($j)) { $fb=ai_deterministic($prompt); $fb['text']='Model returned an unusable reply; deterministic fallback. '.$fb['text']; return $fb; } |
| 104 | $spec=ai_validate_spec($j); |
| 105 | $text=trim((string)($j['commentary']??'')).' '.ai_stats_text($spec); |
| 106 | return ['spec'=>$spec,'text'=>$text,'model'=>(defined('AI_PROVIDER')?AI_PROVIDER:'openai').':'.AI_MODEL]; |
| 107 | } |
| 108 | |
| 109 | function ai_ask(string $prompt): array { |
| 110 | $prompt=trim(mb_substr($prompt,0,600)); |
| 111 | $ans = ai_mode()==='api' ? ai_api($prompt) : ai_deterministic($prompt); |
| 112 | db()->prepare('INSERT INTO analysis_runs(ts,question,output,model_label,exploratory) VALUES(NOW(),?,?,?,1)') |
| 113 | ->execute([$prompt, mb_substr($ans['text'],0,2000), $ans['model']]); |
| 114 | log_activity('system','ask_grimnir',$ans['model']); |
| 115 | return $ans; |
| 116 | } |
| 117 | |
Lines can be cited as inc/ai.php L120-L140, platform code 2026.09.17-a2894de; add #L120-L140 to this page's address to highlight them. Source shown for scrutiny of the research platform. All rights reserved. Not offered as installable software.