harshith99 Claude Opus 4.8 commited on
Commit
1a26356
Β·
1 Parent(s): d1b556d

Feat: living-library cost-skip on re-extract (Phase 2)

Browse files

POST /upload now accepts merge_into (the cached library CSV). _run_pipeline
seeds it as the job's places_full.csv and runs extract.run(resume=True), so
_warm_start_from_csv skips the LLM on posts already in the library (keyed on
instagram_url). new_urls excludes known posts, so the cost estimate and
progress message reflect only genuinely new ones ("X already in your library,
skipped"). After extraction the result is folded back with _merge_rows
(keep-existing-wins) so known posts keep status/coords and any place the new
export omitted is re-added.

Client sends the cached library on the Extract upload. tests/test_merge.py
gains a warm-start section that patches extract.analyze_batch to prove known
posts never reach the LLM and keep their visited status.

A 600-post re-export where 550 are already mapped now only pays to extract the
50 new ones. Phase 3 (polish) is next.

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

Files changed (6) hide show
  1. CLAUDE.md +10 -0
  2. README.md +1 -0
  3. TODO.md +5 -3
  4. tests/test_merge.py +62 -0
  5. web/app.py +34 -7
  6. web/static/app.js +4 -0
CLAUDE.md CHANGED
@@ -102,6 +102,16 @@ flags "nothing matched" (likely wrong file β†’ client offers Replace). Client (`
102
  `{forceReplace}`) sends `merge_into` from `localStorage.savedLibrary` unless restoring. Pinned
103
  by `tests/test_merge.py` (CI guardrail).
104
 
 
 
 
 
 
 
 
 
 
 
105
  **Downloads** live in the Browse sub-panel (`#download-bar`), revealed when `showSubTabBar` is called.
106
  Buttons: `#download-btn` (KML), `#download-geojson-btn`, `#download-csv-btn` β€” onclick set in
107
  both `showDownload` (post-extraction) and `handleImportFile` (post-import).
 
102
  `{forceReplace}`) sends `merge_into` from `localStorage.savedLibrary` unless restoring. Pinned
103
  by `tests/test_merge.py` (CI guardrail).
104
 
105
+ **`POST /upload` living-library cost-skip (Phase 2).** `/upload` also accepts `merge_into`
106
+ (the cached library CSV). `_run_pipeline` writes it into the job dir as the seed `places_full.csv`
107
+ and runs `extract.run(resume=True)`, so `_warm_start_from_csv` skips the **LLM** on posts already
108
+ in the library (keyed on `instagram_url`) β€” `new_urls` excludes known posts so the cost estimate
109
+ and progress message reflect only new ones. After extraction the result is folded back with
110
+ `_merge_rows(existing, extracted)` (keep-existing-wins) so known posts keep status/coords and any
111
+ library place the new export omitted is re-added. Geocoding re-runs on the merged CSV (cheap via
112
+ the geocode cache; hosted/client-geocode only pins coord-less rows). `tests/test_merge.py` patches
113
+ `extract.analyze_batch` to prove known posts never reach the LLM.
114
+
115
  **Downloads** live in the Browse sub-panel (`#download-bar`), revealed when `showSubTabBar` is called.
116
  Buttons: `#download-btn` (KML), `#download-geojson-btn`, `#download-csv-btn` β€” onclick set in
117
  both `showDownload` (post-extraction) and `handleImportFile` (post-import).
README.md CHANGED
@@ -332,6 +332,7 @@ The UI lets you:
332
  - **KML round-trip** β€” city and state are embedded in each Placemark's description so re-importing the KML preserves full location data (not just country)
333
  - **Remembers your library (this device)** β€” after a run, the app caches your places in the browser (`localStorage`) and keeps the copy in sync as you mark places visited, edit fields, or pin them. Come back later and a **πŸ“¦ Restore your last library** card brings everything back β€” visited status, edits, and pins included β€” with no re-upload and no re-extraction. It's stored only on your device and never uploaded; clearing your browser data removes it, so the Download buttons remain your permanent backup. Edits are flushed to the cache when you switch away from or close the tab, so a change made right before leaving isn't lost
334
  - **Living library β€” imports merge, not replace** β€” when you already have a library loaded and drop another KML/CSV, it's **merged into** your library keyed on the Instagram link (keep-existing-wins): your visited status and manual edits are preserved and only genuinely new places are added ("N new places added"). If nothing in the file matches your library, you're offered the choice to replace instead. This is the foundation for incrementally growing one map over time
 
335
 
336
  ### Web UI providers
337
 
 
332
  - **KML round-trip** β€” city and state are embedded in each Placemark's description so re-importing the KML preserves full location data (not just country)
333
  - **Remembers your library (this device)** β€” after a run, the app caches your places in the browser (`localStorage`) and keeps the copy in sync as you mark places visited, edit fields, or pin them. Come back later and a **πŸ“¦ Restore your last library** card brings everything back β€” visited status, edits, and pins included β€” with no re-upload and no re-extraction. It's stored only on your device and never uploaded; clearing your browser data removes it, so the Download buttons remain your permanent backup. Edits are flushed to the cache when you switch away from or close the tab, so a change made right before leaving isn't lost
334
  - **Living library β€” imports merge, not replace** β€” when you already have a library loaded and drop another KML/CSV, it's **merged into** your library keyed on the Instagram link (keep-existing-wins): your visited status and manual edits are preserved and only genuinely new places are added ("N new places added"). If nothing in the file matches your library, you're offered the choice to replace instead. This is the foundation for incrementally growing one map over time
335
+ - **Re-export costs nothing for places you already have** β€” when you re-run Extract on a fuller export months later, the app sends your existing library along, so the pipeline **skips the AI call on posts already in your map** and only extracts genuinely new ones (your visited status and pins are kept). A 600-post re-export where 550 are already mapped only pays to extract the 50 new ones
336
 
337
  ### Web UI providers
338
 
TODO.md CHANGED
@@ -40,9 +40,11 @@ The paid competitors (Someday Map, Rezz, ReelsMap, Drawer) are all closed, cloud
40
  curation, returns `added`/`merged`/`no_overlap`; client sends the cached library,
41
  shows an "N new places added" banner, and offers Replace when nothing matched.
42
  Pinned by `tests/test_merge.py` (CI). Unlocks the Shortcut/capture path.
43
- - [ ] **Phase 2 β€” server-side cost-skip for bulk re-export.** Pass the cached library
44
- to the Extract pipeline so `_warm_start_from_csv` skips the LLM on posts already in
45
- the library (saves $), and report "X already known, extracting Z new".
 
 
46
  - [ ] **Phase 3 β€” polish.** New-to-top sort + "new" card badge; an explicit
47
  merge-vs-replace toggle on the import card; a dedup/merge report.
48
  - [ ] **HF Spaces live deploy** β€” code-complete (see `deploy/hf-spaces/`); remaining step is the manual "create the Space + push" in `deploy/hf-spaces/SETUP.md`, then add the live URL to README.
 
40
  curation, returns `added`/`merged`/`no_overlap`; client sends the cached library,
41
  shows an "N new places added" banner, and offers Replace when nothing matched.
42
  Pinned by `tests/test_merge.py` (CI). Unlocks the Shortcut/capture path.
43
+ - [x] **Phase 2 β€” server-side cost-skip for bulk re-export (done).** `/upload` accepts
44
+ `merge_into`; `_run_pipeline` seeds it as the job CSV and runs `extract.run(resume=True)`
45
+ so `_warm_start_from_csv` skips the LLM on known posts (`new_urls` excludes them; progress
46
+ says "X already in your library, skipped"), then `_merge_rows` folds results back so
47
+ nothing is lost. `tests/test_merge.py` proves known posts never reach `analyze_batch`.
48
  - [ ] **Phase 3 β€” polish.** New-to-top sort + "new" card badge; an explicit
49
  merge-vs-replace toggle on the import card; a dedup/merge report.
50
  - [ ] **HF Spaces live deploy** β€” code-complete (see `deploy/hf-spaces/`); remaining step is the manual "create the Space + push" in `deploy/hf-spaces/SETUP.md`, then add the live URL to README.
tests/test_merge.py CHANGED
@@ -15,8 +15,10 @@ Run: python3 tests/test_merge.py
15
 
16
  import csv
17
  import io
 
18
  import shutil
19
  import sys
 
20
  from pathlib import Path
21
 
22
  ROOT = Path(__file__).parent.parent
@@ -28,6 +30,7 @@ except Exception as exc:
28
  print(f"SKIP: TestClient unavailable ({exc}). `pip install httpx` to run.")
29
  sys.exit(0)
30
 
 
31
  from pipeline import geocode as geocode_mod
32
  from pipeline.extract import FIELDNAMES
33
  from web import app as webapp
@@ -147,6 +150,65 @@ finally:
147
  for j in created:
148
  shutil.rmtree(webapp.JOBS_DIR / j, ignore_errors=True)
149
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
  print()
151
  if failures:
152
  print(f"FAIL β€” {len(failures)} merge check(s) failed:")
 
15
 
16
  import csv
17
  import io
18
+ import json
19
  import shutil
20
  import sys
21
+ import tempfile
22
  from pathlib import Path
23
 
24
  ROOT = Path(__file__).parent.parent
 
30
  print(f"SKIP: TestClient unavailable ({exc}). `pip install httpx` to run.")
31
  sys.exit(0)
32
 
33
+ from pipeline import extract as extract_mod
34
  from pipeline import geocode as geocode_mod
35
  from pipeline.extract import FIELDNAMES
36
  from web import app as webapp
 
150
  for j in created:
151
  shutil.rmtree(webapp.JOBS_DIR / j, ignore_errors=True)
152
 
153
+ # ── Phase 2: warm-start cost-skip (extract.run skips the LLM on known posts) ───
154
+ # When the job CSV is seeded with the existing library, a re-export must not pay
155
+ # the LLM again for posts already extracted β€” only genuinely new posts hit it,
156
+ # and the known post's status survives.
157
+ seen_urls: list[str] = []
158
+
159
+
160
+ def _recording_analyze_batch(client, posts, model=None, provider="anthropic", ollama_url=""):
161
+ out = []
162
+ for p in posts:
163
+ seen_urls.append(p["url"])
164
+ row = {k: "UNKNOWN" for k in FIELDNAMES}
165
+ row.update(name="Koffee Mameya", city="Tokyo", country="Japan", category="Cafe")
166
+ out.append(row)
167
+ return out
168
+
169
+
170
+ _orig_ab = extract_mod.analyze_batch
171
+ extract_mod.analyze_batch = _recording_analyze_batch
172
+ tmp = Path(tempfile.mkdtemp())
173
+ try:
174
+ json_p = tmp / "saved_posts.json"
175
+ csv_p = tmp / "places_full.csv"
176
+
177
+ # Export contains a KNOWN post (aaa, already curated) + a NEW post (ccc).
178
+ json_p.write_text(json.dumps([
179
+ {"label_values": [{"label": "URL", "value": "https://instagram.com/p/aaa"},
180
+ {"label": "Caption", "value": "Ichiran ramen Tokyo"}],
181
+ "timestamp": 1700000000},
182
+ {"label_values": [{"label": "URL", "value": "https://instagram.com/p/ccc"},
183
+ {"label": "Caption", "value": "Amazing new cafe @koffee_mameya in Tokyo"}],
184
+ "timestamp": 1700000100},
185
+ ]), encoding="utf-8")
186
+
187
+ # Seed the existing library: aaa already extracted and marked visited.
188
+ with open(csv_p, "w", newline="", encoding="utf-8") as f:
189
+ w = csv.DictWriter(f, fieldnames=FIELDNAMES)
190
+ w.writeheader()
191
+ w.writerow({**{k: "" for k in FIELDNAMES}, "name": "Ichiran", "city": "Tokyo",
192
+ "country": "Japan", "category": "Restaurant", "status": "visited",
193
+ "instagram_url": "https://instagram.com/p/aaa"})
194
+
195
+ extract_mod.run(str(json_p), str(csv_p), resume=True, provider="anthropic",
196
+ api_key="sk-test-unused", batch_size=10)
197
+
198
+ check("warm-start: LLM called only for the NEW post (known aaa skipped)",
199
+ seen_urls == ["https://instagram.com/p/ccc"])
200
+
201
+ with open(csv_p) as f:
202
+ out_rows = {r["instagram_url"]: r for r in csv.DictReader(f)}
203
+ check("warm-start: known + new both in output",
204
+ set(out_rows) == {"https://instagram.com/p/aaa", "https://instagram.com/p/ccc"})
205
+ check("warm-start: known post keeps visited status",
206
+ out_rows["https://instagram.com/p/aaa"]["status"] == "visited")
207
+ finally:
208
+ extract_mod.analyze_batch = _orig_ab
209
+ shutil.rmtree(tmp, ignore_errors=True)
210
+
211
+
212
  print()
213
  if failures:
214
  print(f"FAIL β€” {len(failures)} merge check(s) failed:")
web/app.py CHANGED
@@ -166,7 +166,7 @@ def _acquire_slot(job_id: str) -> None:
166
 
167
  def _run_pipeline(job_id: str, json_bytes: bytes, model: str,
168
  provider: str = "anthropic", ollama_url: str = "http://localhost:11434",
169
- api_key: str | None = None) -> None:
170
  job_dir = JOBS_DIR / job_id
171
  job_dir.mkdir(exist_ok=True)
172
  json_path = job_dir / "saved_posts.json"
@@ -178,6 +178,22 @@ def _run_pipeline(job_id: str, json_bytes: bytes, model: str,
178
  try:
179
  json_path.write_bytes(json_bytes)
180
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
181
  # ── Extract ────────────────────────────────────────────────────────
182
  _update(job_id, step="extract", progress=10,
183
  message="Reading your saved posts…")
@@ -189,25 +205,35 @@ def _run_pipeline(job_id: str, json_bytes: bytes, model: str,
189
  f"({len(posts)} posts, max {MAX_POSTS}). Run the Docker "
190
  f"or CLI version locally for libraries this big."))
191
  return
192
- new_urls = {p["url"] for p in posts}
 
 
193
  est = estimate_cost(posts, new_urls, model, provider=provider)
194
 
195
  provider_label = f"Ollama/{model}" if provider == "ollama" else f"Claude/{model}"
196
  pre_filtered = est.get("prefiltered", 0)
197
- llm_posts = est.get("llm_posts", len(posts))
198
  pre_note = f" ({pre_filtered} pre-filtered, no LLM call)" if pre_filtered else ""
 
199
  _update(job_id, step="extract", progress=15,
200
- message=f"Extracting {llm_posts} posts with {provider_label}{pre_note}…",
201
  total_posts=len(posts), prefiltered=pre_filtered)
202
 
203
  with contextlib.redirect_stdout(io.StringIO()):
204
  extract_mod.run(str(json_path), str(csv_path), model=model,
205
- provider=provider, ollama_url=ollama_url, resume=False,
206
- batch_size=10, api_key=api_key)
207
 
208
  with open(csv_path) as f:
209
  extracted = list(csv.DictReader(f))
210
 
 
 
 
 
 
 
 
211
  # ── Geocode ────────────────────────────────────────────────────────
212
  if CLIENT_GEOCODE:
213
  # Hosted/shared deploy: skip server-side geocoding (public OSM bulk
@@ -529,6 +555,7 @@ async def upload(
529
  ollama_model: str = Form(DEFAULT_OLLAMA_MODEL),
530
  ollama_url: str = Form(None),
531
  api_key: str = Form(""),
 
532
  ):
533
  provider, active_model = resolve_provider(
534
  provider, model, ollama_model, ollama_enabled=OLLAMA_ENABLED
@@ -549,7 +576,7 @@ async def upload(
549
  target=_run_pipeline,
550
  args=(job_id, contents, active_model, provider,
551
  ollama_url or OLLAMA_URL,
552
- api_key.strip() or None),
553
  daemon=True,
554
  )
555
  thread.start()
 
166
 
167
  def _run_pipeline(job_id: str, json_bytes: bytes, model: str,
168
  provider: str = "anthropic", ollama_url: str = "http://localhost:11434",
169
+ api_key: str | None = None, merge_into: str = "") -> None:
170
  job_dir = JOBS_DIR / job_id
171
  job_dir.mkdir(exist_ok=True)
172
  json_path = job_dir / "saved_posts.json"
 
178
  try:
179
  json_path.write_bytes(json_bytes)
180
 
181
+ # ── Living-library merge: seed the job CSV with the caller's existing
182
+ # library so extract's warm-start skips the LLM on posts already known
183
+ # (keyed on instagram_url). Results are merged back below so nothing in
184
+ # the library is lost if a slimmer export omits it.
185
+ existing_rows: list[dict] = []
186
+ existing_urls: set[str] = set()
187
+ if merge_into:
188
+ try:
189
+ existing_rows = _parse_import_csv(merge_into.encode())
190
+ except ValueError:
191
+ existing_rows = []
192
+ if existing_rows:
193
+ _write_csv(job_id, existing_rows) # seed for warm-start
194
+ existing_urls = {r.get("instagram_url") for r in existing_rows
195
+ if r.get("instagram_url")}
196
+
197
  # ── Extract ────────────────────────────────────────────────────────
198
  _update(job_id, step="extract", progress=10,
199
  message="Reading your saved posts…")
 
205
  f"({len(posts)} posts, max {MAX_POSTS}). Run the Docker "
206
  f"or CLI version locally for libraries this big."))
207
  return
208
+ # Only posts not already in the library need the LLM.
209
+ new_urls = {p["url"] for p in posts if p["url"] not in existing_urls}
210
+ known = len(posts) - len(new_urls)
211
  est = estimate_cost(posts, new_urls, model, provider=provider)
212
 
213
  provider_label = f"Ollama/{model}" if provider == "ollama" else f"Claude/{model}"
214
  pre_filtered = est.get("prefiltered", 0)
215
+ llm_posts = est.get("llm_posts", len(new_urls))
216
  pre_note = f" ({pre_filtered} pre-filtered, no LLM call)" if pre_filtered else ""
217
+ known_note = f" β€” {known} already in your library, skipped" if known else ""
218
  _update(job_id, step="extract", progress=15,
219
+ message=f"Extracting {llm_posts} new posts with {provider_label}{pre_note}{known_note}…",
220
  total_posts=len(posts), prefiltered=pre_filtered)
221
 
222
  with contextlib.redirect_stdout(io.StringIO()):
223
  extract_mod.run(str(json_path), str(csv_path), model=model,
224
+ provider=provider, ollama_url=ollama_url,
225
+ resume=bool(existing_rows), batch_size=10, api_key=api_key)
226
 
227
  with open(csv_path) as f:
228
  extracted = list(csv.DictReader(f))
229
 
230
+ # Merge the fresh extraction back into the existing library
231
+ # (keep-existing-wins): preserves status/coords on known posts and
232
+ # re-adds any library places the new export happened to omit.
233
+ if existing_rows:
234
+ extracted, _added = _merge_rows(existing_rows, extracted)
235
+ _write_csv(job_id, extracted)
236
+
237
  # ── Geocode ────────────────────────────────────────────────────────
238
  if CLIENT_GEOCODE:
239
  # Hosted/shared deploy: skip server-side geocoding (public OSM bulk
 
555
  ollama_model: str = Form(DEFAULT_OLLAMA_MODEL),
556
  ollama_url: str = Form(None),
557
  api_key: str = Form(""),
558
+ merge_into: str = Form(""),
559
  ):
560
  provider, active_model = resolve_provider(
561
  provider, model, ollama_model, ollama_enabled=OLLAMA_ENABLED
 
576
  target=_run_pipeline,
577
  args=(job_id, contents, active_model, provider,
578
  ollama_url or OLLAMA_URL,
579
+ api_key.strip() or None, merge_into),
580
  daemon=True,
581
  )
582
  thread.start()
web/static/app.js CHANGED
@@ -803,6 +803,10 @@ runBtn.addEventListener('click', async () => {
803
  fd.append('model', selectedModel());
804
  fd.append('ollama_model', selectedOllamaModel());
805
  fd.append('api_key', ($('anthropic-api-key').value || '').trim());
 
 
 
 
806
 
807
  let res;
808
  try {
 
803
  fd.append('model', selectedModel());
804
  fd.append('ollama_model', selectedOllamaModel());
805
  fd.append('api_key', ($('anthropic-api-key').value || '').trim());
806
+ // Living library: send the cached library so the pipeline warm-starts and
807
+ // skips the LLM on posts already extracted (saves cost on a re-export).
808
+ const _lib = _readSavedLibrary();
809
+ if (_lib && _lib.csv) fd.append('merge_into', _lib.csv);
810
 
811
  let res;
812
  try {