Skip to content

Commit be54782

Browse files
authored
Merge pull request #15 from YuWei-CH/LLM-integration
Support LLM extraction via openrouter
2 parents c8fa2cd + 4db7f91 commit be54782

File tree

19 files changed

+567
-27
lines changed

19 files changed

+567
-27
lines changed

.env.example

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@
99
# Required: Browser API key (HTTP referrer-restricted)
1010
VITE_GOOGLE_MAPS_API_KEY="YOUR_BROWSER_KEY"
1111

12-
# Optional: Backend API base URL (defaults to http://localhost:8000)
13-
VITE_API_BASE_URL="http://localhost:8000"
12+
# Optional: Backend API base URL (defaults to http://127.0.0.1:8000)
13+
# If `localhost` doesn't work on your machine (IPv6-only), use `http://127.0.0.1:8000`.
14+
VITE_API_BASE_URL="http://127.0.0.1:8000"
1415

1516
# Optional: Disable Google Maps loading (useful for CI)
1617
# VITE_DISABLE_GOOGLE_MAPS="1"
@@ -28,5 +29,12 @@ GEOCODING_PROVIDER="google"
2829
# Optional: Enable/disable all geocoding calls
2930
ENABLE_GEOCODING="1"
3031

32+
# Optional: LLM extraction for selected posts (Chrome extension → "Add selected post")
33+
# Get a key from OpenRouter and put it here.
34+
OPENROUTER_API_KEY="YOUR_OPENROUTER_KEY"
35+
36+
# Optional: Choose a model on OpenRouter
37+
OPENROUTER_MODEL="z-ai/glm-4.5-air:free"
38+
3139
# Optional: Database URL (default is an auto-created SQLite DB under backend/)
3240
# DATABASE_URL="sqlite+pysqlite:////absolute/path/to/easyrelocate.db"

README.md

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ Frontend variables must start with `VITE_`:
2828
# Required (browser key)
2929
VITE_GOOGLE_MAPS_API_KEY="YOUR_BROWSER_KEY"
3030

31-
# Optional (defaults to http://localhost:8000)
32-
VITE_API_BASE_URL="http://localhost:8000"
31+
# Optional (defaults to http://127.0.0.1:8000)
32+
VITE_API_BASE_URL="http://127.0.0.1:8000"
3333

3434
# Optional (useful for CI / keyless dev)
3535
# VITE_DISABLE_GOOGLE_MAPS="1"
@@ -42,14 +42,18 @@ Backend reads standard env vars (auto-loads repo-root `.env` on startup):
4242
GOOGLE_MAPS_API_KEY="YOUR_SERVER_KEY"
4343
GEOCODING_PROVIDER="google"
4444

45+
# Optional (required for the extension’s “Add selected post” feature)
46+
OPENROUTER_API_KEY="YOUR_OPENROUTER_KEY"
47+
OPENROUTER_MODEL="z-ai/glm-4.5-air:free"
48+
4549
# Optional
4650
ENABLE_GEOCODING="1"
4751
DATABASE_URL="sqlite:///easyrelocate.db"
4852
```
4953

5054
### Extension (Chrome)
5155
The extension does not read `.env` files. Configure its API base URL in Chrome:
52-
Extension → **Details****Extension options** → “API base URL” (default: `http://localhost:8000`).
56+
Extension → **Details****Extension options** → “API base URL” (default: `http://127.0.0.1:8000`).
5357

5458
## Local dev (MVP)
5559

@@ -62,7 +66,7 @@ pip install -r requirements.txt
6266
uvicorn app.main:app --reload --port 8000
6367
```
6468

65-
API docs: `http://localhost:8000/docs`
69+
API docs: `http://127.0.0.1:8000/docs`
6670

6771
### 2) Frontend
6872
```bash
@@ -71,9 +75,9 @@ npm install
7175
npm run dev
7276
```
7377

74-
Open: `http://localhost:5173` (landing page)
78+
Open: `http://127.0.0.1:5173` (landing page)
7579

76-
Compare app: `http://localhost:5173/#/compare`
80+
Compare app: `http://127.0.0.1:5173/#/compare`
7781

7882
Set your workplace target by:
7983
- Typing an address (US) and clicking **Save**, or
@@ -84,11 +88,15 @@ Set your workplace target by:
8488
2. Enable **Developer mode**
8589
3. Click **Load unpacked**
8690
4. Select the `extension/` folder
87-
5. In the extension **Options**, set API base URL to `http://localhost:8000` (default)
91+
5. In the extension **Options**, set API base URL to `http://127.0.0.1:8000` (default)
8892

8993
Then open an Airbnb listing detail page (`https://www.airbnb.com/rooms/...`) or a Blueground
9094
property page (`https://www.theblueground.com/p/...`) and click “Add to Compare”.
9195

96+
To save a housing post from anywhere (e.g., Facebook group):
97+
1. Highlight/select the post text
98+
2. Right click → **EasyRelocate: Add selected post**
99+
92100
## Google Maps setup (required)
93101
EasyRelocate uses Google Maps Platform for:
94102
- **Maps JavaScript API** (frontend map)
@@ -100,3 +108,4 @@ See: `docs/GOOGLE_MAPS_APPROX_LOCATION.md`
100108
## Docs
101109
- Platform organization: `docs/PLATFORM_ORGANIZATION.md`
102110
- Google maps “approx street”: `docs/GOOGLE_MAPS_APPROX_LOCATION.md`
111+
- OpenRouter LLM extraction: `docs/OPENROUTER_LLM_EXTRACTION.md`

backend/README.md

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ in the repo root and put backend env vars there.
2424
You can still override via shell exports if needed.
2525

2626
API docs:
27-
- Swagger UI: `http://localhost:8000/docs`
28-
- OpenAPI: `http://localhost:8000/openapi.json`
27+
- Swagger UI: `http://127.0.0.1:8000/docs`
28+
- OpenAPI: `http://127.0.0.1:8000/openapi.json`
2929

3030
## Geocoding (Nominatim / OpenStreetMap)
3131
The backend can:
@@ -55,3 +55,17 @@ If you use Google geocoding (`GEOCODING_PROVIDER=google` or `GOOGLE_MAPS_API_KEY
5555
If you see:
5656
`Google Geocoding failed with status REQUEST_DENIED: This API is not activated on your API project`
5757
it means the Geocoding API is not enabled (or billing/key restrictions are blocking it).
58+
59+
## LLM extraction (OpenRouter)
60+
The backend can extract monthly rent + location from user-selected text (for Facebook groups, etc.).
61+
62+
Endpoint:
63+
- `POST /api/listings/from_text` (requires `OPENROUTER_API_KEY`)
64+
65+
Env vars:
66+
- `OPENROUTER_API_KEY` (required)
67+
- `OPENROUTER_MODEL` (optional; default `z-ai/glm-4.5-air:free`)
68+
- `OPENROUTER_BASE_URL` (optional)
69+
- `OPENROUTER_TIMEOUT_S` (optional)
70+
71+
Details: `docs/OPENROUTER_LLM_EXTRACTION.md`

backend/app/main.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
from __future__ import annotations
22

33
import os
4+
import re
5+
import hashlib
6+
from urllib.parse import urlsplit, urlunsplit
47
from contextlib import asynccontextmanager
58
from datetime import datetime, timezone
69
from typing import Annotated
@@ -22,10 +25,16 @@
2225
rough_location_from_address,
2326
)
2427
from .models import Listing, Target
28+
from .openrouter import (
29+
extract_housing_post,
30+
OpenRouterConfigError,
31+
OpenRouterProviderError,
32+
)
2533
from .schemas import (
2634
CompareResponse,
2735
GeocodeResultOut,
2836
ListingOut,
37+
ListingFromTextIn,
2938
ListingSummaryOut,
3039
ListingUpsert,
3140
ReverseGeocodeOut,
@@ -67,6 +76,16 @@ async def lifespan(_: FastAPI):
6776

6877
DbDep = Annotated[Session, Depends(get_db)]
6978

79+
_RE_HTTP_URL = re.compile(r"^https?://", re.IGNORECASE)
80+
81+
82+
def _build_post_source_url(page_url: str, text: str) -> str:
83+
parts = urlsplit(page_url)
84+
base = urlunsplit((parts.scheme, parts.netloc, parts.path, parts.query, ""))
85+
normalized = " ".join(text.split()).strip().lower()
86+
h = hashlib.sha1(normalized.encode("utf-8")).hexdigest()[:12]
87+
return f"{base}#easyrelocate_post={h}"
88+
7089

7190
@app.get("/api/health")
7291
def health() -> dict[str, str]:
@@ -164,6 +183,53 @@ def upsert_listing(payload: ListingUpsert, db: DbDep) -> Listing:
164183
return listing
165184

166185

186+
@app.post("/api/listings/from_text", response_model=ListingOut)
187+
def create_listing_from_text(payload: ListingFromTextIn, db: DbDep) -> Listing:
188+
if not _RE_HTTP_URL.match(payload.page_url):
189+
raise HTTPException(status_code=400, detail="page_url must start with http:// or https://")
190+
191+
try:
192+
extracted = extract_housing_post(payload.text, page_url=payload.page_url)
193+
except OpenRouterConfigError as e:
194+
raise HTTPException(status_code=500, detail=str(e)) from e
195+
except OpenRouterProviderError as e:
196+
raise HTTPException(status_code=502, detail=str(e)) from e
197+
except HTTPError as e:
198+
raise HTTPException(status_code=502, detail=str(e)) from e
199+
200+
source_url = _build_post_source_url(payload.page_url, payload.text)
201+
title = extracted.title
202+
if title is None:
203+
first = " ".join(payload.text.split())[:80].strip()
204+
title = first or None
205+
206+
lat = None
207+
lng = None
208+
if extracted.location_text:
209+
try:
210+
candidates = geocode_address(extracted.location_text, limit=1)
211+
if candidates:
212+
lat = candidates[0].lat
213+
lng = candidates[0].lng
214+
except (HTTPError, GeocodingConfigError, GeocodingProviderError):
215+
pass
216+
217+
listing_payload = ListingUpsert(
218+
source="post",
219+
source_url=source_url,
220+
title=title,
221+
price_value=extracted.price_value,
222+
currency=extracted.currency or "USD",
223+
price_period="month" if extracted.price_value is not None else "unknown",
224+
lat=lat,
225+
lng=lng,
226+
location_text=extracted.location_text,
227+
captured_at=_utcnow(),
228+
)
229+
230+
return upsert_listing(listing_payload, db)
231+
232+
167233
@app.get("/api/listings", response_model=list[ListingOut])
168234
def list_listings(db: DbDep) -> list[Listing]:
169235
return list(db.scalars(select(Listing).order_by(Listing.captured_at.desc())))

0 commit comments

Comments
 (0)