generate_report.php 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556
  1. <?php
  2. // generate_report.php (same-folder version)
  3. // Place alongside tas_spp_index.json
  4. ini_set('display_errors','0'); error_reporting(E_ALL);
  5. header('Content-Type: application/json; charset=utf-8');
  6. // ---------- Helpers ----------
  7. // Build a quick lookup map from an array of zones
  8. function build_zone_index(array $zones): array {
  9. $idx = [];
  10. foreach ($zones as $z) {
  11. $name = trim((string)($z['name'] ?? ''));
  12. if ($name === '') continue;
  13. $canon = $z; $canon['name'] = $name; // ensure name present
  14. $labels = [$name];
  15. if (!empty($z['short_name'])) $labels[] = $z['short_name'];
  16. if (!empty($z['aliases']) && is_array($z['aliases']))
  17. $labels = array_merge($labels, $z['aliases']);
  18. foreach ($labels as $lab) {
  19. $k = mb_strtolower(trim((string)$lab));
  20. if ($k !== '') $idx[$k] = $canon;
  21. }
  22. }
  23. return $idx;
  24. }
  25. // Build a quick lookup map from codes (object or array) + optional crosswalk
  26. function build_code_index(array $codes, array $crosswalkAliases = []): array {
  27. $idx = [];
  28. $add = function($label, $entry) use (&$idx) {
  29. $k = mb_strtolower(trim((string)$label));
  30. if ($k !== '') $idx[$k] = $entry;
  31. };
  32. // Dict-style (e.g., "C13": {...}) or array of objects
  33. $isAssoc = !empty($codes) && array_keys($codes) !== range(0, count($codes)-1);
  34. if ($isAssoc) {
  35. foreach ($codes as $key => $c) {
  36. $name = $c['name'] ?? $c['title'] ?? $key;
  37. $entry = $c; $entry['name'] = $name; $entry['id'] = $key;
  38. $add($key, $entry);
  39. $add($name, $entry);
  40. if (!empty($c['aliases']) && is_array($c['aliases']))
  41. foreach ($c['aliases'] as $al) $add($al, $entry);
  42. }
  43. } else {
  44. foreach ($codes as $c) {
  45. $name = $c['name'] ?? $c['title'] ?? '';
  46. if ($name === '') continue;
  47. $entry = $c; $entry['name'] = $name;
  48. $add($name, $entry);
  49. if (!empty($c['aliases']) && is_array($c['aliases']))
  50. foreach ($c['aliases'] as $al) $add($al, $entry);
  51. }
  52. }
  53. // Crosswalk (e.g., "bushfire" -> "C13")
  54. foreach ($crosswalkAliases as $alias => $target) {
  55. $targetKey = mb_strtolower(trim((string)$target));
  56. if (isset($idx[$targetKey])) $add($alias, $idx[$targetKey]);
  57. }
  58. return $idx;
  59. }
  60. function load_index(string $path) {
  61. if (!file_exists($path)) throw new RuntimeException("tas_spp_index.json not found");
  62. $raw = file_get_contents($path);
  63. // Strip BOM
  64. if (substr($raw,0,3) === "\xEF\xBB\xBF") $raw = substr($raw,3); // strip UTF-8 BOM
  65. $json = json_decode($raw, true);
  66. if (!is_array($json)) throw new RuntimeException("tas_spp_index.json is not valid JSON");
  67. return $json;
  68. }
  69. function spp_url(string $section) {
  70. static $tpso;
  71. if ($tpso === null) {
  72. $idx = json_decode(file_get_contents(__DIR__.'/tas_spp_index.json'), true);
  73. $tpso = $idx['tpso'] ?? [];
  74. }
  75. $id = $tpso['map'][$section] ?? null;
  76. $base = $tpso['base'] ?? 'https://tpso.planning.tas.gov.au/tpso/external/planning-scheme-viewer/30/section/';
  77. $eff = $tpso['effectiveForDate'] ?? '2025-08-27';
  78. return $id ? ($base . $id . '?effectiveForDate=' . rawurlencode($eff))
  79. : ($base . rawurlencode($section) . '?effectiveForDate=' . rawurlencode($eff));
  80. }
  81. function tpso_url_for(string $code, array $idx, ?string $fallbackType = null): ?string {
  82. $base = $idx['tpso']['base'] ?? 'https://tpso.planning.tas.gov.au/tpso/external/planning-scheme-viewer/30/section/';
  83. $eff = $idx['tpso']['effectiveForDate'] ?? '2025-08-27';
  84. $map = (array)($idx['tpso']['map'] ?? []);
  85. // Normalised key lookup (e.g. "8.1", "C13.6.2")
  86. $key = strtoupper(trim($code));
  87. if (isset($map[$key])) {
  88. return $base . $map[$key] . '?effectiveForDate=' . rawurlencode($eff);
  89. }
  90. // Fallback to an index page if provided
  91. $fbKey = $fallbackType === 'zone' ? 'ZONES_INDEX' :
  92. ($fallbackType === 'code' ? 'CODES_INDEX' : null);
  93. if ($fbKey && isset($map[$fbKey])) {
  94. return $base . $map[$fbKey] . '?effectiveForDate=' . rawurlencode($eff);
  95. }
  96. return null; // render plain text if we don't know the id yet
  97. }
  98. function render_clause_link(string $label, ?string $clauseCode, array $idx, ?string $fallbackType = null): string {
  99. if (!$clauseCode) return '';
  100. $url = tpso_url_for($clauseCode, $idx, $fallbackType);
  101. $labelEsc = htmlspecialchars($label);
  102. $codeEsc = htmlspecialchars($clauseCode);
  103. if ($url) {
  104. $urlEsc = htmlspecialchars($url);
  105. return "<li><strong>{$labelEsc}:</strong> <a href=\"{$urlEsc}\" target=\"_blank\" rel=\"noopener\">{$codeEsc}</a></li>";
  106. }
  107. // No mapping yet → show plain code (no broken link)
  108. return "<li><strong>{$labelEsc}:</strong> {$codeEsc}</li>";
  109. }
  110. // Normalise zone names so "General Residential" == "General Residential Zone"
  111. function norm_zone($s) {
  112. $s = trim(strtolower($s));
  113. $s = preg_replace('/\s+zone$/i','', $s);
  114. return $s;
  115. }
  116. // Fuzzy zone lookup against index (uses suffix stripping + contains matches)
  117. function find_zone($indexZones, $inputName) {
  118. $needle = norm_zone($inputName);
  119. foreach ($indexZones as $z) {
  120. if (empty($z['name'])) continue;
  121. if (norm_zone($z['name']) === $needle) return $z;
  122. }
  123. foreach ($indexZones as $z) {
  124. $n = norm_zone($z['name'] ?? '');
  125. if ($n !== '' && ($n === $needle || str_contains($n, $needle) || str_contains($needle, $n))) return $z;
  126. }
  127. return null;
  128. }
  129. // Map LIST overlay names to C-code ids (heuristics)
  130. function code_id_from_name($name) {
  131. $n = strtolower($name);
  132. if (str_contains($n,'bushfire')) return 'C13';
  133. if (str_contains($n,'heritage')) return 'C6';
  134. if (str_contains($n,'parking') || str_contains($n,'transport')) return 'C2';
  135. if (str_contains($n,'road') || str_contains($n,'rail')) return 'C3';
  136. if (str_contains($n,'telecom')) return 'C5';
  137. if (str_contains($n,'flood')) return 'C12';
  138. if (str_contains($n,'coastal inund')) return 'C11';
  139. if (str_contains($n,'coastal ero')) return 'C10';
  140. if (str_contains($n,'landslide') || str_contains($n,'landslip')) return 'C15';
  141. if (str_contains($n,'contamin')) return 'C14';
  142. if (str_contains($n,'electric') && str_contains($n,'trans')) return 'C4';
  143. if (str_contains($n,'scenic')) return 'C8';
  144. if (str_contains($n,'natural') || str_contains($n,'biodiv') || str_contains($n,'waterway') || str_contains($n,'coastal protection')) return 'C7';
  145. if (str_contains($n,'attenuation')) return 'C9';
  146. if (str_contains($n,'sign')) return 'C1';
  147. if (str_contains($n,'airport') || str_contains($n,'airfield')) return 'C16';
  148. return null; // unknown -> leave as plain text
  149. }
  150. try {
  151. // ---------- Input ----------
  152. $in = json_decode(file_get_contents('php://input'), true);
  153. if (!$in || empty($in['address']) || empty($in['planning_zones'])) {
  154. http_response_code(400);
  155. echo json_encode(['ok'=>false,'error'=>'Missing property payload']); exit;
  156. }
  157. // ---------- DEBUG ----------
  158. $debug = !empty($in['debug']);
  159. $log = [];
  160. $DLOG = function($m) use (&$log) {
  161. $s = is_string($m) ? $m : json_encode($m, JSON_UNESCAPED_SLASHES);
  162. $log[] = $s;
  163. error_log('[AIReport] '.$s);
  164. };
  165. $DLOG(['received_zones'=>$in['planning_zones'] ?? [], 'received_codes'=>$in['planning_codes'] ?? []]);
  166. // ---------- Load index ----------
  167. $indexPath = __DIR__ . '/tas_spp_index.json';
  168. if (!is_file($indexPath)) throw new RuntimeException('tas_spp_index.json not found');
  169. $idx = json_decode(file_get_contents($indexPath), true);
  170. if (!$idx) throw new RuntimeException('tas_spp_index.json is not valid JSON');
  171. $sppVersion = $idx['meta']['version'] ?? 'unknown';
  172. $zonesIndexArr = (array)($idx['zones'] ?? []);
  173. $codesIndex = (array)($idx['codes'] ?? []);
  174. $aliasMap = (array)($idx['crosswalk']['aliases'] ?? []);
  175. /* ---------- Names (as shown at top of report) ---------- */
  176. $zoneNames = array_values((array)$in['planning_zones']);
  177. $codeNames = array_values((array)$in['planning_codes']);
  178. /* ---------- Zone matching (fuzzy) ---------- */
  179. // map zones by case-insensitive name match against idx["zones"] (array of objects)
  180. $zoneBlobs = [];
  181. foreach ($zoneNames as $zn) {
  182. $match = find_zone($zonesIndexArr, $zn);
  183. if ($match) {
  184. $zoneBlobs[] = $match;
  185. $DLOG(['zone_match'=>$zn, 'matched_to'=>$match['name'] ?? null, 'doc_section'=>$match['doc_section'] ?? null]);
  186. } else {
  187. $zoneBlobs[] = ['name'=>$zn]; // fall back to plain name
  188. $DLOG(['zone_match'=>$zn, 'matched_to'=>null]);
  189. }
  190. }
  191. $codeBlobs = [];
  192. $alias = (array)($idx['crosswalk']['aliases'] ?? []);
  193. $codesIndex = (array)($idx['codes'] ?? []);
  194. foreach ($codeNames as $cn) {
  195. $matchKey = null;
  196. // try direct key like "C13"
  197. if (isset($codesIndex[$cn])) { $matchKey = $cn; }
  198. // try alias like "bushfire" -> "C13"
  199. else {
  200. foreach ($alias as $k=>$v) {
  201. if (strcasecmp($k, $cn)===0 || strcasecmp($v, $cn)===0) { $matchKey = $v; break; }
  202. }
  203. }
  204. $codeBlobs[] = $matchKey && isset($codesIndex[$matchKey])
  205. ? ['name'=>$matchKey] + $codesIndex[$matchKey]
  206. : ['name'=>$cn];
  207. }
  208. /* ---------- Code matching (heuristic) ---------- */
  209. // Build friendly code objects preserving the original label + inferred id (if any)
  210. $codeMatches = [];
  211. foreach ($codeNames as $label) {
  212. $id = null;
  213. // direct dictionary hit (e.g. "C13")
  214. if (isset($codesIndex[$label])) $id = $label;
  215. // alias map hit (e.g. "bushfire" -> "C13")
  216. if (!$id) {
  217. foreach ($aliasMap as $k=>$v) {
  218. if (strcasecmp($k, $label)===0 || strcasecmp($v, $label)===0) { $id = $v; break; }
  219. }
  220. }
  221. // heuristic from LIST label (e.g. "Safeguarding of Airports Code" -> "C16")
  222. if (!$id) {
  223. $heur = code_id_from_name($label);
  224. if ($heur && isset($codesIndex[$heur])) $id = $heur;
  225. }
  226. $def = $id && isset($codesIndex[$id]) ? $codesIndex[$id] : null;
  227. $codeMatches[] = ['id'=>$id, 'label'=>$label, 'def'=>$def];
  228. $DLOG(['code_match'=>$label, 'id'=>$id, 'has_key_clauses'=> $def && !empty($def['key_clauses'])]);
  229. }
  230. // ----- Intended-use rules (optional section) -----
  231. $intended = trim((string)($in['intended_use'] ?? ''));
  232. $intentRows = [];
  233. if ($intended !== '') {
  234. // Load rules
  235. $rulesPath = __DIR__ . '/tas_use_rules.json';
  236. $useRules = is_file($rulesPath) ? json_decode(file_get_contents($rulesPath), true) : null;
  237. if (is_array($useRules)) {
  238. // Normalise the user's intended use via aliases
  239. $canonUse = strtolower($intended);
  240. foreach (($useRules['aliases'] ?? []) as $k => $arr) {
  241. if (strtolower($k) === $canonUse) { $canonUse = $k; break; }
  242. foreach ((array)$arr as $alt) {
  243. if (strtolower($alt) === strtolower($intended)) { $canonUse = $k; break 2; }
  244. }
  245. }
  246. foreach ($zoneBlobs as $z) {
  247. $zname = $z['name'] ?? '';
  248. $zrules = $useRules['rules'][$zname] ?? [];
  249. $match = null;
  250. foreach ($zrules as $r) {
  251. if (strtolower($r['use'] ?? '') === $canonUse) { $match = $r; break; }
  252. }
  253. // Build a row per zone
  254. $useTable = $z['key_clauses']['use_table'] ?? ($match['refs']['use_table'] ?? null);
  255. $standards = $match['refs']['standards'] ?? [];
  256. $statusCode = $match['status'] ?? 'CHECK';
  257. $statusText = $useRules['enums']['status'][$statusCode] ?? 'Check Use Table';
  258. $intentRows[] = [
  259. 'zone' => $zname ?: 'Zone',
  260. 'status' => $statusText,
  261. 'use_table' => $useTable,
  262. 'standards' => $standards,
  263. 'notes' => $match['notes'] ?? null
  264. ];
  265. }
  266. }
  267. }
  268. /* ---------- Small trimmed RAW sample for future LLM ---------- */
  269. // ---- Prepare RAW (optional) for LLM context; trim to keep prompts lean ----
  270. $raw = $in['raw'] ?? null;
  271. // pick first feature’s attrs for each layer, keep up to ~40 keys to avoid giant prompts
  272. $pickFirst = function($arr){
  273. $a = (array)$arr;
  274. if (isset($a[0]) && is_array($a[0])) {
  275. $attrs = $a[0];
  276. return array_slice($attrs, 0, 40, true);
  277. }
  278. return null;
  279. };
  280. $raw_for_prompt = [
  281. 'parcel_attrs' => $pickFirst($raw['parcels'] ?? []),
  282. 'lga_attrs' => $pickFirst($raw['lga'] ?? []),
  283. 'zone_attrs' => $pickFirst($raw['zones'] ?? []),
  284. 'code_attrs' => $pickFirst($raw['codes'] ?? []),
  285. ];
  286. // ---- Build a concise, LLM-friendly context blob (you can expand later) ----
  287. $context = [
  288. 'address' => (string)$in['address'],
  289. 'pid' => $in['pid'] ?? null,
  290. 'title_id' => $in['title_id'] ?? null,
  291. 'council' => $in['council'] ?? null,
  292. 'planning_scheme' => $in['planning_scheme'] ?? null,
  293. 'zones' => array_map(fn($z)=>[
  294. 'name' => $z['name'] ?? null,
  295. 'doc_section' => $z['doc_section'] ?? null,
  296. 'key_clauses' => $z['key_clauses'] ?? null], $zoneBlobs),
  297. 'codes' => array_map(fn($c)=>[
  298. 'id' => $c['id'] ?? null,
  299. 'name' => $c['name'] ?? null,
  300. 'key_clauses' => $c['key_clauses'] ?? null], $codeMatches),
  301. 'area' => [
  302. 'sqm' => $in['total_area']['sqm'] ?? null,
  303. 'sqm_label' => $in['total_area']['sqm_label'] ?? null,
  304. 'ha' => $in['total_area']['ha'] ?? null,
  305. 'ha_label' => $in['total_area']['ha_label'] ?? null,
  306. ],
  307. 'spp_version' => $sppVersion,
  308. 'raw_sample' => $raw_for_prompt, // safe, trimmed
  309. ];
  310. // ====== (optional) Hook an LLM here ======
  311. // Leave disabled by default. When ready, implement call_llm() to your provider.
  312. $USE_LLM = false;
  313. $llm_html = null;
  314. if ($USE_LLM) {
  315. $prompt = [
  316. "role" => "user",
  317. "content" =>
  318. "You are an expert planning assistant for the Tasmanian Planning Scheme (SPPs). Please don't make anything up, strictly stick to the wording and context of the State Planning Provisions.".
  319. "Using the JSON context below, write a concise property planning summary: ".
  320. "1) Identify applicable zones and codes (with clause references from key_clauses). ".
  321. "2) For an *intended use* placeholder (like dwelling, shed, café), outline likely status (Permitted/Discretionary/Prohibited) and key standards to check. ".
  322. "3) Include hyperlinks to the relevant SPP sections when doc_section is available (base URL: https://tpso.planning.tas.gov.au/tpso/external/planning-scheme-viewer/30/section/<DOC>?effectiveForDate={$sppVersion}). ".
  323. "4) Keep it practical, note uncertainty and that LPS may vary locally.\n\n".
  324. "Context JSON:\n".
  325. json_encode($context, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES)
  326. ];
  327. // $llm_html = call_llm([$prompt]); // TODO: implement your provider call
  328. }
  329. /* ---------- Map image (accept data URL or raw base64) ---------- */
  330. $mapSrc = null;
  331. if (!empty($in['map_png'])) {
  332. $mapSrc = (stripos($in['map_png'], 'data:image') === 0)
  333. ? $in['map_png']
  334. : ('data:image/png;base64,' . $in['map_png']);
  335. }
  336. // ---------- HTML (template) ----------
  337. ob_start(); ?>
  338. <div>
  339. <h3 class="mb-2">AI-Assisted Property Report (Tasmania)</h3>
  340. <div class="text-muted mb-3">
  341. Generated <?= htmlspecialchars(gmdate('Y-m-d H:i')) ?> · SPP version: <?= htmlspecialchars($sppVersion) ?>
  342. </div>
  343. <?php if ($mapSrc): ?>
  344. <div class="mb-3">
  345. <img src="<?= htmlspecialchars($mapSrc) ?>" alt="Property map" style="max-width:100%;border-radius:.5rem;border:1px solid rgba(0,0,0,.1)" />
  346. </div>
  347. <?php endif; ?>
  348. <h5 class="mb-1"><?= htmlspecialchars($in['address']) ?></h5>
  349. <ul class="list-unstyled mb-3">
  350. <?php if (!empty($in['pid'])): ?><li><strong>PID:</strong> <?= htmlspecialchars($in['pid']) ?></li><?php endif; ?>
  351. <?php if (!empty($in['title_id'])): ?><li><strong>Title:</strong> <?= htmlspecialchars($in['title_id']) ?></li><?php endif; ?>
  352. <?php if (!empty($in['council'])): ?><li><strong>Council (LGA):</strong> <?= htmlspecialchars($in['council']) ?></li><?php endif; ?>
  353. <?php if (!empty($in['total_area']['sqm_label'])): ?>
  354. <li><strong>Area:</strong> <?= htmlspecialchars($in['total_area']['sqm_label']) ?>
  355. <?php if (!empty($in['total_area']['ha_label'])): ?> (<?= htmlspecialchars($in['total_area']['ha_label']) ?>)<?php endif; ?>
  356. </li>
  357. <?php endif; ?>
  358. </ul>
  359. <h6>Primary zone & overlays</h6>
  360. <p>
  361. <strong>Zone(s):</strong>
  362. <?= htmlspecialchars(implode(', ', $zoneNames)) ?: '—' ?><br/>
  363. <strong>Overlays/Codes:</strong>
  364. <?= htmlspecialchars(implode(', ', $codeNames)) ?: '—' ?>
  365. </p>
  366. <h6>Key scheme parts to review</h6>
  367. <ul>
  368. <?php foreach ($zoneBlobs as $z): ?>
  369. <li>
  370. <strong><?= htmlspecialchars($z['name'] ?? 'Zone') ?>:</strong>
  371. <?php if (!empty($z['key_clauses'])): ?>
  372. <ul class="mb-1">
  373. <?= render_clause_link('Purpose', $z['key_clauses']['purpose'] ?? null, $idx, 'zone') ?>
  374. <?= render_clause_link('Use table', $z['key_clauses']['use_table'] ?? null, $idx, 'zone') ?>
  375. <?= render_clause_link('Use standards', $z['key_clauses']['use_standards'] ?? null, $idx, 'zone') ?>
  376. <?= render_clause_link('Development standards — dwellings', $z['key_clauses']['dev_standards_dwellings'] ?? null, $idx, 'zone') ?>
  377. <?= render_clause_link('Development standards — buildings & works', $z['key_clauses']['dev_standards_buildings_works'] ?? null, $idx, 'zone') ?>
  378. <?= render_clause_link('Subdivision', $z['key_clauses']['subdivision'] ?? null, $idx, 'zone') ?>
  379. </ul>
  380. <?php if (!empty($z['doc_section'])): $zUrl = tpso_url_for($z['doc_section'], $idx, 'zone'); if ($zUrl): ?>
  381. <div class="small">Full zone chapter: <a href="<?= htmlspecialchars($zUrl) ?>" target="_blank" rel="noopener"><?= htmlspecialchars($z['doc_section']) ?></a></div>
  382. <?php else: ?>
  383. <div class="small">Full zone chapter: <?= htmlspecialchars($z['doc_section']) ?></div>
  384. <?php endif; endif; ?>
  385. <?php else: ?>
  386. Refer to the State Planning Provisions zone chapter.
  387. <?php endif; ?>
  388. </li>
  389. <?php endforeach; ?>
  390. <?php foreach ($codeMatches as $cm): ?>
  391. <li>
  392. <strong><?= htmlspecialchars($cm['label']) ?><?= $cm['id'] ? ' ('.$cm['id'].')' : '' ?>:</strong>
  393. <?php if ($cm['def'] && !empty($cm['def']['key_clauses'])): $kc = $cm['def']['key_clauses']; ?>
  394. <ul class="mb-1">
  395. <?= render_clause_link('Purpose', $kc['purpose'] ?? null, $idx, 'code') ?>
  396. <?= render_clause_link('Application', $kc['application'] ?? null, $idx, 'code') ?>
  397. <?= render_clause_link('Definitions', $kc['definitions'] ?? null, $idx, 'code') ?>
  398. <?= render_clause_link('Exempt development / use', ($kc['exempt_development'] ?? $kc['exempt_use'] ?? null), $idx, 'code') ?>
  399. <?= render_clause_link('Use standards', $kc['use_standards'] ?? null, $idx, 'code') ?>
  400. <?php if (!empty($kc['use_standards_detail']) && is_array($kc['use_standards_detail'])): ?>
  401. <?php foreach ($kc['use_standards_detail'] as $it):
  402. $id = $it['id'] ?? null;
  403. if ($id) {
  404. $u = tpso_url_for($id, $idx, 'code');
  405. if ($u) {
  406. echo '<li><a href="'.htmlspecialchars($u).'" target="_blank" rel="noopener">'.htmlspecialchars($id).'</a>'
  407. .($title ? ' – '.htmlspecialchars($title) : '')
  408. .'</li>';
  409. } else {
  410. echo '<li>'.htmlspecialchars($id).($title ? ' – '.htmlspecialchars($title) : '').'</li>';
  411. }
  412. }
  413. continue; ?>
  414. <li>
  415. <a href="<?= htmlspecialchars(spp_url($id)) ?>" target="_blank" rel="noopener"><?= htmlspecialchars($id) ?></a>
  416. <?= $title ? ' – '.htmlspecialchars($title) : '' ?>
  417. </li>
  418. <?php endforeach; ?>
  419. <?php endif; ?>
  420. <?= render_clause_link('Development standards', $kc['development_standards'] ?? null, $idx, 'code') ?>
  421. <?php if (!empty($kc['development_standards_detail']) && is_array($kc['development_standards_detail'])): ?>
  422. <?php foreach ($kc['development_standards_detail'] as $it):
  423. $id = $it['id'] ?? null;
  424. if ($id) {
  425. $u = tpso_url_for($id, $idx, 'code');
  426. if ($u) {
  427. echo '<li><a href="'.htmlspecialchars($u).'" target="_blank" rel="noopener">'.htmlspecialchars($id).'</a>'
  428. .($title ? ' – '.htmlspecialchars($title) : '')
  429. .'</li>';
  430. } else {
  431. echo '<li>'.htmlspecialchars($id).($title ? ' – '.htmlspecialchars($title) : '').'</li>';
  432. }
  433. }
  434. continue; ?>
  435. <li>
  436. <a href="<?= htmlspecialchars(spp_url($id)) ?>" target="_blank" rel="noopener"><?= htmlspecialchars($id) ?></a>
  437. <?= $title ? ' – '.htmlspecialchars($title) : '' ?>
  438. </li>
  439. <?php endforeach; ?>
  440. <?php endif; ?>
  441. </ul>
  442. <?php else: ?>
  443. Review the relevant SPP code chapter for triggers and standards.
  444. <?php endif; ?>
  445. </li>
  446. <?php endforeach; ?>
  447. </ul>
  448. <?php if ($intended !== ''): ?>
  449. <h6>Intended use (beta): <?= htmlspecialchars($intended) ?></h6>
  450. <ul class="mb-3">
  451. <?php foreach ($intentRows as $row): ?>
  452. <li>
  453. <strong><?= htmlspecialchars($row['zone']) ?>:</strong>
  454. <?= htmlspecialchars($row['status']) ?> —
  455. <?php if (!empty($row['use_table'])): ?>
  456. <a href="<?= htmlspecialchars(spp_url($row['use_table'])) ?>" target="_blank" rel="noopener">
  457. Use Table (<?= htmlspecialchars($row['use_table']) ?>)
  458. </a>
  459. <?php else: ?>Use Table<?php endif; ?>
  460. <?php if (!empty($row['standards'])): ?>
  461. · standards
  462. <?php foreach ($row['standards'] as $i => $st): ?>
  463. <a href="<?= htmlspecialchars(spp_url($st)) ?>" target="_blank" rel="noopener"><?= htmlspecialchars($st) ?></a><?= $i < count($row['standards']) - 1 ? ', ' : '' ?>
  464. <?php endforeach; ?>
  465. <?php endif; ?>
  466. <?php if (!empty($row['notes'])): ?> · <?= htmlspecialchars($row['notes']) ?><?php endif; ?>
  467. </li>
  468. <?php endforeach; ?>
  469. </ul>
  470. <?php endif; ?>
  471. <h6>Typical constraints to check</h6>
  472. <ul>
  473. <li>Use Table: whether your intended use is Permitted, Discretionary, or Prohibited.</li>
  474. <li>Zone development standards: height, setbacks, site coverage, privacy, access and parking.</li>
  475. <li>Code triggers (if mapped): bushfire, heritage, inundation, landslide, biodiversity, scenic/attenuation, coastal, roads/rail, parking.</li>
  476. <li>Local Provisions Schedule (<?= htmlspecialchars($in['council'] ?? 'Council') ?>): any local variations, Specific Area Plans, heritage list entries.</li>
  477. </ul>
  478. <h6>Next steps</h6>
  479. <ol>
  480. <li>Confirm zone and all overlays using the LIST map and <?= htmlspecialchars($in['council'] ?? 'Council') ?> LPS.</li>
  481. <li>Check your intended use against the zone Use Table; note any qualifications.</li>
  482. <li>Review development and code standards; obtain specialist reports where required.</li>
  483. <li>Seek pre-application advice from <?= htmlspecialchars($in['council'] ?? 'the local Council') ?> planning.</li>
  484. </ol>
  485. <div class="text-muted small mt-3">Disclaimer: This report summarises public datasets and references the Tasmanian Planning Scheme (SPPs). It is not legal advice. Verify all controls with the responsible authority.</div>
  486. </div>
  487. <?php if ($llm_html): ?>
  488. <div class="mt-3"><?= $llm_html ?></div>
  489. <?php endif; ?>
  490. <div class="mt-3">
  491. <?php if (!empty($in['debug'])): ?>
  492. <details class="mt-3"><summary>Debug context</summary><pre class="small" style="white-space:pre-wrap"><?= htmlspecialchars(json_encode($context, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES)) ?></pre></details>
  493. <?php endif; ?>
  494. </div>
  495. <?php
  496. $html = ob_get_clean();
  497. echo json_encode(['ok'=>true,'html'=>$html], JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE);
  498. } catch (Throwable $e) {
  499. http_response_code(500);
  500. echo json_encode(['ok'=>false,'error'=>$e->getMessage()]);
  501. }
  502. ?>