Spaces:
Runtime error
Feat: living-library cost-skip on re-extract (Phase 2)
Browse filesPOST /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>
- CLAUDE.md +10 -0
- README.md +1 -0
- TODO.md +5 -3
- tests/test_merge.py +62 -0
- web/app.py +34 -7
- web/static/app.js +4 -0
|
@@ -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).
|
|
@@ -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 |
|
|
@@ -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 |
-
- [
|
| 44 |
-
|
| 45 |
-
|
|
|
|
|
|
|
| 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.
|
|
@@ -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:")
|
|
@@ -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 |
-
|
|
|
|
|
|
|
| 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(
|
| 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,
|
| 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()
|
|
@@ -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 {
|