harshith99 Claude Opus 4.8 commited on
Commit
5b573cc
Β·
1 Parent(s): 45de02f

Hardening: multi-user safety, rename to places, geocoder cache, robust chatbot

Browse files

Tier 1 (honesty + safety):
- Rename restaurants β†’ places end-to-end: output files (places_full.csv,
places_map.kml, places_map.geojson, places_skipped.csv, places_override.csv),
HTTP download filenames, page title, headings, SSE messages, gitignore.
Internal LLM prompt key left as-is (benchmark oracle depends on it).
- Fix roulette cross-user data leak: /roulette no longer falls back to the
globally-newest CSV across all jobs (which leaked one user's places to
another on a shared deploy). job_id is now required; client already sends it.

Tier 2 (hosting readiness):
- Job TTL + persisted state: each job's state mirrors to <job_dir>/state.json
so progress survives a restart; job dirs older than JOB_TTL_HOURS (default 24)
are deleted on startup and on each new upload.
- Geocoder: persistent place→coords cache (.geocode_cache.json) so duplicates
and re-runs skip the network; NOMINATIM_URL env to point at a self-hosted
instance; HOSTED=1 refuses bulk geocoding against public OSM (usage policy).
- Robust chatbot handoff: auto-chunk large exports into per-file downloads,
accept multiple pasted replies and merge/dedup them, and parse tolerantly
(markdown fences, prose, trailing commas, truncated arrays salvaged).

TODO: added Hardening (today), Backlog (in-app map preview β€” revisit end of
day), and Tier 3 Growth (mobile consumption docs, offline targets, persistent
library, HTML map export, remix loop) above the Beli V2 section.

Note: pre-existing prefilter false-negative (unrelated, prefilter.py untouched).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

.dockerignore CHANGED
@@ -8,4 +8,5 @@ data/
8
  tests/fixtures/benchmark_results.json
9
  *.checkpoint.json
10
  *.batch_pending.json
 
11
  .DS_Store
 
8
  tests/fixtures/benchmark_results.json
9
  *.checkpoint.json
10
  *.batch_pending.json
11
+ .geocode_cache.json
12
  .DS_Store
.gitignore CHANGED
@@ -7,16 +7,24 @@ data/
7
  # Web UI job artifacts
8
  jobs/
9
 
10
- # Legacy paths (in case any exist at root)
11
  saved_posts.json
12
  saved_collections.json
13
  saved_locations.json
 
 
 
 
 
 
14
  restaurants_full.csv
15
  restaurants_skipped.csv
16
  restaurants_override.csv
17
  restaurants_map.kml
18
  restaurants.csv
19
  *.checkpoint.json
 
 
20
  tests/benchmark_results.json
21
 
22
  # Python
 
7
  # Web UI job artifacts
8
  jobs/
9
 
10
+ # Generated outputs (in case any exist at root)
11
  saved_posts.json
12
  saved_collections.json
13
  saved_locations.json
14
+ places_full.csv
15
+ places_skipped.csv
16
+ places_override.csv
17
+ places_map.kml
18
+ places_map.geojson
19
+ # Legacy (pre-rename) output names
20
  restaurants_full.csv
21
  restaurants_skipped.csv
22
  restaurants_override.csv
23
  restaurants_map.kml
24
  restaurants.csv
25
  *.checkpoint.json
26
+ *.batch_pending.json
27
+ .geocode_cache.json
28
  tests/benchmark_results.json
29
 
30
  # Python
README.md CHANGED
@@ -1,6 +1,6 @@
1
- # Instagram Saved Restaurants β†’ Map
2
 
3
- Turn your Instagram saved posts into a searchable, filterable restaurant map β€” **for free, forever, without giving anyone your data.**
4
 
5
  Every paid alternative (Someday Map, Rezz, ReelsMap, Drawer) is a subscription app that sends your saves to their servers and charges you monthly. This project runs entirely on your machine: Ollama handles AI extraction locally, Nominatim handles geocoding for free, and the only output is files on your disk.
6
 
@@ -10,7 +10,7 @@ Every paid alternative (Someday Map, Rezz, ReelsMap, Drawer) is a subscription a
10
 
11
  1. **Extracts** β€” reads every saved post caption using AI (Claude or a free local Ollama model), identifies posts about places worth visiting (restaurants, bars, cafes, hotels, shops, hikes, viewpoints, museums, and more), and pulls out name, city, category, cuisine, price range, highlight, and occasion
12
  2. **Geocodes** β€” looks up coordinates via Nominatim (OpenStreetMap) with country + city + proximity validation to avoid false pins
13
- 3. **Exports** β€” writes `restaurants_full.csv`, `restaurants_map.kml` (Google My Maps), and `restaurants_map.geojson` (Felt, Mapbox, etc.)
14
 
15
  ## Why not the paid apps?
16
 
@@ -208,16 +208,16 @@ python main.py saved_posts.json --transcribe --whisper-model medium # more accu
208
 
209
  | File | Description |
210
  |---|---|
211
- | `restaurants_full.csv` | One row per place: name, city, category, cuisine, highlight, price_range, lat, lng, creator, saved_at, instagram_url |
212
- | `restaurants_map.kml` | Google My Maps–ready file, organised by country β†’ city. Pins show cuisine, must-order dish, and link back to Instagram. |
213
- | `restaurants_skipped.csv` | Reels that couldn't be downloaded or weren't food. Review manually to fill gaps. |
214
- | `restaurants_override.csv` | Manual override template β€” fill in `maps_url` or `lat`/`lng` for ungeocoded restaurants. |
215
 
216
  ## Fixing missing map pins
217
 
218
- After each run, `restaurants_override.csv` lists every restaurant with no coordinates. To add a pin manually:
219
 
220
- 1. Open `restaurants_override.csv`
221
  2. Search for the restaurant on Google Maps, copy the URL
222
  3. Paste it into the `maps_url` column (or enter `lat`/`lng` directly)
223
  4. Re-run: `python main.py saved_posts.json --no-extract --no-geocode --yes`
@@ -229,7 +229,7 @@ Supported URL formats:
229
  ## Import into Google My Maps
230
 
231
  1. Go to [mymaps.google.com](https://mymaps.google.com) β†’ **Create a new map**
232
- 2. Click **Import** on the base layer β†’ upload `restaurants_map.kml`
233
  3. Done β€” pins are organised into folders by country and city
234
 
235
  ## Docker (self-hosted, batteries included)
@@ -253,6 +253,9 @@ Open [http://localhost:8000](http://localhost:8000). On first run, `llama3.2` (~
253
  | `OLLAMA_URL` | `http://ollama:11434` | Ollama API endpoint (set automatically by compose) |
254
  | `OLLAMA_ENABLED` | `true` | Set to `false` to hide the Ollama provider (for hosted deployments) |
255
  | `DEFAULT_OLLAMA_MODEL` | `llama3.2` | Model pulled on first run and pre-selected in the UI |
 
 
 
256
 
257
  ### Tier comparison
258
 
@@ -299,10 +302,10 @@ The UI lets you:
299
  | **ChatGPT / Claude.ai (free chatbot)** | $0 | Captions to chatbot | None |
300
 
301
  **ChatGPT / Claude.ai path (no API key needed):**
302
- 1. Drop your export file and click **Prepare extraction file** β€” the app pre-filters your posts and generates a numbered JSON file
303
- 2. Download the JSON, copy the extraction prompt, and paste both into ChatGPT or Claude.ai (free tier)
304
- 3. Paste the chatbot's JSON response back into the web UI
305
- 4. The app geocodes the results and builds your map β€” same output as any other path
306
 
307
  **BYOK (Bring Your Own Key):** Select the Claude provider and paste your Anthropic API key into the password field. It is used for that request only and never stored on the server.
308
 
@@ -316,6 +319,10 @@ The geocoder runs three validation checks before accepting any result:
316
 
317
  If any check fails, the row is left blank rather than accepting a wrong pin.
318
 
 
 
 
 
319
  ## Benchmark & quality testing
320
 
321
  The `tests/` directory contains a regression suite that measures extraction quality across all models.
 
1
+ # Instagram Saved Places β†’ Map
2
 
3
+ Turn your Instagram saved posts into a searchable, filterable map of every place you've saved β€” restaurants, bars, cafes, hotels, shops, hikes, viewpoints, museums and more β€” **for free, forever, without giving anyone your data.**
4
 
5
  Every paid alternative (Someday Map, Rezz, ReelsMap, Drawer) is a subscription app that sends your saves to their servers and charges you monthly. This project runs entirely on your machine: Ollama handles AI extraction locally, Nominatim handles geocoding for free, and the only output is files on your disk.
6
 
 
10
 
11
  1. **Extracts** β€” reads every saved post caption using AI (Claude or a free local Ollama model), identifies posts about places worth visiting (restaurants, bars, cafes, hotels, shops, hikes, viewpoints, museums, and more), and pulls out name, city, category, cuisine, price range, highlight, and occasion
12
  2. **Geocodes** β€” looks up coordinates via Nominatim (OpenStreetMap) with country + city + proximity validation to avoid false pins
13
+ 3. **Exports** β€” writes `places_full.csv`, `places_map.kml` (Google My Maps), and `places_map.geojson` (Felt, Mapbox, etc.)
14
 
15
  ## Why not the paid apps?
16
 
 
208
 
209
  | File | Description |
210
  |---|---|
211
+ | `places_full.csv` | One row per place: name, city, category, cuisine, highlight, price_range, lat, lng, creator, saved_at, instagram_url |
212
+ | `places_map.kml` | Google My Maps–ready file, organised by country β†’ city. Pins show cuisine, must-order dish, and link back to Instagram. |
213
+ | `places_skipped.csv` | Reels that couldn't be downloaded or weren't food. Review manually to fill gaps. |
214
+ | `places_override.csv` | Manual override template β€” fill in `maps_url` or `lat`/`lng` for ungeocoded places. |
215
 
216
  ## Fixing missing map pins
217
 
218
+ After each run, `places_override.csv` lists every restaurant with no coordinates. To add a pin manually:
219
 
220
+ 1. Open `places_override.csv`
221
  2. Search for the restaurant on Google Maps, copy the URL
222
  3. Paste it into the `maps_url` column (or enter `lat`/`lng` directly)
223
  4. Re-run: `python main.py saved_posts.json --no-extract --no-geocode --yes`
 
229
  ## Import into Google My Maps
230
 
231
  1. Go to [mymaps.google.com](https://mymaps.google.com) β†’ **Create a new map**
232
+ 2. Click **Import** on the base layer β†’ upload `places_map.kml`
233
  3. Done β€” pins are organised into folders by country and city
234
 
235
  ## Docker (self-hosted, batteries included)
 
253
  | `OLLAMA_URL` | `http://ollama:11434` | Ollama API endpoint (set automatically by compose) |
254
  | `OLLAMA_ENABLED` | `true` | Set to `false` to hide the Ollama provider (for hosted deployments) |
255
  | `DEFAULT_OLLAMA_MODEL` | `llama3.2` | Model pulled on first run and pre-selected in the UI |
256
+ | `JOB_TTL_HOURS` | `24` | Per-upload job directories are deleted after this many hours |
257
+ | `NOMINATIM_URL` | public OSM | Geocoder endpoint β€” point at a self-hosted Nominatim for hosted use |
258
+ | `HOSTED` | unset | Set to `1` on a multi-user deploy: refuses to bulk-geocode against public OSM |
259
 
260
  ### Tier comparison
261
 
 
302
  | **ChatGPT / Claude.ai (free chatbot)** | $0 | Captions to chatbot | None |
303
 
304
  **ChatGPT / Claude.ai path (no API key needed):**
305
+ 1. Drop your export file and click **Prepare extraction file** β€” the app pre-filters your posts and generates a numbered JSON file (large libraries are split into several files that each fit a free chatbot's reply limit)
306
+ 2. Download each file, copy the extraction prompt, and paste both into ChatGPT or Claude.ai (free tier)
307
+ 3. Paste each reply back into the web UI (click **Add response** per file), then process
308
+ 4. The app geocodes the results and builds your map β€” same output as any other path. Response parsing is tolerant of markdown fences, prose, trailing commas, and truncated replies.
309
 
310
  **BYOK (Bring Your Own Key):** Select the Claude provider and paste your Anthropic API key into the password field. It is used for that request only and never stored on the server.
311
 
 
319
 
320
  If any check fails, the row is left blank rather than accepting a wrong pin.
321
 
322
+ **Caching** β€” every lookup is cached to `.geocode_cache.json` (place β†’ coordinates), so duplicate venues and re-runs never re-hit the network. Delete the file to force a fresh geocode.
323
+
324
+ **Endpoint & hosted use** β€” geocoding defaults to the public OSM Nominatim endpoint, which is fine for personal/local use. OSM's usage policy **forbids bulk automated use**, so a shared/hosted deployment must run its own geocoder: set `NOMINATIM_URL` to your instance. With `HOSTED=1`, the pipeline refuses to bulk-geocode against the public endpoint rather than risk getting the server IP banned.
325
+
326
  ## Benchmark & quality testing
327
 
328
  The `tests/` directory contains a regression suite that measures extraction quality across all models.
TODO.md CHANGED
@@ -128,6 +128,36 @@ KML requires Google My Maps. The primary output should be a self-contained file
128
 
129
  ---
130
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
  ## V2: Beli / Google Maps Saved integration
132
 
133
  - [ ] **find_place_ids.py** β€” Google Places Text Search API, validate against expected country + city. ~$2 for 173 requests.
 
128
 
129
  ---
130
 
131
+ ## Hardening β€” multi-user safety & correctness (today)
132
+
133
+ Surfaced in the architecture review. These make the product safe to host and honest about what it is.
134
+
135
+ - [ ] **Rename restaurants β†’ places end-to-end** β€” output files (`places_full.csv`, `places_map.kml`, `places_map.geojson`, `places_skipped.csv`, `places_override.csv`), download filenames, README title, and user-facing UI/CLI text. The product handles hikes/museums/hotels now; users shouldn't download a file called "restaurants".
136
+ - [ ] **Fix roulette cross-user data leak** β€” `/roulette` with no `job_id` scanned `jobs/*/` and returned the globally newest CSV β€” on a hosted deploy that returns *another user's* saved places. Require an explicit `job_id` (the client already sends it); drop the global-newest fallback.
137
+ - [ ] **Job TTL + persisted state** β€” job state lived only in a process-memory dict (lost on restart) and `jobs/` grew forever. Persist each job's state to `state.json`, recover it in the progress endpoint, and delete job dirs older than `JOB_TTL_HOURS` (default 24) on startup and on each new upload.
138
+ - [ ] **Geocoder: cache + configurable endpoint + hosted guard** β€” persistent geocode cache (place β†’ coords) so duplicates and re-runs don't re-hit Nominatim; `NOMINATIM_URL` env to point at a self-hosted instance; refuse bulk geocoding against the public OSM endpoint when `HOSTED=1` (OSM's usage policy forbids it β€” a hosted deploy would get IP-banned).
139
+ - [ ] **Robust chatbot handoff** β€” auto-chunk exports above the free-tier limit into multiple files, accept multiple pasted responses and merge them, and make response parsing tolerant of preamble text, trailing commas, and truncated arrays.
140
+
141
+ ---
142
+
143
+ ## Backlog β€” revisit at end of today's fixes
144
+
145
+ - [ ] **In-app map preview (QC, not consumption)** β€” a Leaflet/OSM preview in the results view whose job is to *catch geocode errors before export* (a venue pinned in the wrong country is invisible in the card grid). Consumption stays in Google My Maps / offline apps via the exported file. Lower priority than the hardening work β€” revisit once that lands.
146
+
147
+ ---
148
+
149
+ ## Tier 3 β€” Growth features
150
+
151
+ Build after the hardening pass. These extend reach without compromising the local-first, no-account story.
152
+
153
+ - [ ] **Close the mobile consumption loop (docs)** β€” the deliverable is meant to live on your phone, so document the last mile: opening a custom Google My Map in the Google Maps app and navigating to a pin. Cheap, directly serves the "usable on your phone while travelling" goal.
154
+ - [ ] **Offline / privacy-first map targets** β€” KML and GPX import cleanly into Organic Maps and OsmAnd: fully offline, no account, works abroad with no data plan. Arguably a better recommendation than Google My Maps for the privacy-first traveller, and we already produce the file β€” mostly a docs + small export addition.
155
+ - [ ] **Persistent library without a server DB** β€” let the user download a "library file" and re-upload it to merge, preserving visited status, edits, and "new since last run" across re-imports. Keyed on Instagram post ID. Stays true to local-first (no backend database). Supersedes the deferred re-import item above.
156
+ - [ ] **Self-contained interactive HTML map export** β€” single bundled file (Leaflet/MapLibre), pins link back to the original reel; opens on a phone browser with no import step. See the on-hold item under "Output".
157
+ - [ ] **"Remix this map" share loop** β€” publish a static map file anyone can view and one-click copy into their own instance. $0 hosting for OSS; mirrors Someday Map's virality mechanic.
158
+
159
+ ---
160
+
161
  ## V2: Beli / Google Maps Saved integration
162
 
163
  - [ ] **find_place_ids.py** β€” Google Places Text Search API, validate against expected country + city. ~$2 for 173 requests.
docker-compose.yml CHANGED
@@ -25,6 +25,12 @@ services:
25
  environment:
26
  OLLAMA_URL: http://ollama:11434
27
  OLLAMA_ENABLED: "true"
 
 
 
 
 
 
28
  volumes:
29
  - ./jobs:/app/jobs
30
  - ./data:/app/data
 
25
  environment:
26
  OLLAMA_URL: http://ollama:11434
27
  OLLAMA_ENABLED: "true"
28
+ JOB_TTL_HOURS: "24" # delete each upload's job dir after N hours
29
+ # Personal/local use geocodes against public OSM Nominatim (fine for one user).
30
+ # For a MULTI-USER hosted deploy, OSM forbids bulk use β€” run your own geocoder
31
+ # and uncomment these (plus a nominatim/photon service):
32
+ # HOSTED: "1"
33
+ # NOMINATIM_URL: http://nominatim:8080
34
  volumes:
35
  - ./jobs:/app/jobs
36
  - ./data:/app/data
main.py CHANGED
@@ -15,8 +15,8 @@ Usage
15
  python main.py <path/to/saved_posts.json> --no-extract --transcribe # transcribe-only run
16
 
17
  Output (written to --output-dir, default: same folder as the JSON file)
18
- restaurants_full.csv β€” one row per restaurant with coordinates + reel URL
19
- restaurants_map.kml β€” Google My Maps–ready KML organised by country β€Ί city
20
 
21
  Environment
22
  ───────────
@@ -117,8 +117,8 @@ def main() -> None:
117
  output_dir = Path(args.output_dir).resolve() if args.output_dir else input_path.parent
118
  output_dir.mkdir(parents=True, exist_ok=True)
119
 
120
- csv_path = output_dir / "restaurants_full.csv"
121
- kml_path = output_dir / "restaurants_map.kml"
122
 
123
  # Load .env starting from the input file's directory
124
  _load_env(input_path.parent)
@@ -252,7 +252,7 @@ def main() -> None:
252
  # ── Step 2b: Manual overrides ─────────────────────────────────────────────
253
  import csv as _csv
254
  from pipeline import override as override_mod
255
- override_path = output_dir / "restaurants_override.csv"
256
  with open(csv_path, encoding="utf-8") as f:
257
  csv_rows = list(_csv.DictReader(f))
258
  csv_rows, applied = override_mod.apply(csv_rows, override_path)
 
15
  python main.py <path/to/saved_posts.json> --no-extract --transcribe # transcribe-only run
16
 
17
  Output (written to --output-dir, default: same folder as the JSON file)
18
+ places_full.csv β€” one row per restaurant with coordinates + reel URL
19
+ places_map.kml β€” Google My Maps–ready KML organised by country β€Ί city
20
 
21
  Environment
22
  ───────────
 
117
  output_dir = Path(args.output_dir).resolve() if args.output_dir else input_path.parent
118
  output_dir.mkdir(parents=True, exist_ok=True)
119
 
120
+ csv_path = output_dir / "places_full.csv"
121
+ kml_path = output_dir / "places_map.kml"
122
 
123
  # Load .env starting from the input file's directory
124
  _load_env(input_path.parent)
 
252
  # ── Step 2b: Manual overrides ─────────────────────────────────────────────
253
  import csv as _csv
254
  from pipeline import override as override_mod
255
+ override_path = output_dir / "places_override.csv"
256
  with open(csv_path, encoding="utf-8") as f:
257
  csv_rows = list(_csv.DictReader(f))
258
  csv_rows, applied = override_mod.apply(csv_rows, override_path)
pipeline/chatbot.py CHANGED
@@ -8,9 +8,18 @@ Flow:
8
  """
9
 
10
  import json
 
11
  from . import prefilter as prefilter_mod
12
 
13
  CHUNK_WARN_THRESHOLD = 400 # show a warning above this many posts (fits in most free-tier windows)
 
 
 
 
 
 
 
 
14
 
15
  _PROMPT = """\
16
  You are a place extraction assistant. The attached JSON file contains Instagram post captions and hashtags.
@@ -119,24 +128,68 @@ def _clean(val) -> str:
119
  return "UNKNOWN" if not s or s.lower() in ("null", "unknown", "n/a", "") else s
120
 
121
 
122
- def parse_response(json_text: str, post_mapping: dict) -> list[dict]:
123
- """Parse the chatbot's JSON response into standard row dicts.
124
 
125
- Raises ValueError if the text cannot be parsed as a JSON array.
126
- Non-place entries and entries with no useful data are silently dropped.
127
  """
128
  text = json_text.strip()
129
- # Strip markdown fences some chatbots add despite instructions
130
  if text.startswith("```"):
131
  text = text.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
132
 
133
- try:
134
- data = json.loads(text)
135
- except json.JSONDecodeError as exc:
136
- raise ValueError(f"Could not parse as JSON: {exc}") from exc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
 
138
- if not isinstance(data, list):
139
- raise ValueError("Expected a JSON array at the top level β€” got a single object or non-array value.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
 
141
  rows: list[dict] = []
142
  for item in data:
 
8
  """
9
 
10
  import json
11
+ import re
12
  from . import prefilter as prefilter_mod
13
 
14
  CHUNK_WARN_THRESHOLD = 400 # show a warning above this many posts (fits in most free-tier windows)
15
+ CHUNK_SIZE = 350 # posts per downloadable file; comfortably under free-tier output limits
16
+
17
+
18
+ def chunk_export(export_posts: list[dict], size: int = CHUNK_SIZE) -> list[list[dict]]:
19
+ """Split the export into chunks small enough for a free chatbot to answer in one reply."""
20
+ if size <= 0 or not export_posts:
21
+ return [export_posts]
22
+ return [export_posts[i:i + size] for i in range(0, len(export_posts), size)]
23
 
24
  _PROMPT = """\
25
  You are a place extraction assistant. The attached JSON file contains Instagram post captions and hashtags.
 
128
  return "UNKNOWN" if not s or s.lower() in ("null", "unknown", "n/a", "") else s
129
 
130
 
131
+ def _extract_json_array(json_text: str) -> list:
132
+ """Best-effort extraction of a JSON array from a chatbot reply.
133
 
134
+ Tolerates: markdown fences, prose before/after the array, trailing commas,
135
+ and a response truncated mid-array (salvages the complete objects).
136
  """
137
  text = json_text.strip()
 
138
  if text.startswith("```"):
139
  text = text.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
140
 
141
+ start = text.find("[")
142
+ end = text.rfind("]")
143
+ candidate = text[start:end + 1] if (start != -1 and end > start) else text
144
+
145
+ attempts = [
146
+ candidate,
147
+ re.sub(r",\s*([\]}])", r"\1", candidate), # drop trailing commas
148
+ ]
149
+ # Salvage a truncated array: keep up to the last complete object, then close it
150
+ if start != -1:
151
+ last_obj = text.rfind("}")
152
+ if last_obj > start:
153
+ salvage = re.sub(r",\s*([\]}])", r"\1", text[start:last_obj + 1] + "]")
154
+ attempts.append(salvage)
155
+
156
+ for attempt in attempts:
157
+ try:
158
+ data = json.loads(attempt)
159
+ except json.JSONDecodeError:
160
+ continue
161
+ if isinstance(data, list):
162
+ return data
163
+
164
+ raise ValueError(
165
+ "Could not read a JSON array from the response. Paste the chatbot's full "
166
+ "reply β€” it should start with [ and end with ]."
167
+ )
168
+
169
 
170
+ def parse_responses(texts: list[str], post_mapping: dict) -> list[dict]:
171
+ """Parse and merge one or more chatbot replies (one per export chunk)."""
172
+ seen: set = set()
173
+ merged: list[dict] = []
174
+ for t in texts:
175
+ if not t or not t.strip():
176
+ continue
177
+ for row in parse_response(t, post_mapping):
178
+ dedup = row.get("instagram_url") or id(row)
179
+ if dedup in seen:
180
+ continue
181
+ seen.add(dedup)
182
+ merged.append(row)
183
+ return merged
184
+
185
+
186
+ def parse_response(json_text: str, post_mapping: dict) -> list[dict]:
187
+ """Parse one chatbot JSON reply into standard row dicts.
188
+
189
+ Raises ValueError if no JSON array can be recovered.
190
+ Non-place entries and entries with no useful data are silently dropped.
191
+ """
192
+ data = _extract_json_array(json_text)
193
 
194
  rows: list[dict] = []
195
  for item in data:
pipeline/export.py CHANGED
@@ -228,5 +228,5 @@ def run(input_csv: str, output_kml: str) -> None:
228
  print(f"Wrote {output_kml} ({pinned}/{len(rows)} rows have coordinates)")
229
  print("\nTo import into Google My Maps:")
230
  print(" 1. mymaps.google.com β†’ Create a new map")
231
- print(" 2. Click 'Import' on the base layer β†’ upload restaurants_map.kml")
232
  print(" 3. Rename the map 'Instagram Saved Places'")
 
228
  print(f"Wrote {output_kml} ({pinned}/{len(rows)} rows have coordinates)")
229
  print("\nTo import into Google My Maps:")
230
  print(" 1. mymaps.google.com β†’ Create a new map")
231
+ print(" 2. Click 'Import' on the base layer β†’ upload places_map.kml")
232
  print(" 3. Rename the map 'Instagram Saved Places'")
pipeline/geocode.py CHANGED
@@ -18,16 +18,47 @@ Blank is always better than a wrong pin.
18
  import csv
19
  import json
20
  import math
 
21
  import ssl
22
  import time
23
  import urllib.request
24
  import urllib.parse
 
25
 
26
  import certifi
27
 
28
  SSL_CTX = ssl.create_default_context(cafile=certifi.where())
29
  MAX_ADDRESS_KM = 50 # max distance from the expected city centre for address hits
30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
 
32
  # ── Country normalisation ─────────────────────────────────────────────────────
33
 
@@ -131,8 +162,8 @@ def _city_centre(city: str, country_code: str | None) -> tuple[float, float] | N
131
  query = city if not country_code else f"{city}, {country_code}"
132
  params = urllib.parse.urlencode({"q": query, "format": "json", "limit": 1, "addressdetails": 0})
133
  req = urllib.request.Request(
134
- f"https://nominatim.openstreetmap.org/search?{params}",
135
- headers={"User-Agent": "InstagramRestaurantMapper/1.0"},
136
  )
137
  try:
138
  with urllib.request.urlopen(req, timeout=10, context=SSL_CTX) as resp:
@@ -164,8 +195,8 @@ def _nominatim(query: str, expected_country: str, expected_city: str) -> tuple[f
164
  "addressdetails": 1,
165
  })
166
  req = urllib.request.Request(
167
- f"https://nominatim.openstreetmap.org/search?{params}",
168
- headers={"User-Agent": "InstagramRestaurantMapper/1.0"},
169
  )
170
  try:
171
  with urllib.request.urlopen(req, timeout=10, context=SSL_CTX) as resp:
@@ -272,6 +303,13 @@ def geocode_one(row: dict) -> tuple[str, str]:
272
 
273
  def run(input_csv: str, output_csv: str | None = None) -> list[dict]:
274
  """Geocode every row in input_csv and write results to output_csv (or in-place)."""
 
 
 
 
 
 
 
275
  target = output_csv or input_csv
276
 
277
  with open(input_csv, encoding="utf-8") as f:
@@ -279,6 +317,8 @@ def run(input_csv: str, output_csv: str | None = None) -> list[dict]:
279
 
280
  print(f"Loaded {len(rows)} rows. Geocoding with country + city + proximity validation...\n")
281
 
 
 
282
  for row in rows:
283
  row["lat"] = ""
284
  row["lng"] = ""
@@ -287,21 +327,30 @@ def run(input_csv: str, output_csv: str | None = None) -> list[dict]:
287
  name = row.get("name", "")
288
  city = row.get("city", "")
289
  country = row.get("country", "")
290
- print(f" [{i}/{len(rows)}] {name} β€” {city}, {country}")
291
-
292
- lat, lng = geocode_row(
293
- name,
294
- city,
295
- row.get("state", ""),
296
- country,
297
- row.get("address", ""),
298
- )
 
 
 
 
 
 
 
299
  row["lat"] = f"{lat:.7f}" if lat is not None else ""
300
  row["lng"] = f"{lng:.7f}" if lng is not None else ""
301
 
302
  status = f"β†’ {lat:.4f}, {lng:.4f}" if lat is not None else "β†’ not found (left blank)"
303
  print(f" {status}")
304
 
 
 
305
  with open(target, "w", newline="", encoding="utf-8") as f:
306
  writer = csv.DictWriter(f, fieldnames=rows[0].keys())
307
  writer.writeheader()
 
18
  import csv
19
  import json
20
  import math
21
+ import os
22
  import ssl
23
  import time
24
  import urllib.request
25
  import urllib.parse
26
+ from pathlib import Path
27
 
28
  import certifi
29
 
30
  SSL_CTX = ssl.create_default_context(cafile=certifi.where())
31
  MAX_ADDRESS_KM = 50 # max distance from the expected city centre for address hits
32
 
33
+ # Geocoding endpoint. Defaults to public OSM Nominatim for local use; point at a
34
+ # self-hosted instance for any shared/hosted deployment (OSM forbids bulk use).
35
+ NOMINATIM_URL = os.environ.get("NOMINATIM_URL", "https://nominatim.openstreetmap.org").rstrip("/")
36
+ _PUBLIC_NOMINATIM = "nominatim.openstreetmap.org"
37
+ USER_AGENT = "InstagramPlacesMapper/1.0 (+https://github.com/)"
38
+
39
+ # Persistent place→coords cache so duplicate venues and re-runs skip the network.
40
+ GEOCODE_CACHE_PATH = Path(
41
+ os.environ.get("GEOCODE_CACHE", str(Path(__file__).parent.parent / ".geocode_cache.json"))
42
+ )
43
+
44
+
45
+ def _cache_key(name: str, city: str, state: str, country: str, address: str) -> str:
46
+ return "|".join(s.strip().lower() for s in (name, city, state, country, address))
47
+
48
+
49
+ def _load_cache() -> dict:
50
+ try:
51
+ return json.loads(GEOCODE_CACHE_PATH.read_text(encoding="utf-8"))
52
+ except (OSError, json.JSONDecodeError):
53
+ return {}
54
+
55
+
56
+ def _save_cache(cache: dict) -> None:
57
+ try:
58
+ GEOCODE_CACHE_PATH.write_text(json.dumps(cache), encoding="utf-8")
59
+ except OSError:
60
+ pass
61
+
62
 
63
  # ── Country normalisation ─────────────────────────────────────────────────────
64
 
 
162
  query = city if not country_code else f"{city}, {country_code}"
163
  params = urllib.parse.urlencode({"q": query, "format": "json", "limit": 1, "addressdetails": 0})
164
  req = urllib.request.Request(
165
+ f"{NOMINATIM_URL}/search?{params}",
166
+ headers={"User-Agent": USER_AGENT},
167
  )
168
  try:
169
  with urllib.request.urlopen(req, timeout=10, context=SSL_CTX) as resp:
 
195
  "addressdetails": 1,
196
  })
197
  req = urllib.request.Request(
198
+ f"{NOMINATIM_URL}/search?{params}",
199
+ headers={"User-Agent": USER_AGENT},
200
  )
201
  try:
202
  with urllib.request.urlopen(req, timeout=10, context=SSL_CTX) as resp:
 
303
 
304
  def run(input_csv: str, output_csv: str | None = None) -> list[dict]:
305
  """Geocode every row in input_csv and write results to output_csv (or in-place)."""
306
+ if os.environ.get("HOSTED", "").lower() in ("1", "true", "yes") and _PUBLIC_NOMINATIM in NOMINATIM_URL:
307
+ raise RuntimeError(
308
+ "Refusing to bulk-geocode against public Nominatim in hosted mode. "
309
+ "Set NOMINATIM_URL to a self-hosted instance β€” OSM's usage policy "
310
+ "forbids bulk automated use of the public endpoint."
311
+ )
312
+
313
  target = output_csv or input_csv
314
 
315
  with open(input_csv, encoding="utf-8") as f:
 
317
 
318
  print(f"Loaded {len(rows)} rows. Geocoding with country + city + proximity validation...\n")
319
 
320
+ cache = _load_cache()
321
+
322
  for row in rows:
323
  row["lat"] = ""
324
  row["lng"] = ""
 
327
  name = row.get("name", "")
328
  city = row.get("city", "")
329
  country = row.get("country", "")
330
+
331
+ key = _cache_key(name, city, row.get("state", ""), country, row.get("address", ""))
332
+ if key in cache:
333
+ lat, lng = cache[key]
334
+ print(f" [{i}/{len(rows)}] {name} β€” {city}, {country} (cached)")
335
+ else:
336
+ print(f" [{i}/{len(rows)}] {name} β€” {city}, {country}")
337
+ lat, lng = geocode_row(
338
+ name,
339
+ city,
340
+ row.get("state", ""),
341
+ country,
342
+ row.get("address", ""),
343
+ )
344
+ cache[key] = [lat, lng]
345
+
346
  row["lat"] = f"{lat:.7f}" if lat is not None else ""
347
  row["lng"] = f"{lng:.7f}" if lng is not None else ""
348
 
349
  status = f"β†’ {lat:.4f}, {lng:.4f}" if lat is not None else "β†’ not found (left blank)"
350
  print(f" {status}")
351
 
352
+ _save_cache(cache)
353
+
354
  with open(target, "w", newline="", encoding="utf-8") as f:
355
  writer = csv.DictWriter(f, fieldnames=rows[0].keys())
356
  writer.writeheader()
pipeline/override.py CHANGED
@@ -2,7 +2,7 @@
2
  Manual override for restaurants that geocoding couldn't place.
3
 
4
  Workflow:
5
- 1. After geocoding, restaurants_override.csv is generated (or updated)
6
  with all rows that still have no lat/lng.
7
  2. User opens it, finds the restaurant on Google Maps, and pastes the URL
8
  into the maps_url column (or enters lat/lng directly).
 
2
  Manual override for restaurants that geocoding couldn't place.
3
 
4
  Workflow:
5
+ 1. After geocoding, places_override.csv is generated (or updated)
6
  with all rows that still have no lat/lng.
7
  2. User opens it, finds the restaurant on Google Maps, and pastes the URL
8
  into the maps_url column (or enters lat/lng directly).
pipeline/transcribe.py CHANGED
@@ -6,7 +6,7 @@ For each post URL not already in the extracted CSV, this module:
6
  2. Transcribes it with Whisper (local, free β€” requires openai-whisper + torch)
7
  3. Re-runs Claude extraction on the transcript
8
  4. Appends new finds to the extracted CSV
9
- 5. Writes restaurants_skipped.csv with URLs that failed or were not food,
10
  so the user can manually review and add them
11
 
12
  Install extra deps first:
@@ -144,7 +144,7 @@ def run(
144
 
145
  New rows are appended to extracted_csv with lat/lng blank.
146
  All posts that couldn't be downloaded or weren't food are written to
147
- restaurants_skipped.csv for manual review.
148
 
149
  Download failures are never checkpointed β€” they always retry so that
150
  adding browser auth takes effect without clearing the checkpoint.
@@ -157,7 +157,7 @@ def run(
157
  return
158
 
159
  checkpoint_path = Path(str(Path(extracted_csv).with_suffix("")) + _CHECKPOINT_SUFFIX)
160
- skipped_csv_path = Path(extracted_csv).parent / "restaurants_skipped.csv"
161
 
162
  checkpoint = _load_checkpoint(checkpoint_path) if resume else {}
163
  already_done = sum(1 for u in skipped if u["url"] in checkpoint)
@@ -172,7 +172,7 @@ def run(
172
  client = anthropic.Anthropic()
173
 
174
  new_rows: list[dict] = []
175
- skipped_records: list[dict] = [] # for restaurants_skipped.csv
176
 
177
  print()
178
  bar = tqdm(skipped, desc="Transcribing reels", unit="reel", dynamic_ncols=True)
 
6
  2. Transcribes it with Whisper (local, free β€” requires openai-whisper + torch)
7
  3. Re-runs Claude extraction on the transcript
8
  4. Appends new finds to the extracted CSV
9
+ 5. Writes places_skipped.csv with URLs that failed or were not food,
10
  so the user can manually review and add them
11
 
12
  Install extra deps first:
 
144
 
145
  New rows are appended to extracted_csv with lat/lng blank.
146
  All posts that couldn't be downloaded or weren't food are written to
147
+ places_skipped.csv for manual review.
148
 
149
  Download failures are never checkpointed β€” they always retry so that
150
  adding browser auth takes effect without clearing the checkpoint.
 
157
  return
158
 
159
  checkpoint_path = Path(str(Path(extracted_csv).with_suffix("")) + _CHECKPOINT_SUFFIX)
160
+ skipped_csv_path = Path(extracted_csv).parent / "places_skipped.csv"
161
 
162
  checkpoint = _load_checkpoint(checkpoint_path) if resume else {}
163
  already_done = sum(1 for u in skipped if u["url"] in checkpoint)
 
172
  client = anthropic.Anthropic()
173
 
174
  new_rows: list[dict] = []
175
+ skipped_records: list[dict] = [] # for places_skipped.csv
176
 
177
  print()
178
  bar = tqdm(skipped, desc="Transcribing reels", unit="reel", dynamic_ncols=True)
web/app.py CHANGED
@@ -37,6 +37,7 @@ JOBS_DIR.mkdir(exist_ok=True)
37
 
38
  OLLAMA_ENABLED = os.environ.get("OLLAMA_ENABLED", "true").lower() not in ("0", "false", "no")
39
  OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434")
 
40
 
41
  _BENCHMARK_RESULTS_PATH = Path(__file__).parent.parent / "tests" / "benchmark_results.json"
42
 
@@ -48,17 +49,66 @@ def _load_benchmark_scores() -> dict:
48
  except (FileNotFoundError, json.JSONDecodeError):
49
  return {}
50
 
51
- app = FastAPI(title="Instagram Restaurant Mapper")
52
  templates = Jinja2Templates(directory=str(Path(__file__).parent / "templates"))
53
 
54
- # In-memory job state (survives for the lifetime of the server process)
 
 
 
 
 
55
  _jobs: dict[str, dict] = {}
56
  _lock = threading.Lock()
57
 
58
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  def _update(job_id: str, **kwargs) -> None:
60
  with _lock:
61
  _jobs[job_id].update(kwargs)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
 
63
 
64
  def _haversine_km(lat1, lon1, lat2, lon2) -> float:
@@ -75,9 +125,9 @@ def _run_pipeline(job_id: str, json_bytes: bytes, model: str,
75
  job_dir = JOBS_DIR / job_id
76
  job_dir.mkdir(exist_ok=True)
77
  json_path = job_dir / "saved_posts.json"
78
- csv_path = job_dir / "restaurants_full.csv"
79
- kml_path = job_dir / "restaurants_map.kml"
80
- override_path = job_dir / "restaurants_override.csv"
81
 
82
  try:
83
  json_path.write_bytes(json_bytes)
@@ -107,7 +157,7 @@ def _run_pipeline(job_id: str, json_bytes: bytes, model: str,
107
  extracted = list(csv.DictReader(f))
108
 
109
  _update(job_id, step="geocode", progress=55,
110
- message=f"Found {len(extracted)} restaurants β€” geocoding…",
111
  extracted=len(extracted))
112
 
113
  # ── Geocode ────────────────────────────────────────────────────────
@@ -143,9 +193,9 @@ def _run_pipeline(job_id: str, json_bytes: bytes, model: str,
143
  def _run_geocode_export(job_id: str) -> None:
144
  """Geocode + export an already-written CSV (no extraction step)."""
145
  job_dir = JOBS_DIR / job_id
146
- csv_path = job_dir / "restaurants_full.csv"
147
- kml_path = job_dir / "restaurants_map.kml"
148
- override_path = job_dir / "restaurants_override.csv"
149
  try:
150
  _update(job_id, step="geocode", progress=30,
151
  message="Geocoding places…")
@@ -192,7 +242,7 @@ class RegeocodeRequest(BaseModel):
192
 
193
 
194
  def _csv_path(job_id: str) -> Path:
195
- return JOBS_DIR / job_id / "restaurants_full.csv"
196
 
197
 
198
  def _read_csv(job_id: str) -> list[dict] | None:
@@ -265,10 +315,13 @@ async def upload(
265
  active_model = model if model in MODELS else DEFAULT_MODEL
266
 
267
  contents = await file.read()
 
268
  job_id = uuid.uuid4().hex
269
 
 
270
  with _lock:
271
- _jobs[job_id] = {"step": "queued", "progress": 0, "message": "Starting…"}
 
272
 
273
  thread = threading.Thread(
274
  target=_run_pipeline,
@@ -287,8 +340,7 @@ async def progress(job_id: str):
287
  async def _generate():
288
  import asyncio
289
  while True:
290
- with _lock:
291
- state = dict(_jobs.get(job_id, {"step": "unknown", "message": "Job not found"}))
292
  yield f"data: {json.dumps(state)}\n\n"
293
  if state.get("step") in ("done", "error"):
294
  break
@@ -300,11 +352,11 @@ async def progress(job_id: str):
300
 
301
  @app.get("/download/{job_id}")
302
  async def download(job_id: str):
303
- kml = JOBS_DIR / job_id / "restaurants_map.kml"
304
  if not kml.exists():
305
  return HTMLResponse("Not found", status_code=404)
306
  return FileResponse(str(kml), media_type="application/vnd.google-earth.kml+xml",
307
- filename="restaurants_map.kml")
308
 
309
 
310
  @app.get("/download/{job_id}/geojson")
@@ -316,7 +368,7 @@ async def download_geojson(job_id: str):
316
  return Response(
317
  content=geojson_str,
318
  media_type="application/geo+json",
319
- headers={"Content-Disposition": 'attachment; filename="restaurants_map.geojson"'},
320
  )
321
 
322
 
@@ -326,7 +378,7 @@ async def download_csv(job_id: str):
326
  if not csv_p.exists():
327
  return HTMLResponse("Not found", status_code=404)
328
  return FileResponse(str(csv_p), media_type="text/csv",
329
- filename="restaurants_full.csv")
330
 
331
 
332
  @app.get("/results/{job_id}")
@@ -383,7 +435,7 @@ async def reexport(job_id: str):
383
  csv_p = _csv_path(job_id)
384
  if not csv_p.exists():
385
  return JSONResponse({"error": "Job not found"}, status_code=404)
386
- kml_p = JOBS_DIR / job_id / "restaurants_map.kml"
387
 
388
  def _run():
389
  with contextlib.redirect_stdout(io.StringIO()):
@@ -394,7 +446,8 @@ async def reexport(job_id: str):
394
 
395
 
396
  class ChatbotProcessRequest(BaseModel):
397
- response_json: str
 
398
 
399
 
400
  @app.post("/chatbot-prepare")
@@ -402,6 +455,7 @@ async def chatbot_prepare(file: UploadFile = File(...)):
402
  """Receive an Instagram JSON/zip, run prefilter, return the chatbot export package."""
403
  import zipfile as _zipfile
404
  contents = await file.read()
 
405
 
406
  if file.filename and file.filename.lower().endswith(".zip"):
407
  try:
@@ -432,8 +486,10 @@ async def chatbot_prepare(file: UploadFile = File(...)):
432
  json.dumps(result["post_mapping"]), encoding="utf-8"
433
  )
434
 
 
435
  with _lock:
436
- _jobs[job_id] = {"step": "chatbot_pending", "progress": 0, "message": "Waiting for chatbot response…"}
 
437
 
438
  return {
439
  "job_id": job_id,
@@ -443,6 +499,8 @@ async def chatbot_prepare(file: UploadFile = File(...)):
443
  "skipped": result["skipped"],
444
  "prompt": chatbot_mod.get_prompt(len(result["export_posts"])),
445
  "warn_large": len(result["export_posts"]) > chatbot_mod.CHUNK_WARN_THRESHOLD,
 
 
446
  }
447
 
448
 
@@ -459,8 +517,9 @@ async def chatbot_process(job_id: str, body: ChatbotProcessRequest):
459
  post_mapping = json.load(f)
460
 
461
  from pipeline import chatbot as chatbot_mod
 
462
  try:
463
- rows = chatbot_mod.parse_response(body.response_json, post_mapping)
464
  except ValueError as exc:
465
  return JSONResponse({"error": str(exc)}, status_code=422)
466
 
@@ -470,7 +529,7 @@ async def chatbot_process(job_id: str, body: ChatbotProcessRequest):
470
  status_code=422,
471
  )
472
 
473
- csv_path = job_dir / "restaurants_full.csv"
474
  with open(csv_path, "w", newline="", encoding="utf-8") as f:
475
  writer = csv.DictWriter(f, fieldnames=FIELDNAMES)
476
  writer.writeheader()
@@ -490,24 +549,19 @@ async def chatbot_process(job_id: str, body: ChatbotProcessRequest):
490
  async def roulette(job_id: str = "", lat: float = None, lng: float = None,
491
  radius_km: float = 40, city: str = "", all_statuses: bool = False,
492
  category: str = ""):
493
- """Pick a random restaurant from the most recent job's CSV."""
494
- import random
495
 
496
- # Find CSV: prefer the given job, otherwise the newest job with a CSV
497
- csv_path = None
498
- if job_id:
499
- p = JOBS_DIR / job_id / "restaurants_full.csv"
500
- if p.exists():
501
- csv_path = p
502
 
503
- if csv_path is None:
504
- candidates = sorted(JOBS_DIR.glob("*/restaurants_full.csv"),
505
- key=lambda p: p.stat().st_mtime, reverse=True)
506
- if candidates:
507
- csv_path = candidates[0]
508
 
509
- if csv_path is None:
510
- return {"error": "No restaurant data yet. Run a pipeline first."}
 
511
 
512
  with open(csv_path, encoding="utf-8") as f:
513
  rows = [r for r in csv.DictReader(f)
@@ -517,7 +571,7 @@ async def roulette(job_id: str = "", lat: float = None, lng: float = None,
517
  if city:
518
  rows = [r for r in rows if r.get("city", "").strip().lower() == city.lower()]
519
  if not rows:
520
- return {"error": f"No restaurants found in {city}."}
521
  elif lat is not None and lng is not None:
522
  def _ok(r):
523
  try:
 
37
 
38
  OLLAMA_ENABLED = os.environ.get("OLLAMA_ENABLED", "true").lower() not in ("0", "false", "no")
39
  OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434")
40
+ JOB_TTL_HOURS = float(os.environ.get("JOB_TTL_HOURS", "24"))
41
 
42
  _BENCHMARK_RESULTS_PATH = Path(__file__).parent.parent / "tests" / "benchmark_results.json"
43
 
 
49
  except (FileNotFoundError, json.JSONDecodeError):
50
  return {}
51
 
52
+ app = FastAPI(title="Instagram Places Mapper")
53
  templates = Jinja2Templates(directory=str(Path(__file__).parent / "templates"))
54
 
55
+
56
+ @app.on_event("startup")
57
+ def _on_startup() -> None:
58
+ _cleanup_old_jobs()
59
+
60
+ # In-memory job state, mirrored to <job_dir>/state.json so it survives a restart.
61
  _jobs: dict[str, dict] = {}
62
  _lock = threading.Lock()
63
 
64
 
65
+ def _state_path(job_id: str) -> Path:
66
+ return JOBS_DIR / job_id / "state.json"
67
+
68
+
69
+ def _persist_state(job_id: str, state: dict) -> None:
70
+ """Mirror a job's state to disk so the progress endpoint survives a restart."""
71
+ try:
72
+ job_dir = JOBS_DIR / job_id
73
+ job_dir.mkdir(exist_ok=True)
74
+ _state_path(job_id).write_text(json.dumps(state), encoding="utf-8")
75
+ except OSError:
76
+ pass # disk mirror is best-effort; in-memory state is authoritative
77
+
78
+
79
+ def _get_state(job_id: str) -> dict:
80
+ """Return a job's state from memory, falling back to the on-disk mirror."""
81
+ with _lock:
82
+ if job_id in _jobs:
83
+ return dict(_jobs[job_id])
84
+ try:
85
+ return json.loads(_state_path(job_id).read_text(encoding="utf-8"))
86
+ except (OSError, json.JSONDecodeError):
87
+ return {"step": "unknown", "message": "Job not found"}
88
+
89
+
90
  def _update(job_id: str, **kwargs) -> None:
91
  with _lock:
92
  _jobs[job_id].update(kwargs)
93
+ snapshot = dict(_jobs[job_id])
94
+ _persist_state(job_id, snapshot)
95
+
96
+
97
+ def _cleanup_old_jobs() -> None:
98
+ """Delete job directories older than JOB_TTL_HOURS (best-effort)."""
99
+ import shutil
100
+ import time as _time
101
+ cutoff = _time.time() - JOB_TTL_HOURS * 3600
102
+ for d in JOBS_DIR.glob("*"):
103
+ if not d.is_dir():
104
+ continue
105
+ try:
106
+ if d.stat().st_mtime < cutoff:
107
+ shutil.rmtree(d, ignore_errors=True)
108
+ with _lock:
109
+ _jobs.pop(d.name, None)
110
+ except OSError:
111
+ pass
112
 
113
 
114
  def _haversine_km(lat1, lon1, lat2, lon2) -> float:
 
125
  job_dir = JOBS_DIR / job_id
126
  job_dir.mkdir(exist_ok=True)
127
  json_path = job_dir / "saved_posts.json"
128
+ csv_path = job_dir / "places_full.csv"
129
+ kml_path = job_dir / "places_map.kml"
130
+ override_path = job_dir / "places_override.csv"
131
 
132
  try:
133
  json_path.write_bytes(json_bytes)
 
157
  extracted = list(csv.DictReader(f))
158
 
159
  _update(job_id, step="geocode", progress=55,
160
+ message=f"Found {len(extracted)} places β€” geocoding…",
161
  extracted=len(extracted))
162
 
163
  # ── Geocode ────────────────────────────────────────────────────────
 
193
  def _run_geocode_export(job_id: str) -> None:
194
  """Geocode + export an already-written CSV (no extraction step)."""
195
  job_dir = JOBS_DIR / job_id
196
+ csv_path = job_dir / "places_full.csv"
197
+ kml_path = job_dir / "places_map.kml"
198
+ override_path = job_dir / "places_override.csv"
199
  try:
200
  _update(job_id, step="geocode", progress=30,
201
  message="Geocoding places…")
 
242
 
243
 
244
  def _csv_path(job_id: str) -> Path:
245
+ return JOBS_DIR / job_id / "places_full.csv"
246
 
247
 
248
  def _read_csv(job_id: str) -> list[dict] | None:
 
315
  active_model = model if model in MODELS else DEFAULT_MODEL
316
 
317
  contents = await file.read()
318
+ _cleanup_old_jobs()
319
  job_id = uuid.uuid4().hex
320
 
321
+ initial = {"step": "queued", "progress": 0, "message": "Starting…"}
322
  with _lock:
323
+ _jobs[job_id] = initial
324
+ _persist_state(job_id, initial)
325
 
326
  thread = threading.Thread(
327
  target=_run_pipeline,
 
340
  async def _generate():
341
  import asyncio
342
  while True:
343
+ state = _get_state(job_id)
 
344
  yield f"data: {json.dumps(state)}\n\n"
345
  if state.get("step") in ("done", "error"):
346
  break
 
352
 
353
  @app.get("/download/{job_id}")
354
  async def download(job_id: str):
355
+ kml = JOBS_DIR / job_id / "places_map.kml"
356
  if not kml.exists():
357
  return HTMLResponse("Not found", status_code=404)
358
  return FileResponse(str(kml), media_type="application/vnd.google-earth.kml+xml",
359
+ filename="places_map.kml")
360
 
361
 
362
  @app.get("/download/{job_id}/geojson")
 
368
  return Response(
369
  content=geojson_str,
370
  media_type="application/geo+json",
371
+ headers={"Content-Disposition": 'attachment; filename="places_map.geojson"'},
372
  )
373
 
374
 
 
378
  if not csv_p.exists():
379
  return HTMLResponse("Not found", status_code=404)
380
  return FileResponse(str(csv_p), media_type="text/csv",
381
+ filename="places_full.csv")
382
 
383
 
384
  @app.get("/results/{job_id}")
 
435
  csv_p = _csv_path(job_id)
436
  if not csv_p.exists():
437
  return JSONResponse({"error": "Job not found"}, status_code=404)
438
+ kml_p = JOBS_DIR / job_id / "places_map.kml"
439
 
440
  def _run():
441
  with contextlib.redirect_stdout(io.StringIO()):
 
446
 
447
 
448
  class ChatbotProcessRequest(BaseModel):
449
+ response_json: str = ""
450
+ responses: list[str] | None = None # one per export chunk (preferred)
451
 
452
 
453
  @app.post("/chatbot-prepare")
 
455
  """Receive an Instagram JSON/zip, run prefilter, return the chatbot export package."""
456
  import zipfile as _zipfile
457
  contents = await file.read()
458
+ _cleanup_old_jobs()
459
 
460
  if file.filename and file.filename.lower().endswith(".zip"):
461
  try:
 
486
  json.dumps(result["post_mapping"]), encoding="utf-8"
487
  )
488
 
489
+ pending = {"step": "chatbot_pending", "progress": 0, "message": "Waiting for chatbot response…"}
490
  with _lock:
491
+ _jobs[job_id] = pending
492
+ _persist_state(job_id, pending)
493
 
494
  return {
495
  "job_id": job_id,
 
499
  "skipped": result["skipped"],
500
  "prompt": chatbot_mod.get_prompt(len(result["export_posts"])),
501
  "warn_large": len(result["export_posts"]) > chatbot_mod.CHUNK_WARN_THRESHOLD,
502
+ "chunk_size": chatbot_mod.CHUNK_SIZE,
503
+ "chunk_count": len(chatbot_mod.chunk_export(result["export_posts"])),
504
  }
505
 
506
 
 
517
  post_mapping = json.load(f)
518
 
519
  from pipeline import chatbot as chatbot_mod
520
+ texts = body.responses if body.responses else [body.response_json]
521
  try:
522
+ rows = chatbot_mod.parse_responses(texts, post_mapping)
523
  except ValueError as exc:
524
  return JSONResponse({"error": str(exc)}, status_code=422)
525
 
 
529
  status_code=422,
530
  )
531
 
532
+ csv_path = job_dir / "places_full.csv"
533
  with open(csv_path, "w", newline="", encoding="utf-8") as f:
534
  writer = csv.DictWriter(f, fieldnames=FIELDNAMES)
535
  writer.writeheader()
 
549
  async def roulette(job_id: str = "", lat: float = None, lng: float = None,
550
  radius_km: float = 40, city: str = "", all_statuses: bool = False,
551
  category: str = ""):
552
+ """Pick a random place from a specific job's CSV.
 
553
 
554
+ A job_id is required β€” there is deliberately no "newest job wins" fallback,
555
+ which would leak one user's places to another on a shared deployment.
556
+ """
557
+ import random
 
 
558
 
559
+ if not job_id:
560
+ return {"error": "No job selected. Run a pipeline first."}
 
 
 
561
 
562
+ csv_path = JOBS_DIR / job_id / "places_full.csv"
563
+ if not csv_path.exists():
564
+ return {"error": "No place data for this job yet. Run a pipeline first."}
565
 
566
  with open(csv_path, encoding="utf-8") as f:
567
  rows = [r for r in csv.DictReader(f)
 
571
  if city:
572
  rows = [r for r in rows if r.get("city", "").strip().lower() == city.lower()]
573
  if not rows:
574
+ return {"error": f"No places found in {city}."}
575
  elif lat is not None and lng is not None:
576
  def _ok(r):
577
  try:
web/templates/index.html CHANGED
@@ -3,7 +3,7 @@
3
  <head>
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>The Belagurian Β· Restaurant Map</title>
7
  <style>
8
  :root {
9
  --ig1: #e1306c;
@@ -711,7 +711,7 @@
711
  <div class="logo-ring">
712
  <div class="logo-inner">🍜</div>
713
  </div>
714
- <h1>Restaurant Mapper</h1>
715
  <p class="tagline">Turn your Instagram saved posts into a Google My Maps import</p>
716
  </div>
717
 
@@ -820,14 +820,12 @@
820
  </ol>
821
  <!-- Step outputs (hidden until prepare runs) -->
822
  <div id="chatbot-step2" style="display:none;margin-top:16px;">
823
- <div style="display:flex;gap:10px;flex-wrap:wrap;margin-bottom:12px;">
824
- <a id="chatbot-download-btn" class="btn btn-outline" download="instagram_places.json" style="text-decoration:none;">
825
- ⬇ Download posts JSON
826
- </a>
827
  <button class="btn btn-outline" id="chatbot-copy-prompt-btn" type="button">πŸ“‹ Copy prompt</button>
828
  </div>
829
- <div id="chatbot-warn-large" style="display:none;background:#fff8e1;border:1px solid #f0c040;border-radius:6px;padding:10px 12px;font-size:0.8rem;margin-bottom:12px;">
830
- ⚠️ Your library has <strong id="chatbot-post-count"></strong> posts to analyse. Most free AI tiers handle this fine, but if you hit a limit, split the JSON into two halves and run each separately.
831
  </div>
832
  <label style="font-size:0.82rem;font-weight:600;display:block;margin-bottom:6px;">
833
  Paste the chatbot's response here:
@@ -835,6 +833,10 @@
835
  <textarea id="chatbot-response-area" rows="6"
836
  placeholder='[{"post_number": 1, "is_place": true, "category": "Restaurant", …}, …]'
837
  style="width:100%;padding:8px 10px;border:1px solid var(--border);border-radius:6px;font-size:0.78rem;font-family:monospace;resize:vertical;"></textarea>
 
 
 
 
838
  <button class="btn" id="chatbot-process-btn" type="button" style="margin-top:10px;width:100%;" disabled>
839
  Process response &amp; build map β†’
840
  </button>
@@ -895,7 +897,7 @@
895
  <div class="steps">
896
  <div class="step" id="s-extract">
897
  <div class="step-icon">πŸ€–</div>
898
- <span id="extract-step-label">Extract restaurants with AI</span>
899
  </div>
900
  <div class="step" id="s-geocode">
901
  <div class="step-icon">πŸ“</div>
@@ -1254,6 +1256,14 @@ $('btn-chatbot').addEventListener('click', () => setProvider('chatbot'));
1254
  // ── Chatbot handoff ────────────────────────────────────────────────────────
1255
  let _chatbotJobId = null;
1256
  let _chatbotPromptText = '';
 
 
 
 
 
 
 
 
1257
 
1258
  $('chatbot-prepare-btn').addEventListener('click', async () => {
1259
  const prepBtn = $('chatbot-prepare-btn');
@@ -1282,14 +1292,37 @@ $('chatbot-prepare-btn').addEventListener('click', async () => {
1282
  const data = await res.json();
1283
  _chatbotJobId = data.job_id;
1284
  _chatbotPromptText = data.prompt;
 
 
1285
 
1286
- const blob = new Blob([JSON.stringify(data.export_posts, null, 2)], { type: 'application/json' });
1287
- $('chatbot-download-btn').href = URL.createObjectURL(blob);
1288
-
1289
- if (data.warn_large) {
1290
- $('chatbot-post-count').textContent = data.post_count;
1291
- $('chatbot-warn-large').style.display = 'block';
1292
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1293
 
1294
  $('chatbot-step2').style.display = 'block';
1295
  prepBtn.innerHTML = 'βœ“ File ready β€” see steps below';
@@ -1303,14 +1336,31 @@ $('chatbot-copy-prompt-btn').addEventListener('click', () => {
1303
  });
1304
  });
1305
 
 
 
1306
  $('chatbot-response-area').addEventListener('input', () => {
1307
- $('chatbot-process-btn').disabled = !$('chatbot-response-area').value.trim();
 
 
 
 
 
 
 
 
 
 
 
 
1308
  });
1309
 
1310
  $('chatbot-process-btn').addEventListener('click', async () => {
1311
  const processBtn = $('chatbot-process-btn');
1312
- const responseText = $('chatbot-response-area').value.trim();
1313
- if (!responseText || !_chatbotJobId) return;
 
 
 
1314
 
1315
  processBtn.innerHTML = '<span class="spinner"></span>Processing…';
1316
  processBtn.disabled = true;
@@ -1320,7 +1370,7 @@ $('chatbot-process-btn').addEventListener('click', async () => {
1320
  res = await fetch('/chatbot-process/' + _chatbotJobId, {
1321
  method: 'POST',
1322
  headers: { 'Content-Type': 'application/json' },
1323
- body: JSON.stringify({ response_json: responseText }),
1324
  });
1325
  if (!res.ok) throw new Error(await res.text());
1326
  } catch (err) {
@@ -1395,7 +1445,7 @@ runBtn.addEventListener('click', async () => {
1395
  const modelLabel = activeProvider === 'ollama'
1396
  ? `Ollama / ${selectedOllamaModel()}`
1397
  : `Claude / ${selectedModel()}`;
1398
- $('extract-step-label').textContent = `Extract restaurants with ${modelLabel}`;
1399
 
1400
  $('upload-card').style.display = 'none';
1401
  $('progress-section').style.display = 'block';
@@ -1545,7 +1595,7 @@ $('spin-btn').addEventListener('click', async () => {
1545
  $('spin-btn').disabled = false;
1546
 
1547
  // Auto-flip to "All places" when all unvisited spots are exhausted
1548
- if (r.error && r.error.includes('No unvisited restaurants')) {
1549
  _setRouletteAllStatuses(true);
1550
  $('spin-btn').click();
1551
  return;
 
3
  <head>
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>The Belagurian Β· Places Map</title>
7
  <style>
8
  :root {
9
  --ig1: #e1306c;
 
711
  <div class="logo-ring">
712
  <div class="logo-inner">🍜</div>
713
  </div>
714
+ <h1>Places Mapper</h1>
715
  <p class="tagline">Turn your Instagram saved posts into a Google My Maps import</p>
716
  </div>
717
 
 
820
  </ol>
821
  <!-- Step outputs (hidden until prepare runs) -->
822
  <div id="chatbot-step2" style="display:none;margin-top:16px;">
823
+ <div style="margin-bottom:12px;">
824
+ <div id="chatbot-downloads" style="display:flex;gap:10px;flex-wrap:wrap;margin-bottom:10px;"></div>
 
 
825
  <button class="btn btn-outline" id="chatbot-copy-prompt-btn" type="button">πŸ“‹ Copy prompt</button>
826
  </div>
827
+ <div id="chatbot-chunk-note" style="display:none;background:#eef4ff;border:1px solid #c7d9ff;border-radius:6px;padding:10px 12px;font-size:0.8rem;margin-bottom:12px;">
828
+ Your library is split into <strong id="chatbot-chunk-count"></strong> files so each fits a free chatbot's reply limit. Run <em>each</em> file through the chatbot, and paste every reply below with <strong>Add response</strong> before processing.
829
  </div>
830
  <label style="font-size:0.82rem;font-weight:600;display:block;margin-bottom:6px;">
831
  Paste the chatbot's response here:
 
833
  <textarea id="chatbot-response-area" rows="6"
834
  placeholder='[{"post_number": 1, "is_place": true, "category": "Restaurant", …}, …]'
835
  style="width:100%;padding:8px 10px;border:1px solid var(--border);border-radius:6px;font-size:0.78rem;font-family:monospace;resize:vertical;"></textarea>
836
+ <div style="display:flex;gap:10px;align-items:center;margin-top:8px;">
837
+ <button class="btn btn-outline" id="chatbot-add-response-btn" type="button" style="display:none;">βž• Add response</button>
838
+ <span id="chatbot-added-count" style="font-size:0.8rem;color:var(--muted);"></span>
839
+ </div>
840
  <button class="btn" id="chatbot-process-btn" type="button" style="margin-top:10px;width:100%;" disabled>
841
  Process response &amp; build map β†’
842
  </button>
 
897
  <div class="steps">
898
  <div class="step" id="s-extract">
899
  <div class="step-icon">πŸ€–</div>
900
+ <span id="extract-step-label">Extract places with AI</span>
901
  </div>
902
  <div class="step" id="s-geocode">
903
  <div class="step-icon">πŸ“</div>
 
1256
  // ── Chatbot handoff ────────────────────────────────────────────────────────
1257
  let _chatbotJobId = null;
1258
  let _chatbotPromptText = '';
1259
+ let _chatbotChunkCount = 1;
1260
+ let _chatbotResponses = []; // accumulated pasted replies (multi-chunk mode)
1261
+
1262
+ function _updateAddedCount() {
1263
+ const el = $('chatbot-added-count');
1264
+ if (_chatbotChunkCount <= 1) { el.textContent = ''; return; }
1265
+ el.textContent = `${_chatbotResponses.length} of ${_chatbotChunkCount} added`;
1266
+ }
1267
 
1268
  $('chatbot-prepare-btn').addEventListener('click', async () => {
1269
  const prepBtn = $('chatbot-prepare-btn');
 
1292
  const data = await res.json();
1293
  _chatbotJobId = data.job_id;
1294
  _chatbotPromptText = data.prompt;
1295
+ _chatbotChunkCount = data.chunk_count || 1;
1296
+ _chatbotResponses = [];
1297
 
1298
+ // Split the export into chunk files and render a download link for each
1299
+ const posts = data.export_posts;
1300
+ const size = data.chunk_size || posts.length || 1;
1301
+ const dl = $('chatbot-downloads');
1302
+ dl.innerHTML = '';
1303
+ for (let c = 0; c < _chatbotChunkCount; c++) {
1304
+ const slice = posts.slice(c * size, (c + 1) * size);
1305
+ const blob = new Blob([JSON.stringify(slice, null, 2)], { type: 'application/json' });
1306
+ const a = document.createElement('a');
1307
+ a.className = 'btn btn-outline';
1308
+ a.style.textDecoration = 'none';
1309
+ a.download = _chatbotChunkCount > 1
1310
+ ? `instagram_places_${c + 1}of${_chatbotChunkCount}.json`
1311
+ : 'instagram_places.json';
1312
+ a.href = URL.createObjectURL(blob);
1313
+ a.textContent = _chatbotChunkCount > 1
1314
+ ? `⬇ File ${c + 1} of ${_chatbotChunkCount}`
1315
+ : '⬇ Download posts JSON';
1316
+ dl.appendChild(a);
1317
+ }
1318
+
1319
+ const multi = _chatbotChunkCount > 1;
1320
+ $('chatbot-chunk-note').style.display = multi ? 'block' : 'none';
1321
+ $('chatbot-add-response-btn').style.display = multi ? 'inline-block' : 'none';
1322
+ if (multi) $('chatbot-chunk-count').textContent = _chatbotChunkCount;
1323
+ $('chatbot-process-btn').disabled = true;
1324
+ $('chatbot-response-area').value = '';
1325
+ _updateAddedCount();
1326
 
1327
  $('chatbot-step2').style.display = 'block';
1328
  prepBtn.innerHTML = 'βœ“ File ready β€” see steps below';
 
1336
  });
1337
  });
1338
 
1339
+ // Single-chunk: enable Process as soon as there's text. Multi-chunk: Process is
1340
+ // gated on accumulated responses, so the textarea only feeds "Add response".
1341
  $('chatbot-response-area').addEventListener('input', () => {
1342
+ if (_chatbotChunkCount <= 1) {
1343
+ $('chatbot-process-btn').disabled = !$('chatbot-response-area').value.trim();
1344
+ }
1345
+ });
1346
+
1347
+ $('chatbot-add-response-btn').addEventListener('click', () => {
1348
+ const area = $('chatbot-response-area');
1349
+ const txt = area.value.trim();
1350
+ if (!txt) return;
1351
+ _chatbotResponses.push(txt);
1352
+ area.value = '';
1353
+ _updateAddedCount();
1354
+ $('chatbot-process-btn').disabled = _chatbotResponses.length === 0;
1355
  });
1356
 
1357
  $('chatbot-process-btn').addEventListener('click', async () => {
1358
  const processBtn = $('chatbot-process-btn');
1359
+ // Include anything still sitting in the textarea (single-chunk, or a forgotten "Add")
1360
+ const pending = $('chatbot-response-area').value.trim();
1361
+ const responses = _chatbotResponses.slice();
1362
+ if (pending) responses.push(pending);
1363
+ if (responses.length === 0 || !_chatbotJobId) return;
1364
 
1365
  processBtn.innerHTML = '<span class="spinner"></span>Processing…';
1366
  processBtn.disabled = true;
 
1370
  res = await fetch('/chatbot-process/' + _chatbotJobId, {
1371
  method: 'POST',
1372
  headers: { 'Content-Type': 'application/json' },
1373
+ body: JSON.stringify({ responses }),
1374
  });
1375
  if (!res.ok) throw new Error(await res.text());
1376
  } catch (err) {
 
1445
  const modelLabel = activeProvider === 'ollama'
1446
  ? `Ollama / ${selectedOllamaModel()}`
1447
  : `Claude / ${selectedModel()}`;
1448
+ $('extract-step-label').textContent = `Extract places with ${modelLabel}`;
1449
 
1450
  $('upload-card').style.display = 'none';
1451
  $('progress-section').style.display = 'block';
 
1595
  $('spin-btn').disabled = false;
1596
 
1597
  // Auto-flip to "All places" when all unvisited spots are exhausted
1598
+ if (r.error && r.error.includes('No unvisited places')) {
1599
  _setRouletteAllStatuses(true);
1600
  $('spin-btn').click();
1601
  return;