Skip to content

Commit f59e849

Browse files
committed
New commit
0 parents  commit f59e849

31 files changed

Lines changed: 793 additions & 0 deletions

.DS_Store

8 KB
Binary file not shown.

.github/workflows/lambda-cicd.yml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
name: Deploy Lambda to AWS
2+
on:
3+
workflow_dispatch:
4+
push:
5+
branches:
6+
- main
7+
8+
permissions:
9+
id-token: write
10+
contents: read
11+
12+
jobs:
13+
prod-project-north-fork:
14+
uses: MinneapolisStarTribune/strib-workflows/.github/workflows/
15+
with:
16+
terraform-generic-repo-name: terraform-generic-lambda
17+
terraform-generic-repo-ref: v0.4.0
18+
terraform-tfvars-file-path: "./config/terraform/prod-semantic-image-search.tfvars"
19+
terraform-backend-bucket: "terragrunt-strib-terraform-state-chad-wagner-us-west-2"
20+
aws-account-id: "903601045739"
21+
aws-environment: prod
22+
aws-region: us-west-2
23+
secrets: inherit

README.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Semantic Image Search
2+
3+
This repository contains a prototype for captioning images, embedding text, and serving semantic image search results via an AWS Lambda function. It includes helper scripts for data ingestion, model training, and a minimal Chrome extension.
4+
5+
## Repository Layout
6+
7+
- **`backend/`** – Data preparation and training scripts.
8+
- `alttext-embed.py` – Generates captions for images with GPT-4-Vision and writes image embeddings.
9+
- `ingest.py` – Fetches ArcXP story revisions to build story–image pairs.
10+
- `copy_to_rds.py` – Bulk loads embeddings into a PostgreSQL database using pgvector.
11+
- `train.py` – Trains a LightGBM ranking model from the collected data.
12+
- **`lambda/`** – AWS Lambda code used by the search API.
13+
- `handler.py` – Entry point that scores candidate images against a query.
14+
- `ranker.py` – Downloads the trained model and manages database connections.
15+
- **`extension/`** – Simple Chrome extension that queries `/suggest` for lead-art recommendations.
16+
- **`tests/`** – Unit tests for the handler and ranking helper.
17+
- **`data/`** – Placeholder directory for generated embeddings and the trained model.
18+
19+
## Getting Started
20+
21+
1. **Install dependencies**
22+
```bash
23+
pip install -r backend/requirements.txt
24+
```
25+
26+
2. **Ingest story–image pairs**
27+
```bash
28+
scripts/run_ingest.sh --since 2024-01-01T00:00:00Z
29+
```
30+
Set `ARC_API_URL` and `ARC_TOKEN` to connect to the Arc API.
31+
32+
3. **Caption and embed images**
33+
```bash
34+
scripts/run_alttext.sh <image-id> [...]
35+
```
36+
Requires `PHOTO_URL` and `PHOTO_TOKEN` for fetching image bytes. Results are written to `artifacts/images.csv`.
37+
38+
4. **Load embeddings into PostgreSQL**
39+
```bash
40+
PG_DSN=postgresql://user:pass@host/db python backend/copy_to_rds.py
41+
```
42+
43+
5. **Train the ranking model**
44+
```bash
45+
scripts/run_train.sh
46+
```
47+
Produces `artifacts/leadart_ranker.lgb`.
48+
49+
6. **Run tests**
50+
```bash
51+
pytest
52+
```
53+
54+
The Lambda function (`lambda/handler.py`) uses the trained model to rank candidates stored in the database. The Chrome extension demonstrates how search suggestions can be integrated into Arc Composer.
55+

backend/alttext-embed.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
from __future__ import annotations
2+
3+
import argparse
4+
import base64
5+
import csv
6+
import os
7+
import json
8+
from pathlib import Path
9+
from typing import Iterable, List, Optional, Tuple
10+
import logging
11+
12+
from common.utils import embed_text
13+
import requests
14+
import openai
15+
16+
PHOTO_URL = os.environ.get("PHOTO_URL", "https://photo.example.com")
17+
PHOTO_TOKEN = os.environ.get("PHOTO_TOKEN")
18+
19+
20+
21+
def fetch_image(image_id: str) -> Tuple[bytes, str, str]:
22+
"""Retrieve image bytes and metadata from Merlin/Photo Center."""
23+
if requests is None:
24+
return os.urandom(10), "", ""
25+
url = f"{PHOTO_URL}/{image_id}"
26+
headers = {"Authorization": f"Bearer {PHOTO_TOKEN}"} if PHOTO_TOKEN else None
27+
try:
28+
resp = requests.get(url, headers=headers, timeout=30)
29+
resp.raise_for_status()
30+
taken_at = resp.headers.get("X-Taken-At", "")
31+
uploaded_at = resp.headers.get("X-Uploaded-At", "")
32+
return resp.content, taken_at, uploaded_at
33+
except Exception as exc:
34+
logging.error("Failed to fetch image %s: %s", image_id, exc)
35+
return b"", ""
36+
37+
38+
def caption_image(image_id: str, image_bytes: Optional[bytes] = None) -> str:
39+
"""Call a vision-capable model to produce a caption."""
40+
try:
41+
image_bytes = fetch_image_bytes(image_id)
42+
b64 = base64.b64encode(image_bytes).decode()
43+
resp = openai.ChatCompletion.create(
44+
model="gpt-4-vision-preview",
45+
messages=[
46+
{
47+
"role": "user",
48+
"content": [
49+
{"type": "text", "text": "Describe this image in one sentence."},
50+
{"type": "image_url", "image_url": f"data:image/jpeg;base64,{b64}"},
51+
],
52+
}
53+
],
54+
)
55+
return resp["choices"][0]["message"]["content"].strip()
56+
except Exception as exc:
57+
logging.error("Failed to caption image %s: %s", image_id, exc)
58+
return f"Image {image_id} caption"
59+
60+
def process(images: Iterable[str]) -> None:
61+
Path("artifacts").mkdir(exist_ok=True)
62+
out_path = Path("artifacts/images.csv")
63+
with out_path.open("w", newline="") as f:
64+
writer = csv.writer(f)
65+
header = ["image_id", "caption", "embedding", "taken_at", "uploaded_at"]
66+
writer.writerow(header)
67+
for image_id in images:
68+
image_bytes, taken_at, uploaded_at = fetch_image(image_id)
69+
caption = caption_image(image_id, image_bytes)
70+
vector = embed_text(caption)
71+
writer.writerow([
72+
image_id,
73+
caption,
74+
json.dumps(vector),
75+
taken_at,
76+
uploaded_at,
77+
])
78+
print(f"Wrote {out_path}")
79+
80+
81+
def main(args: Iterable[str] | None = None) -> None:
82+
parser = argparse.ArgumentParser()
83+
parser.add_argument("image_ids", nargs="+", help="IDs to caption")
84+
ns = parser.parse_args(args)
85+
process(ns.image_ids)
86+
87+
88+
if __name__ == "__main__":
89+
main()

backend/copy_to_rds.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
"""Bulk load image embeddings into a PostgreSQL database."""
2+
3+
import csv
4+
import os
5+
6+
import psycopg2
7+
8+
DSN = os.environ.get("PG_DSN")
9+
TABLE = os.environ.get("PG_TABLE", "images")
10+
11+
12+
def copy_csv(path: str) -> None:
13+
if not DSN:
14+
raise RuntimeError("PG_DSN not configured")
15+
conn = psycopg2.connect(DSN)
16+
cur = conn.cursor()
17+
cur.execute("CREATE EXTENSION IF NOT EXISTS vector")
18+
with open(path, "r") as f:
19+
cur.copy_expert(f"COPY {TABLE} FROM STDIN WITH CSV HEADER", f)
20+
conn.commit()
21+
cur.close()
22+
conn.close()
23+
print(f"Copied {path} to {TABLE}")
24+
25+
26+
if __name__ == "__main__":
27+
copy_csv("artifacts/images.csv")

backend/ingest.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import argparse
2+
import csv
3+
import logging
4+
import os
5+
from pathlib import Path
6+
from typing import Iterable, Set, Dict, Any, Optional
7+
import requests
8+
from pymongo import MongoClient
9+
from concurrent.futures import ThreadPoolExecutor, as_completed
10+
11+
API_URL = os.environ.get("ARC_API_URL")
12+
API_TOKEN = os.environ.get("ARC_TOKEN")
13+
14+
MONGO_URI = os.environ.get("MONGO_URI")
15+
MONGO_DB = os.environ.get("MONGO_DB")
16+
MONGO_COLLECTION = os.environ.get("MONGO_COLLECTION")
17+
18+
19+
def load_photo_editor_ids(path: str) -> Set[str]:
20+
with open(path, newline="") as f:
21+
return {row[0] for row in csv.reader(f) if row}
22+
23+
24+
def fetch_revisions(story_id: str) -> Iterable[Dict[str, Any]]:
25+
if not API_TOKEN:
26+
raise RuntimeError("ARC_TOKEN not configured")
27+
url = f"{API_URL}/stories/{story_id}/revisions"
28+
headers = {"Authorization": f"Bearer {API_TOKEN}"}
29+
try:
30+
response = requests.get(url, headers=headers, timeout=30)
31+
response.raise_for_status()
32+
return response.json().get("revisions", [])
33+
except Exception as exc:
34+
logging.error("Failed to fetch revisions for %s: %s", story_id, exc)
35+
return []
36+
37+
38+
def story_has_photo_editor(revisions: Iterable[Dict[str, Any]], editor_ids: Set[str]) -> bool:
39+
return any(rev.get("user_id") in editor_ids for rev in revisions)
40+
41+
def first_lead_art_revision(
42+
revisions: Iterable[Dict[str, Any]],
43+
editors: Optional[Set[str]] = None,
44+
) -> Optional[Dict[str, Any]]:
45+
"""Return the first revision containing lead art.
46+
47+
If ``editors`` is provided, only revisions by those users are considered.
48+
"""
49+
for rev in sorted(revisions, key=lambda r: r.get("revision_date", "")):
50+
if editors is not None and rev.get("user_id") not in editors:
51+
continue
52+
promo = rev.get("story", {}).get("promo_items", {})
53+
if promo.get("lead_art"):
54+
return rev
55+
return None
56+
57+
58+
def ingest_story(story_id: str) -> Optional[Dict[str, Any]]:
59+
"""Return metadata for a story including a label based on editor."""
60+
revisions = list(fetch_revisions(story_id))
61+
rev = first_lead_art_revision(revisions, PHOTO_EDITOR_IDS)
62+
label = 2
63+
if not rev:
64+
rev = first_lead_art_revision(revisions)
65+
label = 1
66+
if not rev:
67+
return None
68+
story = rev.get("story", {})
69+
image_id = story.get("promo_items", {}).get("lead_art", {}).get("id")
70+
text = story.get("text", "")
71+
timestamp = rev.get("revision_date")
72+
if isinstance(timestamp, str):
73+
timestamp = timestamp[:19]
74+
return {
75+
"story_id": story_id,
76+
"text": text,
77+
"image_id": image_id,
78+
"timestamp": timestamp,
79+
"label": label,
80+
}
81+
82+
83+
def fetch_image_id_from_mongo(story_id: str) -> str:
84+
client = MongoClient(MONGO_URI)
85+
db = client[MONGO_DB]
86+
collection = db[MONGO_COLLECTION]
87+
doc = collection.find_one({"_id": story_id}, {"promo_items.lead_art.id": 1})
88+
client.close()
89+
if doc:
90+
return doc.get("promo_items", {}).get("lead_art", {}).get("id", "")
91+
return ""
92+
93+
94+
def fetch_story_ids_since(since_date: str) -> Iterable[str]:
95+
client = MongoClient(MONGO_URI)
96+
db = client[MONGO_DB]
97+
collection = db[MONGO_COLLECTION]
98+
query = {"last_updated_date": {"$gte": since_date}}
99+
projection = {"_id": 1}
100+
story_ids = [doc["_id"] for doc in collection.find(query, projection)]
101+
client.close()
102+
return story_ids
103+
104+
105+
def process_story(story_id: str, editor_ids: Set[str]) -> Dict[str, str] | None:
106+
revisions = list(fetch_revisions(story_id))
107+
if story_has_photo_editor(revisions, editor_ids):
108+
image_id = fetch_image_id_from_mongo(story_id)
109+
return {"story_id": story_id, "image_id": image_id}
110+
return None
111+
112+
113+
def main(args=None):
114+
parser = argparse.ArgumentParser()
115+
parser.add_argument("--since", required=True, help="ISO date to start from (e.g., 2021-01-01)")
116+
parser.add_argument("--editor-csv", default="editor_ids.csv", help="CSV file of photo editor user IDs")
117+
parser.add_argument("--workers", type=int, default=8, help="Number of parallel threads")
118+
ns = parser.parse_args(args)
119+
120+
photo_editor_ids = load_photo_editor_ids(ns.editor_csv)
121+
story_ids = fetch_story_ids_since(ns.since)
122+
123+
Path("artifacts").mkdir(exist_ok=True)
124+
matched_path = Path("artifacts/story_image_pairs.csv")
125+
126+
matched_rows = []
127+
with ThreadPoolExecutor(max_workers=ns.workers) as executor:
128+
future_to_story = {executor.submit(process_story, story_id, photo_editor_ids): story_id for story_id in story_ids}
129+
for future in as_completed(future_to_story):
130+
result = future.result()
131+
if result:
132+
matched_rows.append(result)
133+
134+
with matched_path.open("w", newline="") as f:
135+
writer = csv.DictWriter(f, fieldnames=["story_id", "image_id"])
136+
writer.writeheader()
137+
writer.writerows(matched_rows)
138+
139+
print(f"Wrote matched story IDs and image IDs to {matched_path}")
140+
141+
142+
if __name__ == "__main__":
143+
main()

backend/requirements.txt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
lightgbm
2+
openai
3+
psycopg2-binary
4+
pgvector
5+
pandas
6+
tqdm
7+
requests

backend/sql/create_tables.sql

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
CREATE TABLE IF NOT EXISTS images (
2+
image_id TEXT PRIMARY KEY,
3+
caption TEXT,
4+
embedding VECTOR(768),
5+
taken_at TIMESTAMP,
6+
uploaded_at TIMESTAMP
7+
);
8+
9+
-- Create ivfflat index for ANN search
10+
CREATE INDEX IF NOT EXISTS images_embedding_idx ON images USING ivfflat (embedding vector_l2_ops) WITH (lists = 100);

0 commit comments

Comments
 (0)