Research Labs

2026-09-17 20:37 UTCopen field data · live

inc/ai.php

Snapshot 2026.09.17-a2894de (current) · 117 lines · 7,575 bytes · tree · plain text

SHA-256 8903577c20328e7acff479c720d7e3787e3ffa40b477ed2e1eb7ea1352e7bc5e
Functions: 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. */
9require_once __DIR__.'/helpers.php';
10
11function ai_mode(): string { return defined('AI_MODE') ? AI_MODE : 'deterministic'; }
12
13function 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
17function 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
26function 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
44function 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
63function 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
80function 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
109function 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.