query("SELECT s.code,s.label,s.unit FROM streams s WHERE s.visibility='public' ORDER BY s.label")->fetchAll(); } function ai_validate_spec(array $spec): array { $valid=array_column(ai_catalogue(),'code'); $streams=array_values(array_intersect((array)($spec['streams']??[]),$valid)); $streams=array_slice($streams,0,4); $from=preg_match('/^\d{4}-\d{2}-\d{2}$/',$spec['from']??'')?$spec['from']:date('Y-m-d',strtotime('-30 days')); $to=preg_match('/^\d{4}-\d{2}-\d{2}$/',$spec['to']??'')?$spec['to']:date('Y-m-d'); return ['streams'=>$streams,'from'=>$from,'to'=>$to,'normalize'=>count($streams)>1]; } function ai_stats_text(array $spec): string { $out=[]; foreach($spec['streams'] as $code){ $s=stream_by_code($code); if(!$s) continue; $rows=series($s,$spec['from'].' 00:00:00',$spec['to'].' 23:59:59',5000); if(!$rows){ $out[]=$s['label'].': no data in range.'; continue; } $vs=array_map(fn($r)=>(float)$r['value'],$rows); $n=count($vs); $mean=array_sum($vs)/$n; $half=(int)floor($n/2); $m1=array_sum(array_slice($vs,0,$half))/max(1,$half); $m2=array_sum(array_slice($vs,$half))/max(1,$n-$half); $trend=$m2>$m1*1.03?'rising':($m2<$m1*0.97?'falling':'stable'); $out[]=sprintf('%s: %d readings, min %.2f, mean %.2f, max %.2f %s, %s across the period (%s).', $s['label'],$n,min($vs),$mean,max($vs),$s['unit'],$trend,$s['mode']==='live'?'live data':'placeholder data'); } return implode(' ',$out); } function ai_deterministic(string $prompt): array { $p=mb_strtolower($prompt); $hits=[]; foreach(ai_catalogue() as $c){ $words=array_filter(preg_split('/[^a-zà-ÿ0-9]+/u', mb_strtolower($c['label'])), fn($w)=>mb_strlen($w)>3); $score=0; foreach($words as $w) if(str_contains($p,$w)) $score++; 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) if(str_contains($p,$k)&&str_contains(mb_strtolower($c['label']),$syn)) $score++; if($score>0) $hits[$c['code']]=$score; } arsort($hits); $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); if(!$streams) $streams=['ac_impact_activity','ac_control_activity']; $days = str_contains($p,'year')?365:(str_contains($p,'quarter')||str_contains($p,'90')?90:(str_contains($p,'week')?7:30)); $spec=ai_validate_spec(['streams'=>$streams,'from'=>date('Y-m-d',strtotime("-$days days")),'to'=>date('Y-m-d')]); $text='Deterministic mode (no language model configured). Matched your question to the streams below and computed summary statistics. '.ai_stats_text($spec); return ['spec'=>$spec,'text'=>$text,'model'=>'deterministic-v0']; } function ai_http_post(string $url, array $headers, string $body): array { /* returns [status:int, body:string, err:string]. curl first (works even when allow_url_fopen is off), stream fallback. */ if(function_exists('curl_init')){ $ch=curl_init($url); curl_setopt_array($ch,[CURLOPT_POST=>true,CURLOPT_POSTFIELDS=>$body,CURLOPT_HTTPHEADER=>$headers, CURLOPT_RETURNTRANSFER=>true,CURLOPT_TIMEOUT=>30,CURLOPT_CONNECTTIMEOUT=>10]); $res=curl_exec($ch); $st=(int)curl_getinfo($ch,CURLINFO_RESPONSE_CODE); $err=curl_error($ch); curl_close($ch); return [$st, $res===false?'':$res, $res===false?('curl: '.$err):'']; } if(!ini_get('allow_url_fopen')) return [0,'','allow_url_fopen is off and curl is missing: enable one in the hosting panel']; $ctx=stream_context_create(['http'=>['method'=>'POST','timeout'=>30,'ignore_errors'=>true, 'header'=>implode("\r\n",$headers)."\r\n",'content'=>$body]]); $res=@file_get_contents($url,false,$ctx); $st=0; foreach(($http_response_header??[]) as $h){ if(preg_match('#^HTTP/\S+\s+(\d+)#',$h,$m)){ $st=(int)$m[1]; } } return [$st, $res===false?'':$res, $res===false?'no response (outbound HTTPS may be blocked by the host)':'']; } function ai_api(string $prompt): array { $cat=json_encode(array_map(fn($c)=>$c['code'].' = '.$c['label'].' ('.$c['unit'].')',ai_catalogue())); $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."; $provider=defined('AI_PROVIDER')?AI_PROVIDER:'openai'; if($provider==='anthropic'){ $body=json_encode(['model'=>AI_MODEL,'max_tokens'=>600,'system'=>$system,'messages'=>[['role'=>'user','content'=>mb_substr($prompt,0,1000)]]]); [$st,$res,$err]=ai_http_post('https://api.anthropic.com/v1/messages', ['Content-Type: application/json','x-api-key: '.AI_API_KEY,'anthropic-version: 2023-06-01'],$body); $txt=$st===200?(json_decode($res,true)['content'][0]['text']??''):''; } else { $body=json_encode(['model'=>AI_MODEL,'messages'=>[['role'=>'system','content'=>$system],['role'=>'user','content'=>mb_substr($prompt,0,1000)]],'temperature'=>0.2]); [$st,$res,$err]=ai_http_post(rtrim(AI_ENDPOINT,'/').'/chat/completions', ['Content-Type: application/json','Authorization: Bearer '.AI_API_KEY],$body); $txt=$st===200?(json_decode($res,true)['choices'][0]['message']['content']??''):''; } if(!$txt){ $why = $err ?: ($st>0 ? ('provider returned HTTP '.$st.(($e=json_decode($res,true)['error']['message']??'')!==''?': '.mb_substr($e,0,160):'')) : 'no response'); log_activity('system','ai_api_fail',mb_substr($why,0,300)); $fb=ai_deterministic($prompt); $fb['text']='Model call failed ('.$why.'); deterministic fallback. '.$fb['text']; return $fb; } $j=json_decode(trim(preg_replace('/^```json|```$/m','',trim($txt))),true); if(!is_array($j)) { $fb=ai_deterministic($prompt); $fb['text']='Model returned an unusable reply; deterministic fallback. '.$fb['text']; return $fb; } $spec=ai_validate_spec($j); $text=trim((string)($j['commentary']??'')).' '.ai_stats_text($spec); return ['spec'=>$spec,'text'=>$text,'model'=>(defined('AI_PROVIDER')?AI_PROVIDER:'openai').':'.AI_MODEL]; } function ai_ask(string $prompt): array { $prompt=trim(mb_substr($prompt,0,600)); $ans = ai_mode()==='api' ? ai_api($prompt) : ai_deterministic($prompt); db()->prepare('INSERT INTO analysis_runs(ts,question,output,model_label,exploratory) VALUES(NOW(),?,?,?,1)') ->execute([$prompt, mb_substr($ans['text'],0,2000), $ans['model']]); log_activity('system','ask_grimnir',$ans['model']); return $ans; }