flowforge-map 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- flowforge/__init__.py +0 -0
- flowforge/_classic.html +289 -0
- flowforge/_dashboard/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- flowforge/_dashboard/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- flowforge/_dashboard/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- flowforge/_dashboard/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- flowforge/_dashboard/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
- flowforge/_dashboard/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
- flowforge/_dashboard/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
- flowforge/_dashboard/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
- flowforge/_dashboard/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
- flowforge/_dashboard/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
- flowforge/_dashboard/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- flowforge/_dashboard/assets/index-BGT5seIN.css +1 -0
- flowforge/_dashboard/assets/index-BoGikST6.js +52 -0
- flowforge/_dashboard/favicon.svg +1 -0
- flowforge/_dashboard/index.html +16 -0
- flowforge/_example/trip-planner/.env.example +3 -0
- flowforge/_example/trip-planner/.gitignore +2 -0
- flowforge/_example/trip-planner/.mcp.json +20 -0
- flowforge/_example/trip-planner/README.md +48 -0
- flowforge/_example/trip-planner/config.py +27 -0
- flowforge/_example/trip-planner/mcp_servers/currency.py +10 -0
- flowforge/_example/trip-planner/planner.py +113 -0
- flowforge/_example/trip-planner/requirements.txt +3 -0
- flowforge/_example/trip-planner/services/__init__.py +0 -0
- flowforge/_example/trip-planner/services/geo.py +49 -0
- flowforge/_example/trip-planner/services/llm.py +18 -0
- flowforge/_example/trip-planner/services/mcp_tools.py +26 -0
- flowforge/_example/trip-planner/services/weather.py +25 -0
- flowforge/_example/trip-planner/tools/packing_list.py +24 -0
- flowforge/_example/trip-planner/trips/.gitkeep +0 -0
- flowforge/_workflows/diamond_mock.json +71 -0
- flowforge/_workflows/stripe_to_razorpay.json +38 -0
- flowforge/agent/__init__.py +5 -0
- flowforge/agent/server.py +333 -0
- flowforge/api/__init__.py +0 -0
- flowforge/api/agent.py +79 -0
- flowforge/api/checks.py +255 -0
- flowforge/api/connectors.py +289 -0
- flowforge/api/flowmap.py +174 -0
- flowforge/api/runs.py +284 -0
- flowforge/api/scan.py +117 -0
- flowforge/api/security.py +31 -0
- flowforge/api/stats.py +80 -0
- flowforge/checks/__init__.py +13 -0
- flowforge/checks/models.py +41 -0
- flowforge/checks/probes.py +359 -0
- flowforge/cli.py +108 -0
- flowforge/connectors/__init__.py +0 -0
- flowforge/connectors/catalog.py +84 -0
- flowforge/connectors/models.py +149 -0
- flowforge/connectors/presets.py +192 -0
- flowforge/connectors/registry.py +85 -0
- flowforge/connectors/runtime.py +47 -0
- flowforge/connectors/secrets.py +53 -0
- flowforge/example_data.py +247 -0
- flowforge/flow/__init__.py +328 -0
- flowforge/flow/advice.py +132 -0
- flowforge/flow/curly.py +884 -0
- flowforge/flow/index.py +116 -0
- flowforge/flow/order.py +181 -0
- flowforge/flow/reach.py +145 -0
- flowforge/flow/rewrite.py +146 -0
- flowforge/flowmap/__init__.py +1 -0
- flowforge/flowmap/diff.py +145 -0
- flowforge/flowmap/fixture.py +283 -0
- flowforge/flowmap/models.py +258 -0
- flowforge/flowmap/planner.py +131 -0
- flowforge/flowmap/service.py +379 -0
- flowforge/flowmap/validate.py +228 -0
- flowforge/home.py +58 -0
- flowforge/main.py +137 -0
- flowforge/nodes/__init__.py +3 -0
- flowforge/nodes/base.py +74 -0
- flowforge/nodes/check_node.py +50 -0
- flowforge/nodes/http_node.py +161 -0
- flowforge/nodes/llm_node.py +158 -0
- flowforge/nodes/local_node.py +164 -0
- flowforge/nodes/mcp_node.py +243 -0
- flowforge/nodes/mock_node.py +40 -0
- flowforge/scan/__init__.py +6 -0
- flowforge/scan/facts.py +47 -0
- flowforge/scan/files.py +112 -0
- flowforge/scan/manifests.py +65 -0
- flowforge/scan/mcp_config.py +75 -0
- flowforge/scan/models.py +73 -0
- flowforge/scan/patterns.py +86 -0
- flowforge/scan/python_ast.py +246 -0
- flowforge/scan/scanner.py +344 -0
- flowforge/scan/services.py +58 -0
- flowforge/scan/urls.py +60 -0
- flowforge/scheduler/__init__.py +0 -0
- flowforge/scheduler/cache.py +76 -0
- flowforge/scheduler/critical_path.py +65 -0
- flowforge/scheduler/durations.py +35 -0
- flowforge/scheduler/executor.py +390 -0
- flowforge/scheduler/graph.py +108 -0
- flowforge/scheduler/rate_limit.py +36 -0
- flowforge/schema.py +81 -0
- flowforge/security/__init__.py +0 -0
- flowforge/security/finders.py +127 -0
- flowforge/security/findings.py +169 -0
- flowforge/security/logfilter.py +129 -0
- flowforge/spa.py +52 -0
- flowforge/state.py +73 -0
- flowforge/storage.py +481 -0
- flowforge/templating.py +60 -0
- flowforge_map-0.1.0.dist-info/METADATA +335 -0
- flowforge_map-0.1.0.dist-info/RECORD +113 -0
- flowforge_map-0.1.0.dist-info/WHEEL +4 -0
- flowforge_map-0.1.0.dist-info/entry_points.txt +3 -0
- flowforge_map-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#0F1D26"/><circle cx="32" cy="32" r="9" fill="#4FD1C0"/><g stroke="#4FD1C0" stroke-width="4" stroke-linecap="round"><path d="M32 12v8M32 44v8M12 32h8M44 32h8"/></g></svg>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<meta name="color-scheme" content="light dark" />
|
|
7
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
8
|
+
<meta name="description" content="FlowForge: connect your apps, see what will run, and watch every step live." />
|
|
9
|
+
<title>FlowForge</title>
|
|
10
|
+
<script type="module" crossorigin src="/assets/index-BoGikST6.js"></script>
|
|
11
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BGT5seIN.css">
|
|
12
|
+
</head>
|
|
13
|
+
<body>
|
|
14
|
+
<div id="root"></div>
|
|
15
|
+
</body>
|
|
16
|
+
</html>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"mcpServers": {
|
|
3
|
+
"time": {
|
|
4
|
+
"command": "uvx",
|
|
5
|
+
"args": ["mcp-server-time@2026.10.19"]
|
|
6
|
+
},
|
|
7
|
+
"fetch": {
|
|
8
|
+
"command": "uvx",
|
|
9
|
+
"args": ["mcp-server-fetch"]
|
|
10
|
+
},
|
|
11
|
+
"files": {
|
|
12
|
+
"command": "npx",
|
|
13
|
+
"args": ["-y", "@modelcontextprotocol/server-filesystem@2.0.0", "trips"]
|
|
14
|
+
},
|
|
15
|
+
"currency": {
|
|
16
|
+
"command": "python",
|
|
17
|
+
"args": ["mcp_servers/currency.py"]
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Trip planner (sample app for FlowForge)
|
|
2
|
+
|
|
3
|
+
A small app that plans a relaxed day trip. It uses only free services that need no key, so you can run it, scan it, and break nothing. FlowForge uses it three ways:
|
|
4
|
+
- the scanner's test case: the tables below are what a scan must find;
|
|
5
|
+
- the demo;
|
|
6
|
+
- the example map.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
# from the FlowForge repo root, after `uv sync`
|
|
10
|
+
cd examples/trip-planner
|
|
11
|
+
../../.venv/bin/python planner.py Mangalore --to "Panambur Beach"
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
You need [Ollama](https://ollama.com) running with `llama3.1:8b` (`ollama pull llama3.1:8b`), plus `uvx` and `npx` for the MCP servers. Every call is tried on its own: a missing piece prints a ✗ and the plan goes on without it. A full run takes a minute or two on a laptop, mostly the local model.
|
|
15
|
+
|
|
16
|
+
It sends your IP address to ip-api.com (to guess your city) and the city name to OpenStreetMap, Open-Meteo, OSRM and Wikipedia. Nothing else leaves your machine.
|
|
17
|
+
|
|
18
|
+
## What it depends on (the scan must find these)
|
|
19
|
+
|
|
20
|
+
| Kind | Dependency | Evidence |
|
|
21
|
+
|---|---|---|
|
|
22
|
+
| LLM | Ollama, `llama3.1:8b`, through the OpenAI SDK | `services/llm.py:5`, `services/llm.py:14`, `config.py:11` |
|
|
23
|
+
| HTTP API | Nominatim (place → coordinates) | `config.py:15` |
|
|
24
|
+
| HTTP API | Open-Meteo (weather) | `config.py:16` |
|
|
25
|
+
| HTTP API | OSRM demo server (routes) | `config.py:17` |
|
|
26
|
+
| HTTP API | ip-api (your city from your IP) | `config.py:18` |
|
|
27
|
+
| HTTP API | Google Static Maps (a link it prints; never called by the app) | `config.py:24` |
|
|
28
|
+
| HTTP API | Wikipedia, fetched through the `fetch` MCP server | `planner.py:67` |
|
|
29
|
+
| MCP | `time` (`uvx mcp-server-time`, pinned) | `.mcp.json:3` |
|
|
30
|
+
| MCP | `fetch` (`uvx mcp-server-fetch`) | `.mcp.json:7` |
|
|
31
|
+
| MCP | `files` (`npx @modelcontextprotocol/server-filesystem`, pinned, only `trips/`) | `.mcp.json:11` |
|
|
32
|
+
| MCP | `currency` (a local Python server) | `.mcp.json:15` |
|
|
33
|
+
| Local | `tools/packing_list.py`, run as a subprocess | `planner.py:37` |
|
|
34
|
+
|
|
35
|
+
Env var names it reads: `OLLAMA_BASE_URL`, `PLANNER_MODEL` (`config.py:11-12`), and `CURRENCY_API_URL` (`mcp_servers/currency.py:9`).
|
|
36
|
+
|
|
37
|
+
These are libraries, not dependencies: `httpx`, `mcp`, `openai` (`requirements.txt`). They are the clients the app uses to reach the dependencies above.
|
|
38
|
+
|
|
39
|
+
## Planted problems (the checks must find these)
|
|
40
|
+
|
|
41
|
+
| What | Where | Found by |
|
|
42
|
+
|---|---|---|
|
|
43
|
+
| A hard-coded Google key (fake, `AIzaFAKE…`) | `config.py:23` | security findings (Phase 6) |
|
|
44
|
+
| A plain `http://` API | `config.py:18` (ip-api's free tier is http only) | security findings (Phase 6) |
|
|
45
|
+
| An unpinned MCP package | `.mcp.json:9` (`mcp-server-fetch`, no version) | security findings (Phase 6) |
|
|
46
|
+
| An MCP server that never starts | `currency`: exits with "CURRENCY_API_URL is not set" | health checks (Phase 5) |
|
|
47
|
+
|
|
48
|
+
The key is fake and never works. Never put a real key in this folder.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Where every outside service lives. Keys come from the environment, except one planted mistake."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
HERE = Path(__file__).resolve().parent
|
|
9
|
+
|
|
10
|
+
# LLM: a local model through Ollama's OpenAI-compatible endpoint (free, nothing leaves the machine)
|
|
11
|
+
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434/v1")
|
|
12
|
+
PLANNER_MODEL = os.getenv("PLANNER_MODEL", "llama3.1:8b")
|
|
13
|
+
|
|
14
|
+
# HTTP APIs: free and keyless
|
|
15
|
+
NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"
|
|
16
|
+
OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast"
|
|
17
|
+
OSRM_URL = "https://router.project-osrm.org/route/v1/driving"
|
|
18
|
+
IP_API_URL = "http://ip-api.com/json/" # PLANTED: the free tier is plain http only, a security finding
|
|
19
|
+
|
|
20
|
+
USER_AGENT = "flowforge-trip-planner-example/0.1 (+https://github.com/Vishwa-Thangapandiyan/flowforge-agent-orchestrator)"
|
|
21
|
+
|
|
22
|
+
# PLANTED: a hard-coded key, the kind of mistake the scan must report. It is fake and never works.
|
|
23
|
+
GOOGLE_STATIC_MAPS_KEY = "AIzaFAKEFAKEFAKEFAKEFAKE1234"
|
|
24
|
+
STATIC_MAP_URL = "https://maps.googleapis.com/maps/api/staticmap"
|
|
25
|
+
|
|
26
|
+
# MCP servers are listed in .mcp.json; the filesystem server may only touch this folder
|
|
27
|
+
TRIPS_DIR = HERE / "trips"
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""A currency MCP server that was never finished: it exits at start-up.
|
|
2
|
+
|
|
3
|
+
PLANTED: FlowForge's health check should show this server as down, with the reason below.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
if not os.getenv("CURRENCY_API_URL"):
|
|
10
|
+
sys.exit("currency server: CURRENCY_API_URL is not set")
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Plan a day trip: `python planner.py Mangalore --to "Panambur Beach"`.
|
|
2
|
+
|
|
3
|
+
Each outside call is tried on its own; a failed one is reported and the plan goes on without it.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import asyncio
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
from collections.abc import Awaitable
|
|
13
|
+
from datetime import date
|
|
14
|
+
from urllib.parse import urlencode
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from config import GOOGLE_STATIC_MAPS_KEY, HERE, STATIC_MAP_URL, TRIPS_DIR
|
|
19
|
+
from services import geo, llm, mcp_tools, weather
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def attempt[T](label: str, work: Awaitable[T]) -> T | None:
|
|
23
|
+
"""Await `work`; on any failure print why and return None."""
|
|
24
|
+
try:
|
|
25
|
+
result = await work
|
|
26
|
+
except Exception as exc: # the demo keeps going without this piece
|
|
27
|
+
while isinstance(exc, ExceptionGroup): # MCP's task groups wrap the real reason
|
|
28
|
+
exc = exc.exceptions[0]
|
|
29
|
+
print(f" ✗ {label}: {str(exc) or type(exc).__name__}")
|
|
30
|
+
return None
|
|
31
|
+
print(f" ✓ {label}")
|
|
32
|
+
return result
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def packing_list(max_c: float, rain: float) -> str:
|
|
36
|
+
"""The local tool, run as a separate program (an argv list, never a shell)."""
|
|
37
|
+
done = subprocess.run([sys.executable, str(HERE / "tools" / "packing_list.py"),
|
|
38
|
+
"--max-c", str(max_c), "--rain", str(rain)],
|
|
39
|
+
capture_output=True, text=True, timeout=10, check=True)
|
|
40
|
+
return done.stdout.strip()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def static_map_link(place: geo.Place) -> str:
|
|
44
|
+
query = {"center": f"{place.lat},{place.lon}", "zoom": 13, "size": "600x300", "key": GOOGLE_STATIC_MAPS_KEY}
|
|
45
|
+
return f"{STATIC_MAP_URL}?{urlencode(query)}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def plan(city: str, sight: str | None) -> None:
|
|
49
|
+
print(f"Planning a day in {city}…")
|
|
50
|
+
async with httpx.AsyncClient(timeout=20) as client:
|
|
51
|
+
home = await attempt("where you are (ip-api)", geo.where_am_i(client))
|
|
52
|
+
place = await attempt("find the city (Nominatim)", geo.find_place(client, city))
|
|
53
|
+
if place is None:
|
|
54
|
+
print("Can't plan without the city's location.")
|
|
55
|
+
return
|
|
56
|
+
target = None
|
|
57
|
+
if sight:
|
|
58
|
+
target = await attempt("find the sight (Nominatim)", geo.find_place(client, f"{sight}, {city}"))
|
|
59
|
+
trip = await attempt("route to the sight (OSRM)", geo.route(client, place, target)) if target else None
|
|
60
|
+
forecast = await attempt("today's weather (Open-Meteo)", weather.today(client, place.lat, place.lon))
|
|
61
|
+
|
|
62
|
+
local_time = None
|
|
63
|
+
if forecast:
|
|
64
|
+
local_time = await attempt("local time (MCP time)", mcp_tools.call(
|
|
65
|
+
"time", "get_current_time", {"timezone": forecast["timezone"]}))
|
|
66
|
+
background = await attempt("about the city (MCP fetch)", mcp_tools.call(
|
|
67
|
+
"fetch", "fetch", {"url": f"https://en.wikipedia.org/wiki/{city.replace(' ', '_')}", "max_length": 1500}))
|
|
68
|
+
rate = await attempt("rupee rate (MCP currency)", mcp_tools.call("currency", "rate", {"to": "USD"}))
|
|
69
|
+
packing = None
|
|
70
|
+
if forecast:
|
|
71
|
+
try:
|
|
72
|
+
packing = packing_list(forecast["max_c"], forecast["rain_chance"])
|
|
73
|
+
print(" ✓ packing list (local tool)")
|
|
74
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
75
|
+
print(f" ✗ packing list (local tool): {exc}")
|
|
76
|
+
|
|
77
|
+
facts = [f"City: {place.name}", f"Traveller is in: {home or 'unknown'}"]
|
|
78
|
+
if target and trip:
|
|
79
|
+
facts.append(f"Sight: {target.name}, {trip['km']} km and {trip['minutes']} min by car from the centre")
|
|
80
|
+
if forecast:
|
|
81
|
+
facts.append(f"Weather: {forecast['min_c']}–{forecast['max_c']} °C, {forecast['rain_chance']}% chance of rain")
|
|
82
|
+
if local_time:
|
|
83
|
+
facts.append(f"Local time: {local_time}")
|
|
84
|
+
if rate:
|
|
85
|
+
facts.append(f"Exchange rate: {rate}")
|
|
86
|
+
if background:
|
|
87
|
+
facts.append(f"About the city: {background[:1200]}")
|
|
88
|
+
itinerary = await attempt("write the plan (Ollama)", llm.write_itinerary("\n".join(facts)))
|
|
89
|
+
|
|
90
|
+
print()
|
|
91
|
+
print(itinerary or "No plan written: the LLM wasn't reachable. Is Ollama running?")
|
|
92
|
+
if packing:
|
|
93
|
+
print(f"\nPack: {packing}")
|
|
94
|
+
print(f"\nMap: {static_map_link(place)}")
|
|
95
|
+
if itinerary:
|
|
96
|
+
TRIPS_DIR.mkdir(exist_ok=True)
|
|
97
|
+
path = TRIPS_DIR / f"{city.lower().replace(' ', '-')}-{date.today()}.md"
|
|
98
|
+
saved = await attempt("save the plan (MCP files)", mcp_tools.call(
|
|
99
|
+
"files", "write_file", {"path": str(path), "content": itinerary}))
|
|
100
|
+
if saved is not None:
|
|
101
|
+
print(f"Saved to {path.relative_to(HERE)}")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def main() -> None:
|
|
105
|
+
parser = argparse.ArgumentParser(description="Plan a relaxed day trip with free, keyless services.")
|
|
106
|
+
parser.add_argument("city")
|
|
107
|
+
parser.add_argument("--to", dest="sight", help="a sight in the city, for the route")
|
|
108
|
+
args = parser.parse_args()
|
|
109
|
+
asyncio.run(plan(args.city, args.sight))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
if __name__ == "__main__":
|
|
113
|
+
main()
|
|
File without changes
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Places and routes: Nominatim (place → coordinates), OSRM (driving route), ip-api (where you are)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from config import IP_API_URL, NOMINATIM_URL, OSRM_URL, USER_AGENT
|
|
11
|
+
|
|
12
|
+
# Nominatim's usage policy: at most one request a second, with a User-Agent that names the app
|
|
13
|
+
_NOMINATIM_GAP_S = 1.1
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Place:
|
|
18
|
+
name: str
|
|
19
|
+
lat: float
|
|
20
|
+
lon: float
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def find_place(client: httpx.AsyncClient, query: str) -> Place:
|
|
24
|
+
response = await client.get(NOMINATIM_URL, params={"q": query, "format": "json", "limit": 1},
|
|
25
|
+
headers={"User-Agent": USER_AGENT})
|
|
26
|
+
response.raise_for_status()
|
|
27
|
+
results = response.json()
|
|
28
|
+
if not results:
|
|
29
|
+
raise LookupError(f"no place called {query!r}")
|
|
30
|
+
first = results[0]
|
|
31
|
+
await asyncio.sleep(_NOMINATIM_GAP_S)
|
|
32
|
+
return Place(first["display_name"].split(",")[0], float(first["lat"]), float(first["lon"]))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def route(client: httpx.AsyncClient, start: Place, end: Place) -> dict[str, float]:
|
|
36
|
+
"""Driving distance (km) and time (minutes) from the public OSRM demo server."""
|
|
37
|
+
coords = f"{start.lon},{start.lat};{end.lon},{end.lat}"
|
|
38
|
+
response = await client.get(f"{OSRM_URL}/{coords}", params={"overview": "false"})
|
|
39
|
+
response.raise_for_status()
|
|
40
|
+
best = response.json()["routes"][0]
|
|
41
|
+
return {"km": round(best["distance"] / 1000, 1), "minutes": round(best["duration"] / 60)}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
async def where_am_i(client: httpx.AsyncClient) -> str:
|
|
45
|
+
"""Your city from your IP address (ip-api's free tier, plain http)."""
|
|
46
|
+
response = await client.get(IP_API_URL, params={"fields": "status,city,country"})
|
|
47
|
+
response.raise_for_status()
|
|
48
|
+
data = response.json()
|
|
49
|
+
return f"{data['city']}, {data['country']}" if data.get("status") == "success" else "unknown"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""The itinerary writer: a local Ollama model through the OpenAI SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from openai import AsyncOpenAI
|
|
6
|
+
|
|
7
|
+
from config import OLLAMA_BASE_URL, PLANNER_MODEL
|
|
8
|
+
|
|
9
|
+
SYSTEM = ("You plan one relaxed day trip. Use only the facts given. "
|
|
10
|
+
"Answer in short markdown: a title, then Morning, Afternoon and Evening, two bullets each.")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def write_itinerary(facts: str) -> str:
|
|
14
|
+
client = AsyncOpenAI(base_url=OLLAMA_BASE_URL, api_key="ollama", timeout=180) # Ollama ignores the key
|
|
15
|
+
reply = await client.chat.completions.create(
|
|
16
|
+
model=PLANNER_MODEL, temperature=0.3,
|
|
17
|
+
messages=[{"role": "system", "content": SYSTEM}, {"role": "user", "content": facts}])
|
|
18
|
+
return reply.choices[0].message.content or ""
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""MCP servers from .mcp.json: local time, page fetch, saving files, and a broken currency server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from mcp import ClientSession, StdioServerParameters
|
|
9
|
+
from mcp.client.stdio import stdio_client
|
|
10
|
+
|
|
11
|
+
from config import HERE
|
|
12
|
+
|
|
13
|
+
SERVERS: dict[str, dict[str, Any]] = json.loads((HERE / ".mcp.json").read_text(encoding="utf-8"))["mcpServers"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def call(server: str, tool: str, arguments: dict[str, Any]) -> str:
|
|
17
|
+
"""Start one server from .mcp.json, call one tool, return its text."""
|
|
18
|
+
spec = SERVERS[server]
|
|
19
|
+
params = StdioServerParameters(command=spec["command"], args=spec.get("args", []), cwd=str(HERE))
|
|
20
|
+
async with stdio_client(params) as (read, write), ClientSession(read, write) as session:
|
|
21
|
+
await session.initialize()
|
|
22
|
+
result = await session.call_tool(tool, arguments)
|
|
23
|
+
text = "\n".join(block.text for block in result.content if getattr(block, "type", "") == "text")
|
|
24
|
+
if result.is_error:
|
|
25
|
+
raise RuntimeError(text or f"{server}.{tool} failed")
|
|
26
|
+
return text
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Today's weather from Open-Meteo (free, no key)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from config import OPEN_METEO_URL
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
async def today(client: httpx.AsyncClient, lat: float, lon: float) -> dict[str, Any]:
|
|
13
|
+
response = await client.get(OPEN_METEO_URL, params={
|
|
14
|
+
"latitude": lat, "longitude": lon, "timezone": "auto", "forecast_days": 1,
|
|
15
|
+
"daily": "temperature_2m_max,temperature_2m_min,precipitation_probability_max",
|
|
16
|
+
})
|
|
17
|
+
response.raise_for_status()
|
|
18
|
+
data = response.json()
|
|
19
|
+
daily = data["daily"]
|
|
20
|
+
return {
|
|
21
|
+
"timezone": data["timezone"],
|
|
22
|
+
"max_c": daily["temperature_2m_max"][0],
|
|
23
|
+
"min_c": daily["temperature_2m_min"][0],
|
|
24
|
+
"rain_chance": daily["precipitation_probability_max"][0],
|
|
25
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""A local command: a packing list from today's weather. Called by planner.py as a subprocess."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main() -> None:
|
|
9
|
+
parser = argparse.ArgumentParser()
|
|
10
|
+
parser.add_argument("--max-c", type=float, required=True)
|
|
11
|
+
parser.add_argument("--rain", type=float, required=True)
|
|
12
|
+
args = parser.parse_args()
|
|
13
|
+
items = ["water bottle", "phone charger"]
|
|
14
|
+
if args.max_c >= 28:
|
|
15
|
+
items += ["sunscreen", "cap"]
|
|
16
|
+
if args.max_c <= 15:
|
|
17
|
+
items.append("jacket")
|
|
18
|
+
if args.rain >= 40:
|
|
19
|
+
items.append("umbrella")
|
|
20
|
+
print(", ".join(items))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
if __name__ == "__main__":
|
|
24
|
+
main()
|
|
File without changes
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "diamond_mock",
|
|
3
|
+
"name": "Mock diamond — hand-checkable (critical path a → b → e = 500 ms)",
|
|
4
|
+
"max_concurrency": 2,
|
|
5
|
+
"steps": [
|
|
6
|
+
{
|
|
7
|
+
"id": "a",
|
|
8
|
+
"type": "mock",
|
|
9
|
+
"params": {
|
|
10
|
+
"duration_ms": 100,
|
|
11
|
+
"step": "a"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"id": "b",
|
|
16
|
+
"type": "mock",
|
|
17
|
+
"depends_on": [
|
|
18
|
+
"a"
|
|
19
|
+
],
|
|
20
|
+
"params": {
|
|
21
|
+
"duration_ms": 300,
|
|
22
|
+
"step": "b"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"id": "c",
|
|
27
|
+
"type": "mock",
|
|
28
|
+
"depends_on": [
|
|
29
|
+
"a"
|
|
30
|
+
],
|
|
31
|
+
"params": {
|
|
32
|
+
"duration_ms": 100,
|
|
33
|
+
"step": "c"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"id": "d",
|
|
38
|
+
"type": "mock",
|
|
39
|
+
"depends_on": [
|
|
40
|
+
"a"
|
|
41
|
+
],
|
|
42
|
+
"params": {
|
|
43
|
+
"duration_ms": 50,
|
|
44
|
+
"step": "d"
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"id": "e",
|
|
49
|
+
"type": "mock",
|
|
50
|
+
"depends_on": [
|
|
51
|
+
"b",
|
|
52
|
+
"c"
|
|
53
|
+
],
|
|
54
|
+
"params": {
|
|
55
|
+
"duration_ms": 100,
|
|
56
|
+
"step": "e"
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"id": "f",
|
|
61
|
+
"type": "mock",
|
|
62
|
+
"depends_on": [
|
|
63
|
+
"d"
|
|
64
|
+
],
|
|
65
|
+
"params": {
|
|
66
|
+
"duration_ms": 50,
|
|
67
|
+
"step": "f"
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
]
|
|
71
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "stripe_to_razorpay",
|
|
3
|
+
"name": "Stripe → Razorpay migration analysis",
|
|
4
|
+
"max_concurrency": 4,
|
|
5
|
+
"type_concurrency": { "llm": 3 },
|
|
6
|
+
"steps": [
|
|
7
|
+
{ "id": "fetch_stripe_endpoints", "type": "http", "estimated_ms": 500,
|
|
8
|
+
"params": { "url": "https://docs.stripe.com/api/payment_intents.md", "max_chars": 8000 } },
|
|
9
|
+
{ "id": "fetch_stripe_lifecycle", "type": "mcp", "estimated_ms": 2500,
|
|
10
|
+
"params": { "tool": "fetch", "arguments": { "url": "https://docs.stripe.com/api/payment_intents", "max_length": 6000 } } },
|
|
11
|
+
{ "id": "fetch_razorpay_endpoints", "type": "http", "estimated_ms": 500,
|
|
12
|
+
"params": { "url": "https://razorpay.com/docs/api/payments.md", "max_chars": 8000 } },
|
|
13
|
+
{ "id": "fetch_razorpay_for_risks", "type": "http", "estimated_ms": 500,
|
|
14
|
+
"description": "Deliberate duplicate of fetch_razorpay_endpoints: the cache should make only one request.",
|
|
15
|
+
"params": { "url": "https://razorpay.com/docs/api/payments.md", "max_chars": 8000 } },
|
|
16
|
+
|
|
17
|
+
{ "id": "summarize_stripe", "type": "llm", "depends_on": ["fetch_stripe_endpoints"], "estimated_ms": 4000,
|
|
18
|
+
"params": { "temperature": 0, "max_tokens": 600,
|
|
19
|
+
"prompt": "List every Stripe PaymentIntent endpoint (method + path) with a one-line purpose, from these docs:\n\n{{steps.fetch_stripe_endpoints.output.body}}" } },
|
|
20
|
+
{ "id": "summarize_stripe_lifecycle", "type": "llm", "depends_on": ["fetch_stripe_lifecycle"], "estimated_ms": 3000,
|
|
21
|
+
"params": { "temperature": 0, "max_tokens": 400,
|
|
22
|
+
"prompt": "Describe the PaymentIntent status lifecycle (each status and what moves it to the next) from these Stripe docs:\n\n{{steps.fetch_stripe_lifecycle.output.content}}" } },
|
|
23
|
+
{ "id": "summarize_razorpay", "type": "llm", "depends_on": ["fetch_razorpay_endpoints"], "estimated_ms": 4000,
|
|
24
|
+
"params": { "temperature": 0, "max_tokens": 600,
|
|
25
|
+
"prompt": "List every Razorpay Payments API endpoint (method + path) with a one-line purpose, from these docs:\n\n{{steps.fetch_razorpay_endpoints.output.body}}" } },
|
|
26
|
+
|
|
27
|
+
{ "id": "map_endpoints", "type": "llm", "depends_on": ["summarize_stripe", "summarize_razorpay"], "estimated_ms": 6000,
|
|
28
|
+
"params": { "temperature": 0, "max_tokens": 800,
|
|
29
|
+
"prompt": "Map each Stripe endpoint to its closest Razorpay equivalent as a markdown table. Flag anything with no equivalent.\n\nSTRIPE:\n{{steps.summarize_stripe.output.text}}\n\nRAZORPAY:\n{{steps.summarize_razorpay.output.text}}" } },
|
|
30
|
+
|
|
31
|
+
{ "id": "migration_notes", "type": "llm", "depends_on": ["map_endpoints", "summarize_stripe_lifecycle"], "estimated_ms": 6000,
|
|
32
|
+
"params": { "temperature": 0, "max_tokens": 900,
|
|
33
|
+
"prompt": "Write step-by-step migration notes (Python) for moving a Stripe PaymentIntent integration to Razorpay, using this mapping and lifecycle.\n\nMAPPING:\n{{steps.map_endpoints.output.text}}\n\nSTRIPE LIFECYCLE:\n{{steps.summarize_stripe_lifecycle.output.text}}" } },
|
|
34
|
+
{ "id": "risk_report", "type": "llm", "depends_on": ["map_endpoints", "fetch_razorpay_for_risks"], "estimated_ms": 4000,
|
|
35
|
+
"params": { "temperature": 0, "max_tokens": 500,
|
|
36
|
+
"prompt": "List the top migration risks (currency, refunds, idempotency, webhooks) given this mapping and the Razorpay docs.\n\nMAPPING:\n{{steps.map_endpoints.output.text}}\n\nRAZORPAY DOCS:\n{{steps.fetch_razorpay_for_risks.output.body}}" } }
|
|
37
|
+
]
|
|
38
|
+
}
|