$c) {
$name = $c['name'] ?? $c['title'] ?? $key;
$entry = $c; $entry['name'] = $name; $entry['id'] = $key;
$add($key, $entry);
$add($name, $entry);
if (!empty($c['aliases']) && is_array($c['aliases']))
foreach ($c['aliases'] as $al) $add($al, $entry);
}
} else {
foreach ($codes as $c) {
$name = $c['name'] ?? $c['title'] ?? '';
if ($name === '') continue;
$entry = $c; $entry['name'] = $name;
$add($name, $entry);
if (!empty($c['aliases']) && is_array($c['aliases']))
foreach ($c['aliases'] as $al) $add($al, $entry);
}
}
// Crosswalk (e.g., "bushfire" -> "C13")
foreach ($crosswalkAliases as $alias => $target) {
$targetKey = mb_strtolower(trim((string)$target));
if (isset($idx[$targetKey])) $add($alias, $idx[$targetKey]);
}
return $idx;
}
function load_index(string $path) {
if (!file_exists($path)) throw new RuntimeException("tas_spp_index.json not found");
$raw = file_get_contents($path);
// Strip BOM
if (substr($raw,0,3) === "\xEF\xBB\xBF") $raw = substr($raw,3); // strip UTF-8 BOM
$json = json_decode($raw, true);
if (!is_array($json)) throw new RuntimeException("tas_spp_index.json is not valid JSON");
return $json;
}
function spp_url(string $section) {
static $tpso;
if ($tpso === null) {
$idx = json_decode(file_get_contents(__DIR__.'/tas_spp_index.json'), true);
$tpso = $idx['tpso'] ?? [];
}
$id = $tpso['map'][$section] ?? null;
$base = $tpso['base'] ?? 'https://tpso.planning.tas.gov.au/tpso/external/planning-scheme-viewer/30/section/';
$eff = $tpso['effectiveForDate'] ?? '2025-08-27';
return $id ? ($base . $id . '?effectiveForDate=' . rawurlencode($eff))
: ($base . rawurlencode($section) . '?effectiveForDate=' . rawurlencode($eff));
}
function tpso_url_for(string $code, array $idx, ?string $fallbackType = null): ?string {
$base = $idx['tpso']['base'] ?? 'https://tpso.planning.tas.gov.au/tpso/external/planning-scheme-viewer/30/section/';
$eff = $idx['tpso']['effectiveForDate'] ?? '2025-08-27';
$map = (array)($idx['tpso']['map'] ?? []);
// Normalised key lookup (e.g. "8.1", "C13.6.2")
$key = strtoupper(trim($code));
if (isset($map[$key])) {
return $base . $map[$key] . '?effectiveForDate=' . rawurlencode($eff);
}
// Fallback to an index page if provided
$fbKey = $fallbackType === 'zone' ? 'ZONES_INDEX' :
($fallbackType === 'code' ? 'CODES_INDEX' : null);
if ($fbKey && isset($map[$fbKey])) {
return $base . $map[$fbKey] . '?effectiveForDate=' . rawurlencode($eff);
}
return null; // render plain text if we don't know the id yet
}
function render_clause_link(string $label, ?string $clauseCode, array $idx, ?string $fallbackType = null): string {
if (!$clauseCode) return '';
$url = tpso_url_for($clauseCode, $idx, $fallbackType);
$labelEsc = htmlspecialchars($label);
$codeEsc = htmlspecialchars($clauseCode);
if ($url) {
$urlEsc = htmlspecialchars($url);
return "
{$labelEsc}: {$codeEsc}";
}
// No mapping yet → show plain code (no broken link)
return "{$labelEsc}: {$codeEsc}";
}
// Normalise zone names so "General Residential" == "General Residential Zone"
function norm_zone($s) {
$s = trim(strtolower($s));
$s = preg_replace('/\s+zone$/i','', $s);
return $s;
}
// Fuzzy zone lookup against index (uses suffix stripping + contains matches)
function find_zone($indexZones, $inputName) {
$needle = norm_zone($inputName);
foreach ($indexZones as $z) {
if (empty($z['name'])) continue;
if (norm_zone($z['name']) === $needle) return $z;
}
foreach ($indexZones as $z) {
$n = norm_zone($z['name'] ?? '');
if ($n !== '' && ($n === $needle || str_contains($n, $needle) || str_contains($needle, $n))) return $z;
}
return null;
}
// Map LIST overlay names to C-code ids (heuristics)
function code_id_from_name($name) {
$n = strtolower($name);
if (str_contains($n,'bushfire')) return 'C13';
if (str_contains($n,'heritage')) return 'C6';
if (str_contains($n,'parking') || str_contains($n,'transport')) return 'C2';
if (str_contains($n,'road') || str_contains($n,'rail')) return 'C3';
if (str_contains($n,'telecom')) return 'C5';
if (str_contains($n,'flood')) return 'C12';
if (str_contains($n,'coastal inund')) return 'C11';
if (str_contains($n,'coastal ero')) return 'C10';
if (str_contains($n,'landslide') || str_contains($n,'landslip')) return 'C15';
if (str_contains($n,'contamin')) return 'C14';
if (str_contains($n,'electric') && str_contains($n,'trans')) return 'C4';
if (str_contains($n,'scenic')) return 'C8';
if (str_contains($n,'natural') || str_contains($n,'biodiv') || str_contains($n,'waterway') || str_contains($n,'coastal protection')) return 'C7';
if (str_contains($n,'attenuation')) return 'C9';
if (str_contains($n,'sign')) return 'C1';
if (str_contains($n,'airport') || str_contains($n,'airfield')) return 'C16';
return null; // unknown -> leave as plain text
}
try {
// ---------- Input ----------
$in = json_decode(file_get_contents('php://input'), true);
if (!$in || empty($in['address']) || empty($in['planning_zones'])) {
http_response_code(400);
echo json_encode(['ok'=>false,'error'=>'Missing property payload']); exit;
}
// ---------- DEBUG ----------
$debug = !empty($in['debug']);
$log = [];
$DLOG = function($m) use (&$log) {
$s = is_string($m) ? $m : json_encode($m, JSON_UNESCAPED_SLASHES);
$log[] = $s;
error_log('[AIReport] '.$s);
};
$DLOG(['received_zones'=>$in['planning_zones'] ?? [], 'received_codes'=>$in['planning_codes'] ?? []]);
// ---------- Load index ----------
$indexPath = __DIR__ . '/tas_spp_index.json';
if (!is_file($indexPath)) throw new RuntimeException('tas_spp_index.json not found');
$idx = json_decode(file_get_contents($indexPath), true);
if (!$idx) throw new RuntimeException('tas_spp_index.json is not valid JSON');
$sppVersion = $idx['meta']['version'] ?? 'unknown';
$zonesIndexArr = (array)($idx['zones'] ?? []);
$codesIndex = (array)($idx['codes'] ?? []);
$aliasMap = (array)($idx['crosswalk']['aliases'] ?? []);
/* ---------- Names (as shown at top of report) ---------- */
$zoneNames = array_values((array)$in['planning_zones']);
$codeNames = array_values((array)$in['planning_codes']);
/* ---------- Zone matching (fuzzy) ---------- */
// map zones by case-insensitive name match against idx["zones"] (array of objects)
$zoneBlobs = [];
foreach ($zoneNames as $zn) {
$match = find_zone($zonesIndexArr, $zn);
if ($match) {
$zoneBlobs[] = $match;
$DLOG(['zone_match'=>$zn, 'matched_to'=>$match['name'] ?? null, 'doc_section'=>$match['doc_section'] ?? null]);
} else {
$zoneBlobs[] = ['name'=>$zn]; // fall back to plain name
$DLOG(['zone_match'=>$zn, 'matched_to'=>null]);
}
}
$codeBlobs = [];
$alias = (array)($idx['crosswalk']['aliases'] ?? []);
$codesIndex = (array)($idx['codes'] ?? []);
foreach ($codeNames as $cn) {
$matchKey = null;
// try direct key like "C13"
if (isset($codesIndex[$cn])) { $matchKey = $cn; }
// try alias like "bushfire" -> "C13"
else {
foreach ($alias as $k=>$v) {
if (strcasecmp($k, $cn)===0 || strcasecmp($v, $cn)===0) { $matchKey = $v; break; }
}
}
$codeBlobs[] = $matchKey && isset($codesIndex[$matchKey])
? ['name'=>$matchKey] + $codesIndex[$matchKey]
: ['name'=>$cn];
}
/* ---------- Code matching (heuristic) ---------- */
// Build friendly code objects preserving the original label + inferred id (if any)
$codeMatches = [];
foreach ($codeNames as $label) {
$id = null;
// direct dictionary hit (e.g. "C13")
if (isset($codesIndex[$label])) $id = $label;
// alias map hit (e.g. "bushfire" -> "C13")
if (!$id) {
foreach ($aliasMap as $k=>$v) {
if (strcasecmp($k, $label)===0 || strcasecmp($v, $label)===0) { $id = $v; break; }
}
}
// heuristic from LIST label (e.g. "Safeguarding of Airports Code" -> "C16")
if (!$id) {
$heur = code_id_from_name($label);
if ($heur && isset($codesIndex[$heur])) $id = $heur;
}
$def = $id && isset($codesIndex[$id]) ? $codesIndex[$id] : null;
$codeMatches[] = ['id'=>$id, 'label'=>$label, 'def'=>$def];
$DLOG(['code_match'=>$label, 'id'=>$id, 'has_key_clauses'=> $def && !empty($def['key_clauses'])]);
}
// ----- Intended-use rules (optional section) -----
$intended = trim((string)($in['intended_use'] ?? ''));
$intentRows = [];
if ($intended !== '') {
// Load rules
$rulesPath = __DIR__ . '/tas_use_rules.json';
$useRules = is_file($rulesPath) ? json_decode(file_get_contents($rulesPath), true) : null;
if (is_array($useRules)) {
// Normalise the user's intended use via aliases
$canonUse = strtolower($intended);
foreach (($useRules['aliases'] ?? []) as $k => $arr) {
if (strtolower($k) === $canonUse) { $canonUse = $k; break; }
foreach ((array)$arr as $alt) {
if (strtolower($alt) === strtolower($intended)) { $canonUse = $k; break 2; }
}
}
foreach ($zoneBlobs as $z) {
$zname = $z['name'] ?? '';
$zrules = $useRules['rules'][$zname] ?? [];
$match = null;
foreach ($zrules as $r) {
if (strtolower($r['use'] ?? '') === $canonUse) { $match = $r; break; }
}
// Build a row per zone
$useTable = $z['key_clauses']['use_table'] ?? ($match['refs']['use_table'] ?? null);
$standards = $match['refs']['standards'] ?? [];
$statusCode = $match['status'] ?? 'CHECK';
$statusText = $useRules['enums']['status'][$statusCode] ?? 'Check Use Table';
$intentRows[] = [
'zone' => $zname ?: 'Zone',
'status' => $statusText,
'use_table' => $useTable,
'standards' => $standards,
'notes' => $match['notes'] ?? null
];
}
}
}
/* ---------- Small trimmed RAW sample for future LLM ---------- */
// ---- Prepare RAW (optional) for LLM context; trim to keep prompts lean ----
$raw = $in['raw'] ?? null;
// pick first feature’s attrs for each layer, keep up to ~40 keys to avoid giant prompts
$pickFirst = function($arr){
$a = (array)$arr;
if (isset($a[0]) && is_array($a[0])) {
$attrs = $a[0];
return array_slice($attrs, 0, 40, true);
}
return null;
};
$raw_for_prompt = [
'parcel_attrs' => $pickFirst($raw['parcels'] ?? []),
'lga_attrs' => $pickFirst($raw['lga'] ?? []),
'zone_attrs' => $pickFirst($raw['zones'] ?? []),
'code_attrs' => $pickFirst($raw['codes'] ?? []),
];
// ---- Build a concise, LLM-friendly context blob (you can expand later) ----
$context = [
'address' => (string)$in['address'],
'pid' => $in['pid'] ?? null,
'title_id' => $in['title_id'] ?? null,
'council' => $in['council'] ?? null,
'planning_scheme' => $in['planning_scheme'] ?? null,
'zones' => array_map(fn($z)=>[
'name' => $z['name'] ?? null,
'doc_section' => $z['doc_section'] ?? null,
'key_clauses' => $z['key_clauses'] ?? null], $zoneBlobs),
'codes' => array_map(fn($c)=>[
'id' => $c['id'] ?? null,
'name' => $c['name'] ?? null,
'key_clauses' => $c['key_clauses'] ?? null], $codeMatches),
'area' => [
'sqm' => $in['total_area']['sqm'] ?? null,
'sqm_label' => $in['total_area']['sqm_label'] ?? null,
'ha' => $in['total_area']['ha'] ?? null,
'ha_label' => $in['total_area']['ha_label'] ?? null,
],
'spp_version' => $sppVersion,
'raw_sample' => $raw_for_prompt, // safe, trimmed
];
// ====== (optional) Hook an LLM here ======
// Leave disabled by default. When ready, implement call_llm() to your provider.
$USE_LLM = false;
$llm_html = null;
if ($USE_LLM) {
$prompt = [
"role" => "user",
"content" =>
"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.".
"Using the JSON context below, write a concise property planning summary: ".
"1) Identify applicable zones and codes (with clause references from key_clauses). ".
"2) For an *intended use* placeholder (like dwelling, shed, café), outline likely status (Permitted/Discretionary/Prohibited) and key standards to check. ".
"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/?effectiveForDate={$sppVersion}). ".
"4) Keep it practical, note uncertainty and that LPS may vary locally.\n\n".
"Context JSON:\n".
json_encode($context, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES)
];
// $llm_html = call_llm([$prompt]); // TODO: implement your provider call
}
/* ---------- Map image (accept data URL or raw base64) ---------- */
$mapSrc = null;
if (!empty($in['map_png'])) {
$mapSrc = (stripos($in['map_png'], 'data:image') === 0)
? $in['map_png']
: ('data:image/png;base64,' . $in['map_png']);
}
// ---------- HTML (template) ----------
ob_start(); ?>
AI-Assisted Property Report (Tasmania)
Generated = htmlspecialchars(gmdate('Y-m-d H:i')) ?> · SPP version: = htmlspecialchars($sppVersion) ?>
= htmlspecialchars($in['address']) ?>
- PID: = htmlspecialchars($in['pid']) ?>
- Title: = htmlspecialchars($in['title_id']) ?>
- Council (LGA): = htmlspecialchars($in['council']) ?>
- Area: = htmlspecialchars($in['total_area']['sqm_label']) ?>
(= htmlspecialchars($in['total_area']['ha_label']) ?>)
Primary zone & overlays
Zone(s):
= htmlspecialchars(implode(', ', $zoneNames)) ?: '—' ?>
Overlays/Codes:
= htmlspecialchars(implode(', ', $codeNames)) ?: '—' ?>
Key scheme parts to review
Review the relevant SPP code chapter for triggers and standards.
Intended use (beta): = htmlspecialchars($intended) ?>
Typical constraints to check
- Use Table: whether your intended use is Permitted, Discretionary, or Prohibited.
- Zone development standards: height, setbacks, site coverage, privacy, access and parking.
- Code triggers (if mapped): bushfire, heritage, inundation, landslide, biodiversity, scenic/attenuation, coastal, roads/rail, parking.
- Local Provisions Schedule (= htmlspecialchars($in['council'] ?? 'Council') ?>): any local variations, Specific Area Plans, heritage list entries.
Next steps
- Confirm zone and all overlays using the LIST map and = htmlspecialchars($in['council'] ?? 'Council') ?> LPS.
- Check your intended use against the zone Use Table; note any qualifications.
- Review development and code standards; obtain specialist reports where required.
- Seek pre-application advice from = htmlspecialchars($in['council'] ?? 'the local Council') ?> planning.
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.
= $llm_html ?>
Debug context
= htmlspecialchars(json_encode($context, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES)) ?>
true,'html'=>$html], JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode(['ok'=>false,'error'=>$e->getMessage()]);
}
?>