This file gives Claude Code full context on the project architecture, conventions, and deployment workflow. Read this before making any changes.
tasplanning.report is an AI-powered assistant for the Tasmanian Planning Scheme (TPS). It allows planners, building designers, architects, and consultants to ask natural-language questions about SPPs and LPS documents and receive clause-cited answers.
Core capability: Retrieval-Augmented Generation (RAG) — user queries are embedded, matched against a Qdrant vector database of official planning documents, and the retrieved context is injected into an LLM prompt. The LLM generates a cited answer using only the retrieved context — it never invents clause numbers.
/home/modulos_llm/
├── pdfs/
│ ├── as/ # Australian Standards in PDF
│ ├── lps/ # Local Planning Scheme in PDF
│ ├── ncc/ # National Construction Code in PDF
│ ├── tps/ # Tasmanian Planning Scheme in PDF
├── backend/ # FastAPI Python backend
│ ├── app.py # Main API — /ask, /feedback, /councils, /readyz, admin endpoints
│ ├── telemetry.py # SQLite telemetry DB, /telemetry POST endpoint, schema init
│ ├── ingest.py # PDF ingestion into Qdrant (run manually)
│ ├── ingest_ollama.py # Ollama-based ingestion variant
│ └── requirements.txt # Python dependencies
├── public/ # PHP/Apache web frontend (bind-mounted into web container)
│ ├── index.php # Landing page with demo modal and waitlist
│ ├── local_state-planning-scheme.php # Main chat assistant UI
│ ├── site-report.php # Property lookup (LIST/ArcGIS + Google Maps)
│ ├── section-builder.php # Planning report section builder
│ ├── byok-settings.php # API key management (BYOK feature)
│ ├── dashboard.php # Internal query monitoring dashboard
│ ├── waitlist.php # PHPMailer waitlist signup endpoint
│ ├── gmaps-key.php # Google Maps API key proxy
│ ├── faq.php # FAQ page
│ ├── privacy.php # Privacy policy
│ ├── terms.php # Terms of use
│ ├── js/
│ │ └── api-status.js # Shared live API health indicator
│ ├── css/
│ │ └── report.css # Shared stylesheet for index.php
│ ├── vendor/ # Composer PHP dependencies (PHPMailer, phpword, google/apiclient)
│ └── composer.json # PHP dependency manifest
├── web/
│ └── Dockerfile # Custom PHP 8.3 + Apache image
├── cache/
├── creds/
│ ├── oauth-client.json
│ ├── service-account.json
├── images/
├── models/
├── qdrant_storage/ # Qdrant vector DB persistent storage
│ ├── aliases/ #
│ ├── collections/ #
│ ├── raft_state.json #
├── telemetry_data/
│ └── telemetry.db # SQLite telemetry database
├── venv/
│ ├── include
│ │ └── python3.1.2
├── docker-compose.yml # Full stack orchestration
└── .env # Secrets (SMTP_PASS, GMAPS_API_KEY, TPR_IP_SECRET, etc.)
└── CHANGELOG.md
└── CLAUDE.md
└── README.md
All services defined in docker-compose.yml. Run from /home/modulos_llm/.
| Service | Container | Port | Purpose |
|---|---|---|---|
qdrant |
modulos-qdrant |
6333, 6334 | Vector database |
backend |
modulos-backend |
2281→8000 | FastAPI API |
web |
modulos-web |
2380→80 | PHP/Apache frontend |
sqliteweb |
modulos-sqliteweb |
8091→8080 | SQLite Web UI (internal) |
composer |
(run-once) | — | PHP dependency installer |
# Start everything
docker compose up -d
# Restart a single service (backend changes need restart, web changes do not)
docker compose restart backend
# View logs
docker logs modulos-backend --tail 50
docker logs modulos-web --tail 50
# Force recreate (picks up docker-compose.yml changes)
docker compose up -d --force-recreate web
# Run composer to install/update PHP dependencies
docker compose run --rm composer
# Or directly:
docker run --rm -v /home/modulos_llm/public:/app composer:2 \
require phpmailer/phpmailer --no-interaction --no-progress --ignore-platform-reqs
# One-off Python command in backend container
docker exec modulos-backend python3 -c "..."
# Check API health
curl -s https://api.modulos.com.au/readyz
./backend:/app — backend Python files are live. Edit files on host, restart backend to pick up changes../public:/var/www/html — PHP files are live. Edit files on host, no restart needed./home/modulos_llm/telemetry_data:/data — telemetry DB shared between backend and web containers.app.py)OLLAMA_URL=http://192.168.8.73:11434 # Windows machine running Ollama
QDRANT_URL=http://qdrant:6333 # Internal Docker network
OLLAMA_KEEP_ALIVE=-1 # Keep model loaded permanently (-1 = forever)
CHAT_MODEL=llama3.1:8b-instruct-q4_K_M # Current model (Q4 quantization)
EMBED_MODEL=nomic-embed-text # Embedding model
QDRANT_COLLECTION=planning_docs # Vector collection name
CORS_ORIGINS=https://tasplanning.report,http://localhost:3000,...
TPR_DB=/data/telemetry.db
TPR_IP_SECRET=<secret> # HMAC secret for IP hashing
| Method | Path | Purpose |
|---|---|---|
| GET | /readyz |
Health check — returns {"ok":true} |
| GET | /councils |
List indexed council names |
| POST | /ask |
Main RAG query endpoint |
| GET | /ask |
Same, query string params |
| POST | /feedback |
Store thumbs up/down with query+answer |
| POST | /telemetry |
Generic event logging |
| GET | /admin/stats |
Collection statistics |
| GET | /admin/files |
List indexed documents |
| GET | /admin/sample |
Sample random chunks |
| GET | /admin/export |
NDJSON export of all chunks |
/ask request body (AskBody){
"query": "What are the setbacks in the Village Zone?",
"council": "launceston", # optional — filters to that council's LPS
"top_k": 8, # number of chunks to retrieve
"scope": "state_plus_local", # state_plus_local | state_only | local_only | any
"section_id": null, # optional — triggers specific output format guide
"include_ncc": false,
"include_standards": false,
"context_only": false # BYOK mode — returns context+prompt without calling Ollama
}
/ask response{
"answer": "...",
"sources": [
{"source_file": "tasmanian-planning-scheme.pdf", "page": 42, "chunk_index": 3, "score": 0.87}
]
}
When context_only: true (BYOK mode):
{
"context_only": true,
"context": "...",
"prompt": "...", // full prompt ready to send to external LLM
"sources": [...],
"sections": [...]
}
ollama_chat())"keep_alive": -1, # top-level param, NOT inside options
"options": {
"num_ctx": 6144, # context window — don't change between requests or model reloads
"num_predict": 1024, # max output tokens
"temperature": 0.15, # low = more deterministic
"top_p": 0.85,
"top_k": 40,
"repeat_penalty": 1.15,
"stop": ["QUESTION:", "---END---"],
}
Important: keep_alive must be a top-level JSON key, not inside options. Putting it
inside options causes Ollama to silently ignore it.
The system prompt uses ## AUTHORITY ORDER, ## STRICT RULES, ## OUTPUT FORMAT headers.
llama3.1 responds well to Markdown section headers as instruction anchors. The fallback
phrase when context is insufficient: "The provided context does not cover this — check the
TPSO viewer directly at tpso.planning.tas.gov.au"
Each chunk in the vector DB has this payload:
{
"corpus": "tps", // "tps" | "lps" | "ncc" | "as"
"council": "launceston", // only for LPS chunks
"source_file": "launceston-lps.pdf",
"page": 42,
"chunk_index": 3,
"text": "..."
}
Scope filtering uses corpus and council exact-match filters in Qdrant.
All pages use a consistent dark design system. Never use Bootstrap on redesigned pages.
--bg: #0b0f0e; /* page background */
--bg-1: #111614; /* slightly lighter panels */
--bg-2: #181e1b; /* card interiors */
--bg-card: #141a17;
--border: rgba(255,255,255,0.07);
--accent: #2ddc8a; /* green accent */
--accent-dim: rgba(45,220,138,0.10);
--text-primary: #eaf0ec;
--text-secondary:#8fa899;
--text-muted: #4f6459;
--danger: #f08080;
--serif: 'DM Serif Display', Georgia, serif;
--sans: 'DM Sans', system-ui, sans-serif;
--mono: ui-monospace, 'Cascadia Code', Menlo, monospace;
Fonts loaded from Google Fonts: DM Serif Display (headings/display) + DM Sans (body).
Bootstrap Icons CDN used for icons: https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css
| File | Purpose | Notes |
|---|---|---|
index.php |
Landing page | Has demo modal, waitlist form, hero search |
local_state-planning-scheme.php |
Main chat assistant | Two-panel: sidebar + chat thread |
site-report.php |
Property lookup | Google Maps PlaceAutocomplete, LIST/ArcGIS API |
section-builder.php |
Report builder | 3-panel: context / sections / preview |
byok-settings.php |
API key management | Keys in localStorage only, never server |
dashboard.php |
Query monitoring | Restricted by IP via .htaccess |
waitlist.php |
Waitlist endpoint | PHPMailer, reads SMTP_* from environment |
gmaps-key.php |
Maps key proxy | Serves GMAPS_API_KEY from env, checks HTTP_HOST |
faq.php |
FAQ | Accordion, sticky sidebar, FAQPage schema |
privacy.php |
Privacy policy | |
terms.php |
Terms of use |
Property data passes from site-report.php → section-builder.php via localStorage:
// site-report.php writes:
localStorage.setItem('tpr_builder_ctx', JSON.stringify({ ctx, written_at: Date.now() }));
// section-builder.php reads (30-minute TTL):
const { ctx, written_at } = JSON.parse(localStorage.getItem('tpr_builder_ctx'));
if (Date.now() - written_at > 30 * 60 * 1000) { /* expired */ }
Never use sessionStorage for cross-tab data — each tab gets its own isolated sessionStorage.
localStorage keys:
tpr_byok_active — active provider: 'internal' | 'anthropic' | 'openai' | 'grok' | 'ollama'tpr_byok_key_{provider} — API key for each providertpr_byok_model_{provider} — selected model for each providerBYOK flow in local_state-planning-scheme.php:
/ask with context_only: true → get RAG context + pre-built promptSupported providers: Anthropic (claude-sonnet-4-5), OpenAI (gpt-4o-mini), xAI Grok (grok-3-mini), local Ollama.
/js/api-status.js is included on every page:
<script src="/js/api-status.js" data-api="https://api.modulos.com.au"></script>
Polls /readyz every 30s (10s when down). Updates .status-dot and .nav-status-text.
Nav HTML must include:
<div class="nav-status">
<span class="status-dot"></span>
<span class="nav-status-text">API live</span>
</div>
Each assistant message stores context as data-* attributes for scope-safe feedback:
div.dataset.query = query;
div.dataset.scope = scope;
div.dataset.provider = provider;
div.dataset.answer = answer.replace(/<[^>]*>/g, '').substring(0, 4000);
window.feedback(msgId, verdict, btn) reads these attributes and POSTs to /feedback.
Never rely on closure variables — use data-* attributes or localStorage reads.
SQLite at /home/modulos_llm/telemetry_data/telemetry.db
ask_logs — every query:
id, ts, sid, ip_hash, query, normalized, scope, allow_tps,
latency_ms, model, ok, topk_json, tokens_in, tokens_out, answer
feedback — thumbs up/down:
id, ts, sid, ip_hash, verdict, query, answer, note, model, scope, sources_json
events — generic telemetry events:
id, ts, type, sid, ua, ip_hash, data_json
docker exec modulos-backend python3 - << 'EOF'
import sqlite3, os
db = sqlite3.connect(os.getenv('TPR_DB', '/data/telemetry.db'))
cols = [r[1] for r in db.execute("PRAGMA table_info(ask_logs)").fetchall()]
if 'answer' not in cols:
db.execute("ALTER TABLE ask_logs ADD COLUMN answer TEXT DEFAULT ''")
db.commit()
print("✓ added")
db.close()
EOF
-- Today's queries with answers
SELECT ts, query, answer, latency_ms, scope, model
FROM ask_logs WHERE ts LIKE '2026-03-26%' ORDER BY ts DESC;
-- Thumbs-down for prompt improvement
SELECT query, answer, note, model, scope
FROM feedback WHERE verdict='down' ORDER BY ts DESC;
-- Satisfaction rate
SELECT
SUM(CASE WHEN verdict='up' THEN 1 ELSE 0 END) as up,
SUM(CASE WHEN verdict='down' THEN 1 ELSE 0 END) as down,
ROUND(SUM(CASE WHEN verdict='up' THEN 1.0 ELSE 0 END) / COUNT(*) * 100) as pct
FROM feedback;
-- Repeated queries (cache candidates)
SELECT normalized, COUNT(*) as hits, ROUND(AVG(latency_ms)) as avg_ms
FROM ask_logs WHERE ok=1 GROUP BY normalized ORDER BY hits DESC LIMIT 20;
{
"require": {
"phpoffice/phpword": "^1.3",
"phpmailer/phpmailer": "^6.9",
"erusev/parsedown": "^1.7",
"google/apiclient": "^2.15"
}
}
# Add a new package (use docker run to avoid platform issues)
docker run --rm -v /home/modulos_llm/public:/app composer:2 \
require vendor/package --no-interaction --no-progress --ignore-platform-reqs
# Update all (run from project root)
docker compose run --rm composer update --no-interaction --ignore-platform-req=ext-gd
Known issue: phpoffice/phpword 1.4.0 requires ext-gd which isn't in the Composer
container — always use --ignore-platform-req=ext-gd or --ignore-platform-reqs.
SMTP_HOST=mail.modulos.com.au
SMTP_PORT=465 # 465=SSL, 587=TLS
SMTP_USER=no-reply@modulos.com.au
SMTP_PASS=<from .env>
SMTP_FROM=no-reply@modulos.com.au
SMTP_FROM_NAME=Tas Planning Assistant
NOTIFY_EMAIL=ben@modulos.com.au
Nginx/Cloudflare sits in front. The web container is on port 2380, backend on 2281. Public URLs:
https://tasplanning.report → web container (port 2380)https://api.modulos.com.au → backend container (port 2281)gmaps-key.php serves the API key from env — never put it in page source.
site-report.php loads Maps via:
const keyRes = await fetch('/gmaps-key.php');
const { key } = await keyRes.json();
await google.maps.importLibrary('places');
PassEnv GMAPS_API_KEY is written to Apache config in the web container startup command.
OAuth2 credentials at /var/www/secure/service-account.json inside the web container.
GDOC_PARENT_ID and GDOC_SHARE_EMAIL set in docker-compose environment.
/home/modulos_llm/public/.htaccess restricts dashboard.php to specific IPs:
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{REMOTE_ADDR} ^YOUR\.IP\.HERE$
RewriteRule ^dashboard\.php$ - [L]
RewriteCond %{HTTP:X-Forwarded-For} ^YOUR\.IP\.HERE
RewriteRule ^dashboard\.php$ - [L]
RewriteRule ^dashboard\.php$ https://tasplanning.report/ [R=302,L]
</IfModule>
sessionStorage for cross-tab data — use localStorage with a TTLnum_ctx between requests — Ollama reloads the model on every changekeep_alive inside Ollama options{} — it must be a top-level keywindow.* functions — use data-* attributescomposer install when lock file is out of sync — run composer update first.env — it contains SMTP_PASS, API keys, secretsapp.py — it's not hot-reloaded--ignore-platform-reqs with composer when adding packagesapachectl -t after changing Apache config or .htaccesshtmlspecialchars($s, ENT_QUOTES, 'UTF-8')credentials: 'omit' for cross-origin fetch to the APIALTER TABLE migration, not by dropping/recreatingcontext_only: true after prompt changes to verify RAG retrieval before LLM issues| Value | Searches |
|---|---|
state_plus_local |
SPPs + selected council's LPS |
state_only |
SPPs only |
local_only |
Selected council's LPS only |
any |
All indexed documents |
The current prompt structure (in do_ask() in app.py):
## AUTHORITY ORDER
1. SPP (baseline)
2. LPS for selected council (overrides SPP)
3. NCC (optional, building control only)
4. Australian Standards (optional)
## STRICT RULES
- Use ONLY information in CONTEXT
- Fallback phrase: "The provided context does not cover this..."
- Every claim must cite (filename, p.N)
- Distinguish A (Acceptable Solutions) vs P (Performance Criteria)
## OUTPUT FORMAT
- Markdown with ## headings
- Tables for setbacks, parking rates, multiple standards
- ## Sources section at end of every response
## CONTEXT
{retrieved chunks}
{format_guide} # section-specific formatting instructions
## QUESTION
{query}
## ANSWER:
Debugging RAG vs prompt issues:
context_only: true to inspect what context was retrievedtop_k, check chunk boundaries)# Edit the file
nano /home/modulos_llm/backend/app.py
# Restart to pick up changes
docker compose restart backend
# Watch logs
docker logs modulos-backend -f
# Edit directly — changes are live immediately
nano /home/modulos_llm/public/local_state-planning-scheme.php
# No restart needed
pagename.php in /home/modulos_llm/public/.status-dot and .nav-status-text<script src="/js/api-status.js" data-api="https://api.modulos.com.au"></script>@app.post("/path") and @limiter.limit("20/minute")try/except — never let logging break the endpointdocker compose restart backend# Copy PDFs to backend folder
cp new-lps.pdf /home/modulos_llm/backend/
# Run ingestion (adjust args for corpus/council)
docker exec modulos-backend python3 ingest.py \
--pdf new-lps.pdf \
--corpus lps \
--council launceston
| Service | URL | Purpose |
|---|---|---|
| Ollama | http://192.168.8.73:11434 |
LLM inference (Windows machine, RTX 4070 Super) |
| Qdrant | http://qdrant:6333 (internal) |
Vector search |
| TPSO Viewer | https://tpso.planning.tas.gov.au/tpso/external/planning-scheme-viewer/30 |
SPP viewer (iframe) |
| LIST GIS | https://services.thelist.tas.gov.au/arcgis/rest/services |
Property/parcel data |
| Google Maps | https://maps.googleapis.com |
Address autocomplete in site-report |
| Anthropic API | https://api.anthropic.com/v1/messages |
BYOK Claude |
| OpenAI API | https://api.openai.com/v1/chat/completions |
BYOK GPT |
| xAI API | https://api.x.ai/v1/chat/completions |
BYOK Grok |
llama3.1:8b-instruct-q4_K_MTPR_IP_SECRET — not reversiblelocalStorage only — never sent to our serversgmaps-key.php checks HTTP_HOST/HTTP_ORIGIN before serving the Maps keydashboard.php restricted by .htaccess IP allowlistDEMO_TOKEN in docker-compose is for optional API gating (currently disabled).env file contains all secrets — never commit to version control