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.
Files changed (113) hide show
  1. flowforge/__init__.py +0 -0
  2. flowforge/_classic.html +289 -0
  3. flowforge/_dashboard/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  4. flowforge/_dashboard/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  5. flowforge/_dashboard/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  6. flowforge/_dashboard/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  7. flowforge/_dashboard/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
  8. flowforge/_dashboard/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
  9. flowforge/_dashboard/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
  10. flowforge/_dashboard/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
  11. flowforge/_dashboard/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
  12. flowforge/_dashboard/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
  13. flowforge/_dashboard/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  14. flowforge/_dashboard/assets/index-BGT5seIN.css +1 -0
  15. flowforge/_dashboard/assets/index-BoGikST6.js +52 -0
  16. flowforge/_dashboard/favicon.svg +1 -0
  17. flowforge/_dashboard/index.html +16 -0
  18. flowforge/_example/trip-planner/.env.example +3 -0
  19. flowforge/_example/trip-planner/.gitignore +2 -0
  20. flowforge/_example/trip-planner/.mcp.json +20 -0
  21. flowforge/_example/trip-planner/README.md +48 -0
  22. flowforge/_example/trip-planner/config.py +27 -0
  23. flowforge/_example/trip-planner/mcp_servers/currency.py +10 -0
  24. flowforge/_example/trip-planner/planner.py +113 -0
  25. flowforge/_example/trip-planner/requirements.txt +3 -0
  26. flowforge/_example/trip-planner/services/__init__.py +0 -0
  27. flowforge/_example/trip-planner/services/geo.py +49 -0
  28. flowforge/_example/trip-planner/services/llm.py +18 -0
  29. flowforge/_example/trip-planner/services/mcp_tools.py +26 -0
  30. flowforge/_example/trip-planner/services/weather.py +25 -0
  31. flowforge/_example/trip-planner/tools/packing_list.py +24 -0
  32. flowforge/_example/trip-planner/trips/.gitkeep +0 -0
  33. flowforge/_workflows/diamond_mock.json +71 -0
  34. flowforge/_workflows/stripe_to_razorpay.json +38 -0
  35. flowforge/agent/__init__.py +5 -0
  36. flowforge/agent/server.py +333 -0
  37. flowforge/api/__init__.py +0 -0
  38. flowforge/api/agent.py +79 -0
  39. flowforge/api/checks.py +255 -0
  40. flowforge/api/connectors.py +289 -0
  41. flowforge/api/flowmap.py +174 -0
  42. flowforge/api/runs.py +284 -0
  43. flowforge/api/scan.py +117 -0
  44. flowforge/api/security.py +31 -0
  45. flowforge/api/stats.py +80 -0
  46. flowforge/checks/__init__.py +13 -0
  47. flowforge/checks/models.py +41 -0
  48. flowforge/checks/probes.py +359 -0
  49. flowforge/cli.py +108 -0
  50. flowforge/connectors/__init__.py +0 -0
  51. flowforge/connectors/catalog.py +84 -0
  52. flowforge/connectors/models.py +149 -0
  53. flowforge/connectors/presets.py +192 -0
  54. flowforge/connectors/registry.py +85 -0
  55. flowforge/connectors/runtime.py +47 -0
  56. flowforge/connectors/secrets.py +53 -0
  57. flowforge/example_data.py +247 -0
  58. flowforge/flow/__init__.py +328 -0
  59. flowforge/flow/advice.py +132 -0
  60. flowforge/flow/curly.py +884 -0
  61. flowforge/flow/index.py +116 -0
  62. flowforge/flow/order.py +181 -0
  63. flowforge/flow/reach.py +145 -0
  64. flowforge/flow/rewrite.py +146 -0
  65. flowforge/flowmap/__init__.py +1 -0
  66. flowforge/flowmap/diff.py +145 -0
  67. flowforge/flowmap/fixture.py +283 -0
  68. flowforge/flowmap/models.py +258 -0
  69. flowforge/flowmap/planner.py +131 -0
  70. flowforge/flowmap/service.py +379 -0
  71. flowforge/flowmap/validate.py +228 -0
  72. flowforge/home.py +58 -0
  73. flowforge/main.py +137 -0
  74. flowforge/nodes/__init__.py +3 -0
  75. flowforge/nodes/base.py +74 -0
  76. flowforge/nodes/check_node.py +50 -0
  77. flowforge/nodes/http_node.py +161 -0
  78. flowforge/nodes/llm_node.py +158 -0
  79. flowforge/nodes/local_node.py +164 -0
  80. flowforge/nodes/mcp_node.py +243 -0
  81. flowforge/nodes/mock_node.py +40 -0
  82. flowforge/scan/__init__.py +6 -0
  83. flowforge/scan/facts.py +47 -0
  84. flowforge/scan/files.py +112 -0
  85. flowforge/scan/manifests.py +65 -0
  86. flowforge/scan/mcp_config.py +75 -0
  87. flowforge/scan/models.py +73 -0
  88. flowforge/scan/patterns.py +86 -0
  89. flowforge/scan/python_ast.py +246 -0
  90. flowforge/scan/scanner.py +344 -0
  91. flowforge/scan/services.py +58 -0
  92. flowforge/scan/urls.py +60 -0
  93. flowforge/scheduler/__init__.py +0 -0
  94. flowforge/scheduler/cache.py +76 -0
  95. flowforge/scheduler/critical_path.py +65 -0
  96. flowforge/scheduler/durations.py +35 -0
  97. flowforge/scheduler/executor.py +390 -0
  98. flowforge/scheduler/graph.py +108 -0
  99. flowforge/scheduler/rate_limit.py +36 -0
  100. flowforge/schema.py +81 -0
  101. flowforge/security/__init__.py +0 -0
  102. flowforge/security/finders.py +127 -0
  103. flowforge/security/findings.py +169 -0
  104. flowforge/security/logfilter.py +129 -0
  105. flowforge/spa.py +52 -0
  106. flowforge/state.py +73 -0
  107. flowforge/storage.py +481 -0
  108. flowforge/templating.py +60 -0
  109. flowforge_map-0.1.0.dist-info/METADATA +335 -0
  110. flowforge_map-0.1.0.dist-info/RECORD +113 -0
  111. flowforge_map-0.1.0.dist-info/WHEEL +4 -0
  112. flowforge_map-0.1.0.dist-info/entry_points.txt +3 -0
  113. flowforge_map-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,145 @@
1
+ """Re-check diff (D17). Re-checking never overwrites the map. It lists what changed between the
2
+ accepted version and the new one, and the user picks what to take.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from collections.abc import Mapping
8
+ from typing import Any
9
+
10
+ from flowforge.flowmap.models import Flow, FlowEdge, FlowNode, Override
11
+
12
+ CompareFields = ("kind", "title", "connector", "op", "condition", "what", "status")
13
+ TOUCHING_OPS = ("set_connector", "rename", "hide", "confirm")
14
+
15
+
16
+ def _summary(old: FlowNode, new: FlowNode) -> list[str]:
17
+ parts: list[str] = []
18
+ if old.title != new.title:
19
+ parts.append(f'"{old.title}" is now "{new.title}"')
20
+ old_model = old.op.model if old.op else None
21
+ new_model = new.op.model if new.op else None
22
+ if old_model != new_model:
23
+ parts.append(f"model {old_model or 'none'} → {new_model or 'none'}")
24
+ if old.connector != new.connector:
25
+ parts.append(f"app {old.connector} → {new.connector}")
26
+ if old.condition != new.condition and old.title == new.title:
27
+ parts.append(f"condition {old.condition} → {new.condition}")
28
+ if old.what != new.what and not parts:
29
+ parts.append("what it does changed")
30
+ return parts or ["details changed"]
31
+
32
+
33
+ def _same(old: FlowNode, new: FlowNode) -> bool:
34
+ return all(getattr(old, f) == getattr(new, f) for f in CompareFields)
35
+
36
+
37
+ def _groups(added: list[str], edges: list[FlowEdge]) -> list[list[str]]:
38
+ """Added blocks joined by lines form one change (a new route is one thing to accept, not three)."""
39
+ left, groups = list(added), []
40
+ while left:
41
+ group, queue = [], [left.pop(0)]
42
+ while queue:
43
+ node = queue.pop()
44
+ group.append(node)
45
+ for e in edges:
46
+ for a, b in ((e.source, e.target), (e.target, e.source)):
47
+ if a == node and b in left:
48
+ left.remove(b)
49
+ queue.append(b)
50
+ groups.append(group)
51
+ return groups
52
+
53
+
54
+ def diff(old: Flow, new: Flow, overrides: list[tuple[int, Override]], notes: Mapping[str, str]) -> list[dict[str, Any]]:
55
+ old_nodes = {n.id: n for n in old.nodes}
56
+ new_nodes = {n.id: n for n in new.nodes}
57
+ touched: dict[str, list[Override]] = {}
58
+ for _, o in overrides:
59
+ if o.op in TOUCHING_OPS and o.node:
60
+ touched.setdefault(o.node, []).append(o)
61
+ changes: list[dict[str, Any]] = []
62
+
63
+ def add(kind: str, title: str, nodes: list[str], node: FlowNode, **extra: Any) -> None:
64
+ ev = next((e.ref for e in node.evidence if e.ref), "")
65
+ changes.append({"id": f"c{len(changes) + 1}", "type": kind, "title": title, "nodes": nodes,
66
+ "detail": notes.get(nodes[0], ""), "evidence": ev, **extra})
67
+
68
+ added = [n.id for n in new.nodes if n.id not in old_nodes]
69
+ for group in _groups(added, new.edges):
70
+ first = new_nodes[group[0]]
71
+ more = f" (+{len(group) - 1} more)" if len(group) > 1 else ""
72
+ add("add", f"New: {first.title}{more}", group, first)
73
+
74
+ for node_id, new_node in new_nodes.items():
75
+ old_node = old_nodes.get(node_id)
76
+ if old_node is None or _same(old_node, new_node):
77
+ continue
78
+ first = _summary(old_node, new_node)[0]
79
+ title = first if old_node.title != new_node.title else f"{new_node.title}: {first}"
80
+ if node_id in touched:
81
+ add("conflict", title, [node_id], new_node,
82
+ choices=[["mine", "Keep your edit"], ["planner", "Use the Planner's"]])
83
+ else:
84
+ add("change", title, [node_id], new_node)
85
+
86
+ for node_id, old_node in old_nodes.items():
87
+ if node_id in new_nodes:
88
+ continue
89
+ if node_id in touched:
90
+ add("conflict", f'Drop "{old_node.title}"', [node_id], old_node,
91
+ choices=[["mine", "Keep it (yours)"], ["planner", "Drop it"]])
92
+ else:
93
+ add("remove", f'Drop "{old_node.title}"', [node_id], old_node)
94
+
95
+ for _, o in overrides:
96
+ if o.op == "add_node" and o.new_node is not None:
97
+ changes.append({"id": f"c{len(changes) + 1}", "type": "kept", "title": f'"{o.new_node.title}" stays',
98
+ "nodes": [o.new_node.id], "evidence": "added by you",
99
+ "detail": "Your own step. The Planner never removes steps you added."})
100
+ return changes
101
+
102
+
103
+ def default_take(changes: list[dict[str, Any]]) -> dict[str, bool | str]:
104
+ return {c["id"]: ("mine" if c["type"] == "conflict" else True) for c in changes if c["type"] != "kept"}
105
+
106
+
107
+ def apply_choices(old: Flow, new: Flow, changes: list[dict[str, Any]],
108
+ take: Mapping[str, bool | str]) -> tuple[Flow, set[str]]:
109
+ """The Planner version to accept, plus the blocks whose user edits should be dropped."""
110
+ choice = {**default_take(changes), **take}
111
+ old_nodes = {n.id: n for n in old.nodes}
112
+ nodes = {n.id: n.model_copy(deep=True) for n in new.nodes}
113
+ order = [n.id for n in new.nodes]
114
+ edges = [e.model_copy(deep=True) for e in new.edges]
115
+ drop_edits: set[str] = set()
116
+
117
+ def restore(node_id: str) -> None:
118
+ if node_id not in nodes:
119
+ order.append(node_id)
120
+ nodes[node_id] = old_nodes[node_id].model_copy(deep=True)
121
+ have = {(e.source, e.target, e.kind) for e in edges}
122
+ for e in old.edges:
123
+ if node_id in (e.source, e.target) and (e.source, e.target, e.kind) not in have:
124
+ edges.append(e.model_copy(deep=True))
125
+
126
+ for c in changes:
127
+ picked = choice.get(c["id"])
128
+ if c["type"] == "add" and picked is False:
129
+ for node_id in c["nodes"]:
130
+ nodes.pop(node_id, None)
131
+ order.remove(node_id)
132
+ elif c["type"] in ("change", "remove") and picked is False:
133
+ for node_id in c["nodes"]:
134
+ restore(node_id)
135
+ elif c["type"] == "conflict":
136
+ node_id = c["nodes"][0]
137
+ if picked == "planner":
138
+ drop_edits.add(node_id)
139
+ elif node_id not in nodes: # kept a block the Planner wanted to drop
140
+ restore(node_id)
141
+
142
+ kept_edges = [e for e in edges if e.source in nodes and e.target in nodes]
143
+ suggestions = [s for s in new.suggestions if s.node in nodes]
144
+ return Flow(kind=new.kind, nodes=[nodes[i] for i in order if i in nodes], edges=kept_edges,
145
+ suggestions=suggestions), drop_edits
@@ -0,0 +1,283 @@
1
+ """The example-mode Planner output (D17): the Baby-care shop, as drawn in docs/design/flow-map-artboards.
2
+
3
+ Version 1 is the first map. The re-check returns version 2 (two new commits): a refund webhook,
4
+ a newer Gemini model, a lower risk threshold, Hindi thank-you notes, and the unconfirmed SMS step
5
+ dropped. All of it is example data; code excerpts are invented and hold no keys.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from flowforge.flowmap.models import Analysis, Flow, Trace
13
+
14
+
15
+ def _code(ref: str, first: int, hl: int, *lines: str) -> dict[str, Any]:
16
+ return {"type": "code", "ref": ref, "first_line": first, "highlight": hl, "lines": list(lines)}
17
+
18
+
19
+ def _line(kind: str, text: str, ref: str | None = None) -> dict[str, Any]:
20
+ return {"type": kind, "text": text, "ref": ref}
21
+
22
+
23
+ def _node(id: str, kind: str, title: str, what: str, *, evidence: list[dict[str, Any]], **fields: Any) -> dict[str, Any]:
24
+ return {"id": id, "kind": kind, "title": title, "what": what, "evidence": evidence, **fields}
25
+
26
+
27
+ def _edge(source: str, target: str, kind: str = "flow", **fields: Any) -> dict[str, Any]:
28
+ return {"id": f"{source}__{target}", "source": source, "target": target, "kind": kind, **fields}
29
+
30
+
31
+ NODES_V1: list[dict[str, Any]] = [
32
+ _node("start", "start", "Payment captured", "Razorpay calls this route when a customer pays.",
33
+ connector="razorpay", evidence=[
34
+ _code("api/webhooks.py:41", 40, 41, '@router.post("/webhooks/razorpay")', "async def payment_captured(req):",
35
+ " event = verify_signature(req)"),
36
+ _line("trace", "Seen 40 times in the test run")]),
37
+ _node("fetch", "api", "Fetch order", "Loads the order and the customer for this payment.", connector="razorpay",
38
+ op={"method": "GET", "path": "/v1/orders/{id}"}, estimate_ms=180, evidence=[
39
+ _code("services/orders.py:18", 17, 18, "def load_order(order_id):", " data = rzp.order.fetch(order_id)",
40
+ " return Order.from_razorpay(data)"),
41
+ _line("trace", "40 calls in the test run · p50 180 ms")]),
42
+ _node("risky", "decision", "New customer or ₹5,000+?", "Only risky orders get a fraud score. The rest go straight on.",
43
+ condition="order.is_first or order.amount >= 500_000", evidence=[
44
+ _code("services/risk.py:27", 26, 27, "def needs_fraud_check(order):",
45
+ " return order.is_first or order.amount >= 500_000")]),
46
+ _node("score", "llm", "Score fraud risk", "Asks an LLM how likely this order is to be fraud, from 0 to 1.",
47
+ connector="gemini", op={"model": "gemini-2.0-flash"}, estimate_ms=2100, evidence=[
48
+ _code("services/risk.py:44", 43, 44, ' prompt = render("fraud.txt", order=redact(order))',
49
+ " reply = gemini.generate(prompt, temperature=0)", " return float(reply.text)"),
50
+ _line("trace", "40 calls · p50 2.1 s · 2 rate-limited")]),
51
+ _node("score_nim", "llm", "Score fraud risk", "Answers instead when Gemini fails or runs out of rate limit.",
52
+ connector="nim", op={"model": "llama-3.1-8b-instruct"}, estimate_ms=600, evidence=[
53
+ _code("config/llm.py:12", 11, 12, 'PRIMARY_LLM = "gemini"', 'FALLBACK_LLM = "nim" # when Gemini fails')]),
54
+ _node("high", "decision", "Risk ≥ 0.7?", "High-risk orders are held until a person looks at them.",
55
+ condition="score >= 0.7", evidence=[
56
+ _code("services/risk.py:57", 56, 57, " score = score_fraud(order)", " if score >= 0.7:",
57
+ " return hold_payment(order)")]),
58
+ _node("gate_hold", "gate", "Hold the payment", "Nothing happens to the money until you approve it.", evidence=[
59
+ _line("policy", "Payments always wait for you (D14). The Planner cannot remove this gate.")]),
60
+ _node("refund", "api", "Refund payment", "Refunds the payment. In test mode no real money moves.", connector="razorpay",
61
+ op={"method": "POST", "path": "/v1/payments/{id}/refund"}, estimate_ms=240, evidence=[
62
+ _code("services/payments.py:77", 76, 77, "def hold_payment(order):", " rzp.payment.refund(order.payment_id)",
63
+ " order.mark_held()")]),
64
+ _node("end_held", "end", "Order held", "The order stops here and waits for a person.", evidence=[
65
+ _code("services/payments.py:78", 78, 78, " order.mark_held()")]),
66
+ _node("fork", "fork", "Run together", "These three steps don't depend on each other, so they run at the same time.",
67
+ evidence=[_code("services/fulfil.py:12", 11, 12, "async def fulfil(order):",
68
+ " await asyncio.gather(thank_you(order), check_stock(order), recap(order))")]),
69
+ _node("note", "llm", "Write thank-you note", "Writes a short, warm note to the customer.", connector="claude",
70
+ op={"model": "claude-haiku-5-5"}, estimate_ms=1300, evidence=[
71
+ _code("services/email.py:30", 29, 30, "async def thank_you(order):",
72
+ " return await llm.write(NOTE_PROMPT, order=redact(order))"),
73
+ _line("trace", "40 calls · p50 1.3 s")]),
74
+ _node("stock", "api", "Check stock", "Checks every item is still in stock. Retries on a server error.",
75
+ connector="inventory", op={"method": "GET", "path": "/stock/{sku}"}, estimate_ms=300, evidence=[
76
+ _code("services/inventory.py:22", 21, 22, "@retry(times=3, on=ServerError)", "async def check_stock(order):",
77
+ ' return await inv.get(f"/stock/{order.sku}")')]),
78
+ _node("recap", "api", "Get recap image", "Gets the order's picture for the email. Repeat orders come from cache.",
79
+ connector="higgsfield", op={"method": "GET", "path": "/v1/renders/{order}"}, estimate_ms=1800, evidence=[
80
+ _code("services/media.py:40", 39, 40, "async def recap(order):",
81
+ " return await higgsfield.get_render(order.recap_id)"),
82
+ _line("trace", "9 calls, 31 cache hits")]),
83
+ _node("join", "join", "Wait for all", "Waits until all three are done, then carries on.", evidence=[
84
+ _code("services/fulfil.py:12", 12, 12,
85
+ " await asyncio.gather(thank_you(order), check_stock(order), recap(order))")]),
86
+ _node("gate_send", "gate", "Send to customer", "Sending email is a side effect, so you approve it first.", evidence=[
87
+ _line("policy", "Side effects always wait for you (D14). The Planner can add gates but never remove them.")]),
88
+ _node("sms", "api", "SMS receipt", "The Planner found a Twilio key name, but no code that calls Twilio.",
89
+ connector="twilio", op={"method": "POST", "path": "/Messages"}, evidence=[
90
+ _line("env_name", "TWILIO_SID (name only; values are never read)", ".env.example:7"),
91
+ _line("missing", "No call site in the repo, so it is unconfirmed instead of guessed.")]),
92
+ _node("send", "mcp", "Send email", "Sends the note and the picture through the Gmail MCP server.", connector="gmail",
93
+ op={"tool": "send_message", "read_only": False}, estimate_ms=400, evidence=[
94
+ _code("services/email.py:52", 51, 52, "async def send(order, note, image):",
95
+ ' await mcp.call("gmail", "send_message", to=order.email, body=note)')]),
96
+ _node("end", "end", "Order done", "The order is paid and checked, and the customer has their note.", evidence=[
97
+ _code("services/fulfil.py:15", 15, 15, " order.mark_done()")]),
98
+ ]
99
+
100
+ EDGES_V1: list[dict[str, Any]] = [
101
+ _edge("start", "fetch"), _edge("fetch", "risky"),
102
+ _edge("risky", "score", "branch", when="yes"), _edge("risky", "fork", "branch", when="no"),
103
+ _edge("score", "high"), _edge("score", "score_nim", "fallback", on="if Gemini fails"), _edge("score_nim", "high"),
104
+ _edge("high", "gate_hold", "branch", when="yes"), _edge("high", "fork", "branch", when="no"),
105
+ _edge("gate_hold", "refund"), _edge("refund", "end_held"),
106
+ _edge("fork", "note"), _edge("fork", "stock"), _edge("fork", "recap"),
107
+ _edge("stock", "stock", "retry", max=3, on="5xx"),
108
+ _edge("note", "join"), _edge("stock", "join"), _edge("recap", "join"),
109
+ _edge("join", "gate_send"), _edge("join", "sms"), _edge("gate_send", "send"), _edge("send", "end"),
110
+ ]
111
+
112
+ SUGGESTIONS_V1 = [{"node": "score", "connector": "nim", "text": (
113
+ "In the test run NVIDIA NIM answered 3.4× faster (0.6 s vs 2.1 s) and gave the same verdict on "
114
+ "40 of 40 orders. Gemini also hit its rate limit twice.")}]
115
+
116
+ # the approved artboard's layout, so the first view matches the design (Re-tidy uses dagre)
117
+ POSITIONS: dict[str, tuple[float, float]] = {
118
+ "start": (32, 386), "fetch": (252, 378), "risky": (472, 366), "score": (660, 158), "score_nim": (660, 38),
119
+ "high": (892, 146), "gate_hold": (1068, 158), "refund": (1292, 158), "end_held": (1532, 166),
120
+ "fork": (1068, 280), "note": (1124, 268), "stock": (1124, 378), "recap": (1124, 488), "join": (1384, 280),
121
+ "gate_send": (1444, 378), "sms": (1444, 598), "send": (1668, 330), "slack": (1668, 436), "end": (1908, 386),
122
+ "r_start": (32, 596), "r_mark": (252, 588), "r_end": (506, 596),
123
+ }
124
+
125
+
126
+ def _history(p50: float, fails: tuple[int, ...] = ()) -> list[dict[str, float | bool]]:
127
+ wobble = (0.9, 1.1, 0.95, 1.2, 0.85, 1.0, 1.05, 0.92, 1.3, 0.97, 1.08, 0.9, 1.15, 1.0)
128
+ return [{"ms": round(p50 * w), "ok": i not in fails} for i, w in enumerate(wobble)]
129
+
130
+
131
+ STATS: dict[str, dict[str, Any]] = {
132
+ "fetch": {"p50_ms": 180, "summary": "p50 180 ms · no failures", "history": _history(180)},
133
+ "score": {"p50_ms": 2100, "summary": "p50 2.1 s · 2 rate-limited in 40", "history": _history(2100, (5, 11))},
134
+ "score_nim": {"p50_ms": 600, "summary": "p50 0.6 s · answered twice", "history": _history(600)},
135
+ "refund": {"p50_ms": 240, "summary": "p50 240 ms · ran once", "history": _history(240)},
136
+ "note": {"p50_ms": 1300, "summary": "p50 1.3 s · no failures", "history": _history(1300)},
137
+ "stock": {"p50_ms": 300, "summary": "p50 300 ms · 3 retries in 40", "history": _history(300, (5,))},
138
+ "recap": {"p50_ms": 1800, "summary": "p50 1.8 s · 31 of 40 from cache", "history": _history(1800)},
139
+ "send": {"p50_ms": 400, "summary": "p50 400 ms · no failures", "history": _history(400)},
140
+ }
141
+
142
+ TRACE: dict[str, Any] = {
143
+ "label": "Test order #17", "branches": {"risky": "yes", "high": "no"}, "calls": 9, "cached": 1,
144
+ "steps": [
145
+ {"node": "start", "start_ms": 0, "duration_ms": 150, "outcome": "ok"},
146
+ {"node": "fetch", "start_ms": 500, "duration_ms": 180, "outcome": "ok", "answered_by": "razorpay"},
147
+ {"node": "risky", "start_ms": 1030, "duration_ms": 40, "outcome": "ok"},
148
+ {"node": "score", "start_ms": 1420, "duration_ms": 1300, "outcome": "failed", "answered_by": "gemini",
149
+ "attempts": 3, "note": "429 rate-limited · 3 tries"},
150
+ {"node": "score_nim", "start_ms": 3070, "duration_ms": 600, "outcome": "ok", "answered_by": "nim", "tokens": 410},
151
+ {"node": "high", "start_ms": 4020, "duration_ms": 40, "outcome": "ok"},
152
+ {"node": "fork", "start_ms": 4410, "duration_ms": 10, "outcome": "ok"},
153
+ {"node": "note", "start_ms": 4770, "duration_ms": 1600, "outcome": "ok", "answered_by": "claude", "tokens": 620},
154
+ {"node": "stock", "start_ms": 4770, "duration_ms": 1220, "outcome": "ok", "answered_by": "inventory", "attempts": 2,
155
+ "note": "500 · retried once"},
156
+ {"node": "recap", "start_ms": 4770, "duration_ms": 150, "outcome": "cached", "note": "from cache"},
157
+ {"node": "join", "start_ms": 6720, "duration_ms": 10, "outcome": "ok"},
158
+ {"node": "gate_send", "start_ms": 7080, "duration_ms": 0, "outcome": "waiting"},
159
+ {"node": "send", "start_ms": 7430, "duration_ms": 650, "outcome": "ok", "answered_by": "gmail"},
160
+ {"node": "end", "start_ms": 8430, "duration_ms": 150, "outcome": "ok"},
161
+ ],
162
+ }
163
+
164
+ STAGES = [
165
+ {"title": "Read the repo", "duration_ms": 2600, "doing": "Reading files", "done": "142 files · 9 routes · 2 webhooks · 11 env names"},
166
+ {"title": "Run the app in test mode", "duration_ms": 2300, "doing": "Replaying test orders",
167
+ "done": "Sandbox · 40 test orders · nothing real touched"},
168
+ {"title": "Trace every outside call", "duration_ms": 2600, "doing": "Watching calls",
169
+ "done": "7 apps your code calls · 2 connected apps left off"},
170
+ {"title": "Draft the flow", "duration_ms": 4400, "doing": "Drawing the map", "done": "18 steps · 2 decisions · 1 fork · 1 fallback"},
171
+ {"title": "Check it", "duration_ms": 2400, "doing": "Validating", "done": "No loops · every app exists · 1 dropped · 1 unconfirmed"},
172
+ {"title": "Ready for you", "duration_ms": 0, "doing": "", "done": "Map saved. Your edits stay on top of it."},
173
+ ]
174
+
175
+ LOG = [
176
+ (200, "read", "api/webhooks.py → POST /webhooks/razorpay"),
177
+ (800, "read", "services/risk.py → gemini.generate(), 2 branches"),
178
+ (1400, "read", "services/fulfil.py → asyncio.gather of 3 tasks"),
179
+ (1900, "read", ".env.example → 11 names, values never read"),
180
+ (2400, "read", "fact sheet sha256:9f2c…e41 · 0 secrets inside"),
181
+ (2900, "run", "sandbox up · Razorpay test mode · mail to a sink"),
182
+ (4300, "run", "40 test orders replayed, 0 errors"),
183
+ (5200, "trace", "razorpay.orders.fetch · 40× · p50 180 ms"),
184
+ (5800, "trace", "gemini.generate · 40× · p50 2.1 s · 2× 429"),
185
+ (6400, "trace", "nim fallback · 2× · p50 0.6 s"),
186
+ (7100, "trace", "twilio · 0 calls, key name only"),
187
+ (7450, "trace", "Sirius, Fetch: connected, never called · left off"),
188
+ (7800, "draft", "proposed 19 steps and 23 lines"),
189
+ (10600, "draft", "tip: NVIDIA NIM fits fraud scoring better than Gemini"),
190
+ (12200, "check", 'dropped "Send WhatsApp update": notify.py:12 not found'),
191
+ (12900, "check", '"SMS receipt" unconfirmed: no call site'),
192
+ (13600, "check", "gates in front of every payment and message"),
193
+ (14200, "done", "map ready · 18 steps"),
194
+ ]
195
+
196
+ FILES = ["api/webhooks.py", "api/orders.py", "services/orders.py", "services/risk.py", "services/payments.py",
197
+ "services/fulfil.py", "services/email.py", "services/inventory.py", "services/media.py",
198
+ "services/notify.py", "config/llm.py", "config/settings.py", ".env.example", "tests/test_risk.py",
199
+ "web/checkout.tsx"]
200
+
201
+ CALLS = [
202
+ {"app": "razorpay", "count": 80, "meta": "webhook + 2 endpoints"},
203
+ {"app": "gemini", "count": 40, "meta": "p50 2.1 s · 2× 429"},
204
+ {"app": "nim", "count": 2, "meta": "fallback · p50 0.6 s"},
205
+ {"app": "claude", "count": 40, "meta": "p50 1.3 s"},
206
+ {"app": "inventory", "count": 43, "meta": "3 retries"},
207
+ {"app": "higgsfield", "count": 9, "meta": "31 from cache"},
208
+ {"app": "gmail", "count": 40, "meta": "send_message"},
209
+ {"app": "twilio", "count": 0, "meta": "key name only", "none": True},
210
+ ]
211
+
212
+ DROPPED = [{"title": "Send WhatsApp update", "reason": "Its evidence, services/notify.py:12, isn't in the repo."}]
213
+
214
+
215
+ def _v2_nodes() -> list[dict[str, Any]]:
216
+ nodes = [dict(n) for n in NODES_V1 if n["id"] != "sms"]
217
+ for n in nodes:
218
+ if n["id"] == "score":
219
+ n["op"] = {"model": "gemini-2.5-flash"}
220
+ n["evidence"] = [_code("config/llm.py:9", 9, 9, 'GEMINI_MODEL = "gemini-2.5-flash"'), *n["evidence"]]
221
+ if n["id"] == "high":
222
+ n["title"] = "Risk ≥ 0.6?"
223
+ n["condition"] = "score >= 0.6"
224
+ n["evidence"] = [_code("services/risk.py:57", 56, 57, " score = score_fraud(order)", " if score >= 0.6:",
225
+ " return hold_payment(order)")]
226
+ if n["id"] == "note":
227
+ n["what"] = "Writes a short, warm note to the customer, in English or Hindi."
228
+ n["evidence"] = [_code("services/email.py:33", 32, 33, " lang = order.customer.language",
229
+ " return await llm.write(NOTE_PROMPT, order=redact(order), language=lang)")]
230
+ nodes += [
231
+ _node("r_start", "start", "Refund received", "Razorpay calls this route when a refund goes through.",
232
+ connector="razorpay", evidence=[_code("api/webhooks.py:58", 57, 58, '@router.post("/webhooks/razorpay/refund")',
233
+ "async def refund_received(req):")]),
234
+ _node("r_mark", "code", "Mark order refunded", "Updates the order in your database. No outside call.", evidence=[
235
+ _code("services/refunds.py:14", 13, 14, "def on_refund(event):", " Order.get(event.order_id).mark_refunded()")]),
236
+ _node("r_end", "end", "Refund logged", "The refund is recorded.", evidence=[
237
+ _code("services/refunds.py:15", 15, 15, ' log.info("refund recorded")')]),
238
+ ]
239
+ return nodes
240
+
241
+
242
+ def _v2_edges() -> list[dict[str, Any]]:
243
+ edges = [e for e in EDGES_V1 if e["target"] != "sms"]
244
+ return edges + [_edge("r_start", "r_mark"), _edge("r_mark", "r_end")]
245
+
246
+
247
+ NOTES_V2 = {
248
+ "r_start": "Three new steps on a new route. No LLM in it, so nothing is sent out.",
249
+ "score": "The model name changed in config. Your NIM fallback stays as it is.",
250
+ "high": "More orders will stop at \"Hold the payment\" and wait for you.",
251
+ "sms": "It was unconfirmed, and still no code calls Twilio.",
252
+ "note": "Notes can now be in Hindi. In the test run llama3.2 wrote 6 of 10 Hindi notes well; Claude wrote 10 of 10.",
253
+ }
254
+
255
+
256
+ def flow_v1() -> Flow:
257
+ return Flow.model_validate({"nodes": NODES_V1, "edges": EDGES_V1, "suggestions": SUGGESTIONS_V1})
258
+
259
+
260
+ def flow_v2() -> Flow:
261
+ return Flow.model_validate({"nodes": _v2_nodes(), "edges": _v2_edges(), "suggestions": SUGGESTIONS_V1})
262
+
263
+
264
+ def trace() -> Trace:
265
+ return Trace.model_validate(TRACE)
266
+
267
+
268
+ def analysis(recheck: bool = False) -> Analysis:
269
+ return Analysis.model_validate({
270
+ "stages": STAGES, "log": LOG, "files": FILES, "calls": CALLS, "dropped": DROPPED, "stats": STATS,
271
+ "notes": NOTES_V2 if recheck else {}, "positions": POSITIONS})
272
+
273
+
274
+ # the example project's own edits (D17): the thank-you note moved to Ollama, and a Slack post added by hand
275
+ EXAMPLE_OVERRIDES: list[dict[str, Any]] = [
276
+ {"op": "set_connector", "node": "note", "connector": "ollama"},
277
+ {"op": "add_node", "new_node": {
278
+ "id": "slack", "kind": "mcp", "title": "Post to #orders", "what": "Tells the team about each new order.",
279
+ "connector": "slack", "op": {"tool": "chat.postMessage", "read_only": False}, "added_by": "user",
280
+ "evidence": [_line("user", "Added by you. Your code has no Slack call yet, so test-run replays skip it.")]}},
281
+ {"op": "add_edge", "node": "slack", "source": "gate_send"},
282
+ {"op": "add_edge", "node": "end", "source": "slack"},
283
+ ]