harshith99 Claude Opus 4.8 commited on
Commit
8221e29
·
1 Parent(s): 9d3aa90

Feat: /capture endpoint core (Phase A) — single-post extraction

Browse files

Refactors _run_url_pipeline's extract core into shared _places_from_caption()
(analyze_batch → split_multi_venue → location-tag coords). New POST /capture
accepts embed_html (the iOS Shortcut fetches /embed/captioned/ on-device so the
server never touches Instagram), or a ready caption, or a url (local fetch),
plus optional lat/lng for a free location-tag pin. Returns {places, count}.
BYOK api_key. tests/test_capture.py added to CI; e2e still green (refactor safe).

Inbox delivery (Phase B) and the Shortcut itself (Phase C) are next.

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

Files changed (5) hide show
  1. .github/workflows/benchmark.yml +3 -0
  2. CLAUDE.md +6 -0
  3. README.md +1 -0
  4. tests/test_capture.py +104 -0
  5. web/app.py +110 -20
.github/workflows/benchmark.yml CHANGED
@@ -39,6 +39,9 @@ jobs:
39
  - name: Merge — living-library merge keeps curation
40
  run: python3 tests/test_merge.py
41
 
 
 
 
42
  benchmark:
43
  name: Benchmark smoke test (Ollama)
44
  runs-on: ubuntu-latest
 
39
  - name: Merge — living-library merge keeps curation
40
  run: python3 tests/test_merge.py
41
 
42
+ - name: Capture — single-post extraction (Shortcut path)
43
+ run: python3 tests/test_capture.py
44
+
45
  benchmark:
46
  name: Benchmark smoke test (Ollama)
47
  runs-on: ubuntu-latest
CLAUDE.md CHANGED
@@ -49,6 +49,12 @@ web/app.py FastAPI: pipeline, SSE progress, results browser, resolve_pro
49
  POST /import — KML or CSV re-import (no extraction, skip to tabs)
50
  POST /extract-url — single Instagram URL → caption fetch → LLM + geocode
51
  POST /url-chatbot-prepare — same but returns chatbot export package
 
 
 
 
 
 
52
  CAPTION_PROXY_URL env var: reserved for a future residential proxy
53
  web/static/ served at /static/ via FastAPI StaticFiles mount
54
  app.css all styles (~1300 lines, no Jinja)
 
49
  POST /import — KML or CSV re-import (no extraction, skip to tabs)
50
  POST /extract-url — single Instagram URL → caption fetch → LLM + geocode
51
  POST /url-chatbot-prepare — same but returns chatbot export package
52
+ POST /capture — single shared post → place(s) JSON (iOS Shortcut path).
53
+ Accepts embed_html (Shortcut fetches /embed/captioned/ ON-DEVICE so the
54
+ server never touches Instagram) / caption / url, + optional lat,lng
55
+ (IG location tag → free coords). Core extraction is the shared
56
+ _places_from_caption() (refactored out of _run_url_pipeline; both use it).
57
+ BYOK api_key. Inbox delivery (token) lands in Phase B.
58
  CAPTION_PROXY_URL env var: reserved for a future residential proxy
59
  web/static/ served at /static/ via FastAPI StaticFiles mount
60
  app.css all styles (~1300 lines, no Jinja)
README.md CHANGED
@@ -426,6 +426,7 @@ tests/
426
  test_prefilter.py Guardrail: prefilter zero-false-negatives against oracle (CI)
427
  test_persistence.py Guardrail: cache/restore CSV round-trip is lossless (CI)
428
  test_merge.py Guardrail: living-library merge keeps curation (CI)
 
429
  benchmark.py Evaluate any model against the oracle
430
  create_fixture.py Sample 50 posts for the benchmark fixture
431
  generate_ground_truth.py Label posts with Opus 4.8 (the oracle)
 
426
  test_prefilter.py Guardrail: prefilter zero-false-negatives against oracle (CI)
427
  test_persistence.py Guardrail: cache/restore CSV round-trip is lossless (CI)
428
  test_merge.py Guardrail: living-library merge keeps curation (CI)
429
+ test_capture.py Guardrail: /capture single-post extraction (CI)
430
  benchmark.py Evaluate any model against the oracle
431
  create_fixture.py Sample 50 posts for the benchmark fixture
432
  generate_ground_truth.py Label posts with Opus 4.8 (the oracle)
tests/test_capture.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ POST /capture — single-post extraction for the iOS Shortcut path (Phase A).
3
+
4
+ The Shortcut fetches /embed/captioned/ on-device and POSTs the HTML (or a ready
5
+ caption, or a URL for local server-fetch). /capture runs the shared
6
+ _places_from_caption and returns the extracted place(s). Optional lat/lng carry
7
+ an Instagram location tag → coordinates without geocoding.
8
+
9
+ Only the LLM is mocked. Run: python3 tests/test_capture.py
10
+ """
11
+
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ ROOT = Path(__file__).parent.parent
16
+ sys.path.insert(0, str(ROOT))
17
+
18
+ try:
19
+ from starlette.testclient import TestClient
20
+ except Exception as exc:
21
+ print(f"SKIP: TestClient unavailable ({exc}). `pip install httpx` to run.")
22
+ sys.exit(0)
23
+
24
+ from pipeline import extract as extract_mod
25
+ from web import app as webapp
26
+
27
+ failures: list[str] = []
28
+
29
+
30
+ def check(label: str, cond: bool) -> None:
31
+ print(("PASS" if cond else "FAIL"), "-", label)
32
+ if not cond:
33
+ failures.append(label)
34
+
35
+
36
+ def _place(name, city="Tokyo", country="Japan", category="Cafe"):
37
+ d = {k: "UNKNOWN" for k in ("name", "city", "state", "country", "address",
38
+ "cuisine", "price_range", "highlight", "occasion")}
39
+ d.update(name=name, city=city, country=country, category=category)
40
+ return d
41
+
42
+
43
+ def fake_analyze_batch(client, posts, model=None, provider="anthropic", ollama_url=""):
44
+ """Deterministic stand-in: a place when the caption looks place-y, else None."""
45
+ out = []
46
+ for p in posts:
47
+ cap = (p.get("caption") or "").lower()
48
+ if "ichiran" in cap or "ramen" in cap or "cafe" in cap or "koffee" in cap:
49
+ out.append(_place("Koffee Mameya" if "koffee" in cap else "Ichiran"))
50
+ else:
51
+ out.append(None)
52
+ return out
53
+
54
+
55
+ _orig_ab = extract_mod.analyze_batch
56
+ extract_mod.analyze_batch = fake_analyze_batch
57
+ try:
58
+ with TestClient(webapp.app) as client:
59
+
60
+ # ── caption path ──────────────────────────────────────────────────────
61
+ r = client.post("/capture", data={"caption": "Amazing @koffee_mameya cafe in Tokyo",
62
+ "url": "https://instagram.com/p/aaa",
63
+ "api_key": "sk-test-unused"})
64
+ check("caption path: 200", r.status_code == 200)
65
+ body = r.json()
66
+ check("caption path: 1 place", body.get("count") == 1)
67
+ check("caption path: place name extracted", body["places"][0]["name"] == "Koffee Mameya")
68
+ check("caption path: instagram_url carried", body["places"][0]["instagram_url"] == "https://instagram.com/p/aaa")
69
+
70
+ # ── location tag → coords without geocoding ─────────────────────────────
71
+ r2 = client.post("/capture", data={"caption": "Ichiran ramen", "url": "https://instagram.com/p/bbb",
72
+ "lat": "35.6595", "lng": "139.7005", "api_key": "x"})
73
+ p2 = r2.json()["places"][0]
74
+ check("location tag: lat applied", p2["lat"].startswith("35.6595"))
75
+ check("location tag: lng applied", p2["lng"].startswith("139.7005"))
76
+
77
+ # ── embed_html path (server parses the Caption block) ───────────────────
78
+ embed = ('<html><body><div class="Caption">'
79
+ '<a class="CaptionUsername" href="#">timeouttokyo_</a>'
80
+ 'Best ramen — Ichiran in Tokyo</div></body></html>')
81
+ r3 = client.post("/capture", data={"embed_html": embed,
82
+ "url": "https://instagram.com/p/ccc", "api_key": "x"})
83
+ check("embed_html path: 200", r3.status_code == 200)
84
+ check("embed_html path: place extracted", r3.json()["places"][0]["name"] == "Ichiran")
85
+
86
+ # ── error: no caption ───────────────────────────────────────────────────
87
+ r4 = client.post("/capture", data={"api_key": "x"})
88
+ check("no caption → 422", r4.status_code == 422)
89
+
90
+ # ── error: caption with no place ────────────────────────────────────────
91
+ r5 = client.post("/capture", data={"caption": "my gym workout routine today", "api_key": "x"})
92
+ check("non-place caption → 422", r5.status_code == 422)
93
+
94
+ finally:
95
+ extract_mod.analyze_batch = _orig_ab
96
+
97
+ print()
98
+ if failures:
99
+ print(f"FAIL — {len(failures)} capture check(s) failed:")
100
+ for f in failures:
101
+ print(f" ✗ {f}")
102
+ sys.exit(1)
103
+ print("PASS — /capture extracts place(s) from a single shared post.")
104
+ sys.exit(0)
web/app.py CHANGED
@@ -285,6 +285,39 @@ def _run_pipeline(job_id: str, json_bytes: bytes, model: str,
285
 
286
 
287
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
288
  def _run_url_pipeline(job_id: str, url: str, model: str,
289
  provider: str = "anthropic", ollama_url: str = "http://localhost:11434",
290
  api_key: str | None = None) -> None:
@@ -319,29 +352,13 @@ def _run_url_pipeline(job_id: str, url: str, model: str,
319
 
320
  _update(job_id, step="extract", progress=40, message="Extracting place with AI…")
321
 
322
- client = None
323
- if provider == "anthropic":
324
- import anthropic as _anthropic
325
- client = _anthropic.Anthropic(api_key=api_key) if api_key else _anthropic.Anthropic()
326
-
327
- post = {"caption": meta["caption"], "hashtags": []}
328
- results = extract_mod.analyze_batch(client, [post],
329
- model=model, provider=provider, ollama_url=ollama_url)
330
- # analyze_batch returns [None] for non-place; multi-venue posts return
331
- # multiple results for the single input post.
332
- infos = [r for r in results if r is not None]
333
- if not infos:
334
  _update(job_id, step="error", progress=0, message="No place found in this post.")
335
  return
336
 
337
- rows = extract_mod.split_multi_venue([
338
- {**info, "creator": meta["creator"], "instagram_url": url, "lat": "", "lng": ""}
339
- for info in infos
340
- ])
341
- if meta["location"] and len(rows) == 1:
342
- rows[0]["lat"] = f"{meta['location']['lat']:.7f}"
343
- rows[0]["lng"] = f"{meta['location']['lng']:.7f}"
344
-
345
  with open(csv_path, "w", newline="", encoding="utf-8") as f:
346
  writer = csv.DictWriter(f, fieldnames=extract_mod.FIELDNAMES)
347
  writer.writeheader()
@@ -618,6 +635,79 @@ async def extract_url_route(
618
  return {"job_id": job_id}
619
 
620
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
621
  @app.post("/url-chatbot-prepare")
622
  async def url_chatbot_prepare(url: str = Form(...)):
623
  """Fetch caption via proxy/yt-dlp and return a chatbot export package."""
 
285
 
286
 
287
 
288
+ def _places_from_caption(caption: str, url: str, creator: str = "",
289
+ location: dict | None = None, *,
290
+ provider: str = "anthropic", model: str = DEFAULT_MODEL,
291
+ api_key: str | None = None,
292
+ ollama_url: str = "http://localhost:11434") -> list[dict]:
293
+ """Extract place row(s) from one post's caption — shared by the single-URL
294
+ pipeline and POST /capture.
295
+
296
+ Returns FIELDNAMES-shaped rows (empty if no place found). Multi-venue posts
297
+ yield one row per venue (split_multi_venue). When the post carries an
298
+ Instagram location tag and resolves to a single venue, its coords are used
299
+ directly (no geocoding needed).
300
+ """
301
+ client = None
302
+ if provider == "anthropic":
303
+ import anthropic as _anthropic
304
+ client = _anthropic.Anthropic(api_key=api_key) if api_key else _anthropic.Anthropic()
305
+
306
+ results = extract_mod.analyze_batch(client, [{"caption": caption, "hashtags": []}],
307
+ model=model, provider=provider, ollama_url=ollama_url)
308
+ infos = [r for r in results if r is not None]
309
+ if not infos:
310
+ return []
311
+ rows = extract_mod.split_multi_venue([
312
+ {**info, "creator": creator, "instagram_url": url, "lat": "", "lng": ""}
313
+ for info in infos
314
+ ])
315
+ if location and len(rows) == 1:
316
+ rows[0]["lat"] = f"{location['lat']:.7f}"
317
+ rows[0]["lng"] = f"{location['lng']:.7f}"
318
+ return rows
319
+
320
+
321
  def _run_url_pipeline(job_id: str, url: str, model: str,
322
  provider: str = "anthropic", ollama_url: str = "http://localhost:11434",
323
  api_key: str | None = None) -> None:
 
352
 
353
  _update(job_id, step="extract", progress=40, message="Extracting place with AI…")
354
 
355
+ rows = _places_from_caption(meta["caption"], url, meta["creator"], meta["location"],
356
+ provider=provider, model=model, api_key=api_key,
357
+ ollama_url=ollama_url)
358
+ if not rows:
 
 
 
 
 
 
 
 
359
  _update(job_id, step="error", progress=0, message="No place found in this post.")
360
  return
361
 
 
 
 
 
 
 
 
 
362
  with open(csv_path, "w", newline="", encoding="utf-8") as f:
363
  writer = csv.DictWriter(f, fieldnames=extract_mod.FIELDNAMES)
364
  writer.writeheader()
 
635
  return {"job_id": job_id}
636
 
637
 
638
+ @app.post("/capture")
639
+ async def capture(
640
+ embed_html: str = Form(""),
641
+ caption: str = Form(""),
642
+ url: str = Form(""),
643
+ creator: str = Form(""),
644
+ lat: str = Form(""),
645
+ lng: str = Form(""),
646
+ model: str = Form(DEFAULT_MODEL),
647
+ provider: str = Form("anthropic"),
648
+ ollama_model: str = Form(DEFAULT_OLLAMA_MODEL),
649
+ ollama_url: str = Form(None),
650
+ api_key: str = Form(""),
651
+ ):
652
+ """Extract place(s) from a single shared post — the iOS Shortcut path.
653
+
654
+ The caller fetches /embed/captioned/ ON-DEVICE (residential IP, no CORS) and
655
+ POSTs `embed_html`; alternatively send a ready `caption`, or a `url` for the
656
+ server to fetch (local builds only — the hosted server is IP-walled).
657
+ Optional `lat`/`lng` carry an Instagram location tag for free coordinates.
658
+ Returns {places, count}. Inbox delivery (token) is added in Phase B.
659
+ """
660
+ provider, active_model = resolve_provider(
661
+ provider, model, ollama_model, ollama_enabled=OLLAMA_ENABLED
662
+ )
663
+
664
+ location = None
665
+ if lat and lng:
666
+ try:
667
+ location = {"lat": float(lat), "lng": float(lng)}
668
+ except ValueError:
669
+ location = None
670
+
671
+ if embed_html:
672
+ if _too_large(embed_html.encode()):
673
+ return JSONResponse({"error": f"Payload too large (max {MAX_UPLOAD_MB:.0f} MB)."}, status_code=413)
674
+ from pipeline.transcribe import _parse_embed_caption
675
+ parsed = _parse_embed_caption(embed_html)
676
+ caption = caption or parsed["caption"]
677
+ creator = creator or parsed["creator"]
678
+ elif url and not caption:
679
+ if CLIENT_GEOCODE and not CAPTION_PROXY_URL:
680
+ return JSONResponse(
681
+ {"error": "The server can't fetch Instagram on the hosted build — "
682
+ "the Shortcut should POST embed_html fetched on your device."},
683
+ status_code=422)
684
+ from pipeline.transcribe import fetch_post_metadata
685
+ try:
686
+ meta = await asyncio.to_thread(fetch_post_metadata, url, None, CAPTION_PROXY_URL)
687
+ except ValueError as exc:
688
+ return JSONResponse({"error": str(exc)}, status_code=422)
689
+ caption = meta["caption"]
690
+ creator = creator or meta["creator"]
691
+ location = location or meta["location"]
692
+
693
+ if not caption.strip():
694
+ return JSONResponse({"error": "No caption found in the post."}, status_code=422)
695
+
696
+ try:
697
+ rows = await asyncio.to_thread(
698
+ _places_from_caption, caption, url, creator, location,
699
+ provider=provider, model=active_model,
700
+ api_key=api_key.strip() or None, ollama_url=ollama_url or OLLAMA_URL,
701
+ )
702
+ except Exception as exc:
703
+ return JSONResponse({"error": safe_err(exc)}, status_code=502)
704
+
705
+ if not rows:
706
+ return JSONResponse({"error": "No place found in this post."}, status_code=422)
707
+
708
+ return {"places": rows, "count": len(rows)}
709
+
710
+
711
  @app.post("/url-chatbot-prepare")
712
  async def url_chatbot_prepare(url: str = Form(...)):
713
  """Fetch caption via proxy/yt-dlp and return a chatbot export package."""