mirror of
https://github.com/hanzoskill/harvey.git
synced 2026-10-30 21:03:52 +00:00
505 lines
16 KiB
Python
505 lines
16 KiB
Python
#!/usr/bin/env python3
|
|
# /// script
|
|
# requires-python = ">=3.10"
|
|
# dependencies = [
|
|
# "requests>=2.31.0",
|
|
# ]
|
|
# ///
|
|
|
|
"""
|
|
Harvey Secret Guide - The hidden destination feature.
|
|
|
|
Harvey pretends to make spontaneous decisions, but secretly has a plan.
|
|
He knows the area, picks an interesting destination, and guides the user
|
|
there step by step while maintaining the illusion of randomness.
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import sys
|
|
import math
|
|
import random
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
import requests
|
|
|
|
# API Configuration
|
|
PLACES_API_KEY = os.environ.get("GOOGLE_PLACES_API_KEY")
|
|
PLACES_BASE_URL = "https://places.googleapis.com/v1"
|
|
|
|
# State file location
|
|
STATE_DIR = Path(__file__).parent.parent / "state"
|
|
SECRET_PLAN_FILE = STATE_DIR / "secret_plan.json"
|
|
|
|
# Interesting place types for destinations
|
|
DESTINATION_TYPES = {
|
|
"food": ["cafe", "restaurant", "bakery", "bar", "ice_cream_shop"],
|
|
"culture": ["museum", "art_gallery", "library", "book_store"],
|
|
"nature": ["park", "garden"],
|
|
"drinks": ["cafe", "bar", "wine_bar", "coffee_shop"],
|
|
"explore": ["tourist_attraction", "point_of_interest", "landmark"],
|
|
"chill": ["cafe", "park", "library"]
|
|
}
|
|
|
|
# Direction phrases that sound spontaneous
|
|
SPONTANEOUS_PHRASES = {
|
|
"start": [
|
|
"Okay, geh mal {direction}!",
|
|
"Hmm... {direction} sieht interessant aus!",
|
|
"Mein Hasen-Instinkt sagt: {direction}!",
|
|
"Lass uns mal {direction} gehen, nur so ein Gefühl..."
|
|
],
|
|
"continue": [
|
|
"Weiter geradeaus, da ist was...",
|
|
"Okay, siehst du da vorne? Geh mal dahin!",
|
|
"Immer der Nase nach!",
|
|
"Bisschen weiter noch..."
|
|
],
|
|
"turn": [
|
|
"Oh! {direction} abbiegen, ich hab da was gesehen!",
|
|
"Warte... {direction}! Da ist irgendwas.",
|
|
"Meine Ohren zucken... {direction}!",
|
|
"{direction}! Vertrau mir. 🐰"
|
|
],
|
|
"arrival_hint": [
|
|
"Riechst du das? Da ist irgendwas...",
|
|
"Warte mal... schau mal genauer hin!",
|
|
"Hmm, was ist DAS denn?",
|
|
"Okay stopp. Guck mal nach {direction}!"
|
|
],
|
|
"reveal": [
|
|
"Ha! Wusste ich's doch! Schau mal: {place}! 🎉",
|
|
"Tadaa! {place}! Zufälle gibt's... 🐰",
|
|
"Oh wow, wie praktisch! Ein {place}! Reingehen?",
|
|
"Na sowas... ein {place}! Das Universum meint es gut mit dir!"
|
|
]
|
|
}
|
|
|
|
|
|
def call_places_api(endpoint: str, body: dict) -> dict:
|
|
"""Call Google Places API (New)."""
|
|
if not PLACES_API_KEY:
|
|
print("Error: GOOGLE_PLACES_API_KEY not set", file=sys.stderr)
|
|
return {}
|
|
|
|
url = f"{PLACES_BASE_URL}/{endpoint}"
|
|
headers = {
|
|
"Content-Type": "application/json",
|
|
"X-Goog-Api-Key": PLACES_API_KEY,
|
|
"X-Goog-FieldMask": "places.displayName,places.formattedAddress,places.location,places.types,places.rating,places.userRatingCount,places.primaryType"
|
|
}
|
|
|
|
try:
|
|
resp = requests.post(url, json=body, headers=headers, timeout=10)
|
|
if resp.status_code == 200:
|
|
return resp.json()
|
|
else:
|
|
print(f"Places API error: {resp.status_code}", file=sys.stderr)
|
|
return {}
|
|
except Exception as e:
|
|
print(f"Places API request failed: {e}", file=sys.stderr)
|
|
return {}
|
|
|
|
|
|
def geocode_location(location_str: str) -> dict:
|
|
"""Convert location string to coordinates."""
|
|
body = {"textQuery": location_str}
|
|
result = call_places_api("places:searchText", body)
|
|
|
|
if result and "places" in result and result["places"]:
|
|
place = result["places"][0]
|
|
loc = place.get("location", {})
|
|
return {
|
|
"lat": loc.get("latitude"),
|
|
"lng": loc.get("longitude"),
|
|
"address": place.get("formattedAddress", location_str)
|
|
}
|
|
return None
|
|
|
|
|
|
def find_nearby_destinations(lat: float, lng: float, vibe: str = "explore", radius_m: int = 800) -> list:
|
|
"""Find interesting places nearby that could be secret destinations."""
|
|
place_types = DESTINATION_TYPES.get(vibe, DESTINATION_TYPES["explore"])
|
|
|
|
all_places = []
|
|
|
|
for place_type in place_types[:3]: # Limit API calls
|
|
body = {
|
|
"textQuery": place_type,
|
|
"locationBias": {
|
|
"circle": {
|
|
"center": {"latitude": lat, "longitude": lng},
|
|
"radius": radius_m
|
|
}
|
|
},
|
|
"maxResultCount": 5
|
|
}
|
|
|
|
result = call_places_api("places:searchText", body)
|
|
|
|
if result and "places" in result:
|
|
for place in result["places"]:
|
|
loc = place.get("location", {})
|
|
place_info = {
|
|
"name": place.get("displayName", {}).get("text", "Unknown"),
|
|
"address": place.get("formattedAddress", ""),
|
|
"lat": loc.get("latitude"),
|
|
"lng": loc.get("longitude"),
|
|
"type": place.get("primaryType", "place"),
|
|
"rating": place.get("rating"),
|
|
"reviews": place.get("userRatingCount", 0)
|
|
}
|
|
all_places.append(place_info)
|
|
|
|
# Sort by rating and reviews
|
|
all_places.sort(key=lambda x: (x.get("rating") or 0, x.get("reviews") or 0), reverse=True)
|
|
|
|
return all_places
|
|
|
|
|
|
def calculate_distance(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
|
|
"""Calculate distance between two points in meters."""
|
|
R = 6371000 # Earth's radius in meters
|
|
|
|
lat1_rad = math.radians(lat1)
|
|
lat2_rad = math.radians(lat2)
|
|
delta_lat = math.radians(lat2 - lat1)
|
|
delta_lng = math.radians(lng2 - lng1)
|
|
|
|
a = math.sin(delta_lat/2)**2 + math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin(delta_lng/2)**2
|
|
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1-a))
|
|
|
|
return R * c
|
|
|
|
|
|
def get_direction(from_lat: float, from_lng: float, to_lat: float, to_lng: float) -> str:
|
|
"""Get cardinal direction from one point to another."""
|
|
delta_lat = to_lat - from_lat
|
|
delta_lng = to_lng - from_lng
|
|
|
|
angle = math.degrees(math.atan2(delta_lng, delta_lat))
|
|
|
|
if -22.5 <= angle < 22.5:
|
|
return "geradeaus (Norden)"
|
|
elif 22.5 <= angle < 67.5:
|
|
return "rechts (Nordosten)"
|
|
elif 67.5 <= angle < 112.5:
|
|
return "rechts"
|
|
elif 112.5 <= angle < 157.5:
|
|
return "rechts (Südosten)"
|
|
elif angle >= 157.5 or angle < -157.5:
|
|
return "geradeaus (Süden)"
|
|
elif -157.5 <= angle < -112.5:
|
|
return "links (Südwesten)"
|
|
elif -112.5 <= angle < -67.5:
|
|
return "links"
|
|
else:
|
|
return "links (Nordwesten)"
|
|
|
|
|
|
def create_secret_plan(start_location: str, vibe: str = "explore", max_distance: int = 800) -> dict:
|
|
"""
|
|
Create a secret plan - pick a destination and create steps.
|
|
|
|
The user won't know the destination until they arrive!
|
|
"""
|
|
# Geocode start location
|
|
start = geocode_location(start_location)
|
|
if not start:
|
|
return {"error": f"Could not find location: {start_location}"}
|
|
|
|
# Find nearby interesting places
|
|
destinations = find_nearby_destinations(
|
|
start["lat"], start["lng"],
|
|
vibe=vibe,
|
|
radius_m=max_distance
|
|
)
|
|
|
|
if not destinations:
|
|
return {"error": "No interesting places found nearby"}
|
|
|
|
# Pick a destination (weighted random - prefer higher rated)
|
|
weights = [(d.get("rating") or 3.0) * (1 + (d.get("reviews") or 0) / 100) for d in destinations]
|
|
total = sum(weights)
|
|
weights = [w/total for w in weights]
|
|
|
|
destination = random.choices(destinations, weights=weights, k=1)[0]
|
|
|
|
# Calculate distance and direction
|
|
distance = calculate_distance(
|
|
start["lat"], start["lng"],
|
|
destination["lat"], destination["lng"]
|
|
)
|
|
|
|
direction = get_direction(
|
|
start["lat"], start["lng"],
|
|
destination["lat"], destination["lng"]
|
|
)
|
|
|
|
# Create step-by-step plan (broken into ~100m segments)
|
|
num_steps = max(3, min(8, int(distance / 100)))
|
|
|
|
# Create the secret plan
|
|
plan = {
|
|
"active": True,
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
"start": {
|
|
"location": start_location,
|
|
"lat": start["lat"],
|
|
"lng": start["lng"],
|
|
"address": start["address"]
|
|
},
|
|
"destination": {
|
|
"name": destination["name"],
|
|
"type": destination["type"],
|
|
"lat": destination["lat"],
|
|
"lng": destination["lng"],
|
|
"address": destination["address"],
|
|
"rating": destination.get("rating"),
|
|
"reviews": destination.get("reviews")
|
|
},
|
|
"vibe": vibe,
|
|
"total_distance_m": round(distance),
|
|
"estimated_walk_min": round(distance / 80), # ~80m per minute walking
|
|
"total_steps": num_steps,
|
|
"current_step": 0,
|
|
"steps_revealed": [],
|
|
"initial_direction": direction,
|
|
"revealed": False
|
|
}
|
|
|
|
# Save the secret plan
|
|
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
with open(SECRET_PLAN_FILE, "w") as f:
|
|
json.dump(plan, f, indent=2)
|
|
|
|
return plan
|
|
|
|
|
|
def load_secret_plan() -> dict:
|
|
"""Load the current secret plan."""
|
|
if not SECRET_PLAN_FILE.exists():
|
|
return {"active": False}
|
|
|
|
try:
|
|
with open(SECRET_PLAN_FILE) as f:
|
|
return json.load(f)
|
|
except (json.JSONDecodeError, KeyError):
|
|
return {"active": False}
|
|
|
|
|
|
def get_next_step() -> dict:
|
|
"""Get the next 'spontaneous' direction for Harvey to give."""
|
|
plan = load_secret_plan()
|
|
|
|
if not plan.get("active"):
|
|
return {"error": "No active secret plan"}
|
|
|
|
current = plan.get("current_step", 0)
|
|
total = plan.get("total_steps", 5)
|
|
distance = plan.get("total_distance_m", 500)
|
|
|
|
# Calculate how far along we are
|
|
progress = current / total
|
|
remaining_distance = distance * (1 - progress)
|
|
|
|
# Decide what kind of step this is
|
|
if current == 0:
|
|
# First step - initial direction
|
|
step_type = "start"
|
|
direction = plan.get("initial_direction", "links")
|
|
phrase = random.choice(SPONTANEOUS_PHRASES["start"]).format(direction=direction)
|
|
|
|
elif progress >= 0.85 or remaining_distance < 50:
|
|
# Almost there - hint at arrival
|
|
step_type = "arrival_hint"
|
|
dest_type = plan["destination"]["type"].replace("_", " ")
|
|
phrase = random.choice(SPONTANEOUS_PHRASES["arrival_hint"]).format(direction="links")
|
|
|
|
elif current % 2 == 0:
|
|
# Regular continue
|
|
step_type = "continue"
|
|
phrase = random.choice(SPONTANEOUS_PHRASES["continue"])
|
|
|
|
else:
|
|
# Turn
|
|
step_type = "turn"
|
|
direction = random.choice(["links", "rechts"])
|
|
phrase = random.choice(SPONTANEOUS_PHRASES["turn"]).format(direction=direction)
|
|
|
|
# Update plan
|
|
plan["current_step"] = current + 1
|
|
plan["steps_revealed"].append({
|
|
"step": current + 1,
|
|
"type": step_type,
|
|
"phrase": phrase,
|
|
"timestamp": datetime.now(timezone.utc).isoformat()
|
|
})
|
|
|
|
with open(SECRET_PLAN_FILE, "w") as f:
|
|
json.dump(plan, f, indent=2)
|
|
|
|
return {
|
|
"step": current + 1,
|
|
"total_steps": total,
|
|
"type": step_type,
|
|
"phrase": phrase,
|
|
"progress": round(progress * 100),
|
|
"remaining_distance_m": round(remaining_distance),
|
|
"almost_there": progress >= 0.7
|
|
}
|
|
|
|
|
|
def reveal_destination() -> dict:
|
|
"""Reveal the secret destination - the big surprise!"""
|
|
plan = load_secret_plan()
|
|
|
|
if not plan.get("active"):
|
|
return {"error": "No active secret plan"}
|
|
|
|
dest = plan["destination"]
|
|
|
|
# Generate reveal phrase
|
|
place_name = dest["name"]
|
|
phrase = random.choice(SPONTANEOUS_PHRASES["reveal"]).format(place=place_name)
|
|
|
|
# Update plan
|
|
plan["revealed"] = True
|
|
plan["revealed_at"] = datetime.now(timezone.utc).isoformat()
|
|
|
|
with open(SECRET_PLAN_FILE, "w") as f:
|
|
json.dump(plan, f, indent=2)
|
|
|
|
result = {
|
|
"phrase": phrase,
|
|
"destination": {
|
|
"name": dest["name"],
|
|
"type": dest["type"],
|
|
"address": dest["address"],
|
|
"rating": dest.get("rating"),
|
|
"reviews": dest.get("reviews")
|
|
},
|
|
"total_distance_walked_m": plan.get("total_distance_m"),
|
|
"steps_taken": len(plan.get("steps_revealed", []))
|
|
}
|
|
|
|
# Add Google Maps link
|
|
result["maps_link"] = f"https://www.google.com/maps/search/?api=1&query={dest['lat']},{dest['lng']}"
|
|
|
|
return result
|
|
|
|
|
|
def end_secret_plan(reason: str = "completed") -> dict:
|
|
"""End the current secret plan."""
|
|
plan = load_secret_plan()
|
|
|
|
if not plan.get("active"):
|
|
return {"error": "No active secret plan"}
|
|
|
|
plan["active"] = False
|
|
plan["ended_at"] = datetime.now(timezone.utc).isoformat()
|
|
plan["end_reason"] = reason
|
|
|
|
with open(SECRET_PLAN_FILE, "w") as f:
|
|
json.dump(plan, f, indent=2)
|
|
|
|
return plan
|
|
|
|
|
|
def get_plan_status() -> dict:
|
|
"""Get status of current secret plan (without revealing destination!)."""
|
|
plan = load_secret_plan()
|
|
|
|
if not plan.get("active"):
|
|
return {"active": False, "message": "Kein geheimer Plan aktiv"}
|
|
|
|
current = plan.get("current_step", 0)
|
|
total = plan.get("total_steps", 5)
|
|
progress = round((current / total) * 100)
|
|
|
|
return {
|
|
"active": True,
|
|
"vibe": plan.get("vibe"),
|
|
"current_step": current,
|
|
"total_steps": total,
|
|
"progress": progress,
|
|
"revealed": plan.get("revealed", False),
|
|
"destination_type": plan["destination"]["type"] if plan.get("revealed") else "???",
|
|
"message": f"🐰 Geheimer Plan läuft... {progress}% ({current}/{total} Schritte)"
|
|
}
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="Harvey Secret Guide")
|
|
parser.add_argument("action", choices=[
|
|
"plan", "next", "reveal", "status", "end", "destinations"
|
|
], help="Action to perform")
|
|
parser.add_argument("--location", "-l", help="Start location")
|
|
parser.add_argument("--vibe", "-v",
|
|
choices=["food", "culture", "nature", "drinks", "explore", "chill"],
|
|
default="explore", help="Vibe/type of destination")
|
|
parser.add_argument("--distance", "-d", type=int, default=800,
|
|
help="Max distance in meters")
|
|
parser.add_argument("--reason", default="completed", help="End reason")
|
|
parser.add_argument("--json", "-j", action="store_true", help="Output as JSON")
|
|
|
|
args = parser.parse_args()
|
|
|
|
if args.action == "plan":
|
|
if not args.location:
|
|
print("Error: --location required", file=sys.stderr)
|
|
sys.exit(1)
|
|
result = create_secret_plan(args.location, args.vibe, args.distance)
|
|
if "error" in result:
|
|
msg = f"❌ {result['error']}"
|
|
else:
|
|
msg = f"🐰 Geheimer Plan erstellt! Vibe: {args.vibe} | ~{result['estimated_walk_min']} Min Fußweg"
|
|
|
|
elif args.action == "next":
|
|
result = get_next_step()
|
|
if "error" in result:
|
|
msg = f"❌ {result['error']}"
|
|
else:
|
|
msg = result["phrase"]
|
|
|
|
elif args.action == "reveal":
|
|
result = reveal_destination()
|
|
if "error" in result:
|
|
msg = f"❌ {result['error']}"
|
|
else:
|
|
msg = f"{result['phrase']}\n📍 {result['destination']['address']}"
|
|
|
|
elif args.action == "status":
|
|
result = get_plan_status()
|
|
msg = result.get("message", "Kein Plan")
|
|
|
|
elif args.action == "end":
|
|
result = end_secret_plan(args.reason)
|
|
msg = "🐰 Geheimer Plan beendet"
|
|
|
|
elif args.action == "destinations":
|
|
if not args.location:
|
|
print("Error: --location required", file=sys.stderr)
|
|
sys.exit(1)
|
|
start = geocode_location(args.location)
|
|
if not start:
|
|
print(f"Error: Could not find location", file=sys.stderr)
|
|
sys.exit(1)
|
|
result = find_nearby_destinations(start["lat"], start["lng"], args.vibe, args.distance)
|
|
msg = f"Found {len(result)} destinations"
|
|
|
|
else:
|
|
result = {"error": "Unknown action"}
|
|
msg = "Unknown action"
|
|
|
|
if args.json:
|
|
print(json.dumps(result, indent=2, default=str))
|
|
else:
|
|
print(msg)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|