patchr 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.
- apps/__init__.py +2 -0
- apps/api/__init__.py +2 -0
- apps/api/main.py +652 -0
- apps/benchmarks/__init__.py +1 -0
- apps/benchmarks/main.py +20 -0
- apps/sandbox/__init__.py +1 -0
- apps/sandbox/main.py +20 -0
- apps/worker/__init__.py +2 -0
- apps/worker/main.py +15 -0
- apps/worker/verify.py +14 -0
- patchr/__init__.py +12 -0
- patchr/sdk/__init__.py +20 -0
- patchr/sdk/client.py +12 -0
- patchr-0.1.0.dist-info/METADATA +137 -0
- patchr-0.1.0.dist-info/RECORD +116 -0
- patchr-0.1.0.dist-info/WHEEL +5 -0
- patchr-0.1.0.dist-info/entry_points.txt +5 -0
- patchr-0.1.0.dist-info/licenses/LICENSE +17 -0
- patchr-0.1.0.dist-info/top_level.txt +3 -0
- picux/__init__.py +6 -0
- picux/agents/__init__.py +5 -0
- picux/agents/registry.py +204 -0
- picux/api/__init__.py +5 -0
- picux/api/service.py +5075 -0
- picux/audit/__init__.py +31 -0
- picux/audit/activity.py +97 -0
- picux/audit/observability.py +55 -0
- picux/audit/verification/__init__.py +21 -0
- picux/audit/verification/ledger.py +633 -0
- picux/benchmarks/__init__.py +5 -0
- picux/benchmarks/local.py +286 -0
- picux/config.py +140 -0
- picux/contracts/__init__.py +22 -0
- picux/contracts/handshake.py +122 -0
- picux/contracts/integration.py +385 -0
- picux/contracts/openapi.py +187 -0
- picux/contracts/protocol_map.py +152 -0
- picux/contracts/routes.py +980 -0
- picux/contracts/schema_catalog.py +125 -0
- picux/core/__init__.py +17 -0
- picux/core/models.py +148 -0
- picux/core/router.py +131 -0
- picux/core/runtime.py +42 -0
- picux/core/state_machine.py +38 -0
- picux/domains/__init__.py +2 -0
- picux/domains/bridge/HostRun.py +1104 -0
- picux/domains/bridge/__init__.py +6 -0
- picux/domains/bridge/engine.py +345 -0
- picux/domains/hunt/__init__.py +6 -0
- picux/domains/hunt/engine.py +307 -0
- picux/domains/hunt/models.py +88 -0
- picux/domains/pay/__init__.py +16 -0
- picux/domains/pay/adapters.py +607 -0
- picux/domains/pay/engine.py +950 -0
- picux/domains/pay/models.py +95 -0
- picux/domains/proxy/__init__.py +5 -0
- picux/domains/proxy/engine.py +466 -0
- picux/domains/resolve/__init__.py +5 -0
- picux/domains/resolve/engine.py +546 -0
- picux/orchestrator/__init__.py +3 -0
- picux/orchestrator/engine.py +2840 -0
- picux/portals/__init__.py +17 -0
- picux/portals/templates.py +272 -0
- picux/protocols/__init__.py +1 -0
- picux/protocols/a2a/__init__.py +6 -0
- picux/protocols/a2a/client.py +51 -0
- picux/protocols/a2a/envelope.py +132 -0
- picux/protocols/mcp/__init__.py +7 -0
- picux/protocols/mcp/client.py +69 -0
- picux/protocols/mcp/contract.py +67 -0
- picux/protocols/mcp/server.py +76 -0
- picux/sandbox/__init__.py +6 -0
- picux/sandbox/midnight_arbitrage.py +215 -0
- picux/sandbox/models.py +90 -0
- picux/sdk/__init__.py +13 -0
- picux/sdk/client.py +768 -0
- picux/sdk/external.py +245 -0
- picux/security/__init__.py +18 -0
- picux/security/auth.py +86 -0
- picux/security/config_validator.py +58 -0
- picux/security/policy.py +158 -0
- picux/security/secrets.py +144 -0
- picux/signals/__init__.py +1 -0
- picux/signals/community/__init__.py +24 -0
- picux/signals/community/adapters/__init__.py +7 -0
- picux/signals/community/adapters/reddit.py +37 -0
- picux/signals/community/adapters/shopify.py +23 -0
- picux/signals/community/adapters/web.py +23 -0
- picux/signals/community/disambiguation.py +51 -0
- picux/signals/community/intake.py +227 -0
- picux/signals/community/models.py +102 -0
- picux/signals/community/rules.py +91 -0
- picux/signals/community/scoring.py +64 -0
- picux/storage/__init__.py +41 -0
- picux/storage/agents.py +50 -0
- picux/storage/cases.py +440 -0
- picux/storage/channels.py +476 -0
- picux/storage/connectors.py +411 -0
- picux/storage/envelopes.py +137 -0
- picux/storage/escrows.py +168 -0
- picux/storage/events.py +989 -0
- picux/storage/keyspace.py +60 -0
- picux/storage/mandates.py +107 -0
- picux/storage/portals.py +222 -0
- picux/storage/postgres.py +2049 -0
- picux/storage/providers.py +148 -0
- picux/storage/proxy.py +231 -0
- picux/storage/receipts.py +131 -0
- picux/storage/signals.py +147 -0
- picux/storage/tasks.py +179 -0
- picux/tools/__init__.py +11 -0
- picux/tools/shared.py +2048 -0
- picux/verification/__init__.py +5 -0
- picux/verification/rollout.py +183 -0
- picux/workflows/__init__.py +5 -0
- picux/workflows/templates.py +74 -0
|
@@ -0,0 +1,2840 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from picux.core import ProtocolTaskStatus
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PicuxMultiAgentOrchestrator:
|
|
13
|
+
"""Core Picux multi-agent orchestrator used by API, SDK, and test clients."""
|
|
14
|
+
|
|
15
|
+
domains = {"hunt", "bridge", "resolve", "proxy", "pay"}
|
|
16
|
+
|
|
17
|
+
def __init__(self, host: Any) -> None:
|
|
18
|
+
self.host = host
|
|
19
|
+
|
|
20
|
+
def run(self, payload: dict[str, Any], onEvent: Any | None = None) -> dict[str, Any]:
|
|
21
|
+
host = self.host
|
|
22
|
+
resumeAction = self._followUpAction(payload)
|
|
23
|
+
request = str(payload.get("request", payload.get("goal", payload.get("query", ""))) or "").strip()
|
|
24
|
+
requestedConversationId = self._conversationId(payload)
|
|
25
|
+
if not resumeAction and requestedConversationId and self._hasConversationState(host, requestedConversationId):
|
|
26
|
+
resumeAction = self._followUpTextAction(request)
|
|
27
|
+
if not request:
|
|
28
|
+
request = self._resumeRequestLabel(resumeAction, payload) if resumeAction else "Find my best-fit options and return an auditable Picux proof card."
|
|
29
|
+
|
|
30
|
+
route = self._route(request, payload)
|
|
31
|
+
start = time.time()
|
|
32
|
+
trace: list[dict[str, Any]] = []
|
|
33
|
+
agentConversation = [f"[User] {request}"]
|
|
34
|
+
clientId = str(payload.get("clientId", payload.get("userId", "externalClient")) or "externalClient")
|
|
35
|
+
channel = str(payload.get("channel", "api") or "api")
|
|
36
|
+
metadata = payload.get("metadata", {}) if isinstance(payload.get("metadata"), dict) else {}
|
|
37
|
+
|
|
38
|
+
def stream(kind: str, data: dict[str, Any]) -> None:
|
|
39
|
+
if onEvent is None:
|
|
40
|
+
return
|
|
41
|
+
try:
|
|
42
|
+
onEvent({"type": kind, **data})
|
|
43
|
+
except Exception:
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
def event(phase: str, name: str, status: str, msg: str, data: dict[str, Any] | None = None) -> None:
|
|
47
|
+
traceEvent = {
|
|
48
|
+
"seq": len(trace) + 1,
|
|
49
|
+
"phase": phase,
|
|
50
|
+
"name": name,
|
|
51
|
+
"status": status,
|
|
52
|
+
"msg": msg,
|
|
53
|
+
"elapsedMs": int((time.time() - start) * 1000),
|
|
54
|
+
"data": data or {},
|
|
55
|
+
}
|
|
56
|
+
trace.append(traceEvent)
|
|
57
|
+
stream("trace", {"event": traceEvent})
|
|
58
|
+
|
|
59
|
+
if resumeAction:
|
|
60
|
+
route = self._resumeRoute(resumeAction)
|
|
61
|
+
nlp = host.tools.nlp({"request": request, "context": payload.get("context", {}), "taskId": requestedConversationId})
|
|
62
|
+
nlp = {
|
|
63
|
+
**nlp,
|
|
64
|
+
"isFollowUp": True,
|
|
65
|
+
"route": route,
|
|
66
|
+
"followUp": {
|
|
67
|
+
"action": resumeAction,
|
|
68
|
+
"conversationId": requestedConversationId,
|
|
69
|
+
"itemId": self._selectedItemId(payload),
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
toolResults = {"nlpTool": nlp}
|
|
73
|
+
event(
|
|
74
|
+
"tools",
|
|
75
|
+
"nlpTool",
|
|
76
|
+
"followUp",
|
|
77
|
+
"NLPTool classified this payload as a conversation resume request.",
|
|
78
|
+
{"intent": nlp.get("intent", {}), "route": route, "isFollowUp": True, "followUp": nlp["followUp"]},
|
|
79
|
+
)
|
|
80
|
+
return self._runResume(
|
|
81
|
+
payload=payload,
|
|
82
|
+
request=request,
|
|
83
|
+
clientId=clientId,
|
|
84
|
+
channel=channel,
|
|
85
|
+
metadata=metadata,
|
|
86
|
+
route=route,
|
|
87
|
+
toolResults=toolResults,
|
|
88
|
+
trace=trace,
|
|
89
|
+
agentConversation=agentConversation,
|
|
90
|
+
event=event,
|
|
91
|
+
stream=stream,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
nlp = host.tools.nlp({"request": request, "context": payload.get("context", {}), "taskId": payload.get("taskId", "")})
|
|
95
|
+
route = [item for item in nlp.get("route", []) if item in self.domains] or route
|
|
96
|
+
toolResults: dict[str, Any] = {"nlpTool": nlp}
|
|
97
|
+
event(
|
|
98
|
+
"tools",
|
|
99
|
+
"nlpTool",
|
|
100
|
+
"classified",
|
|
101
|
+
"NLPTool classified the user request and selected the orchestration route.",
|
|
102
|
+
{"intent": nlp.get("intent", {}), "route": route, "isFollowUp": nlp.get("isFollowUp", False)},
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
taskResult = host.createTask(
|
|
106
|
+
{
|
|
107
|
+
"userId": clientId,
|
|
108
|
+
"goal": request,
|
|
109
|
+
"domain": route[0] if route else "hunt",
|
|
110
|
+
"channel": channel,
|
|
111
|
+
"inData": {"request": request, "route": route},
|
|
112
|
+
"meta": {**metadata, "clientId": clientId, "channel": channel, "conversationId": requestedConversationId},
|
|
113
|
+
}
|
|
114
|
+
)
|
|
115
|
+
task = taskResult.get("task", {})
|
|
116
|
+
taskId = str(task.get("taskId", "") or self._stableId("task", {"request": request, "route": route}))
|
|
117
|
+
conversationId = requestedConversationId or taskId
|
|
118
|
+
if taskId:
|
|
119
|
+
nextMeta = {**(task.get("meta", {}) if isinstance(task.get("meta"), dict) else {}), "clientId": clientId, "channel": channel, "conversationId": conversationId}
|
|
120
|
+
savedTask = host.taskBook.updateTask(taskId, {"meta": nextMeta})
|
|
121
|
+
if savedTask.get("ok"):
|
|
122
|
+
task = savedTask["task"]
|
|
123
|
+
event("orchestrator", "taskCreate", "created", "Picux task created from client input.", {"taskId": taskId, "clientId": clientId, "route": route})
|
|
124
|
+
|
|
125
|
+
results: dict[str, Any] = {"tools": toolResults}
|
|
126
|
+
selectedVendor = ""
|
|
127
|
+
selectedAmount = 0.0
|
|
128
|
+
mandate: dict[str, Any] = {}
|
|
129
|
+
proxyCreated = False
|
|
130
|
+
localServiceFlow = False
|
|
131
|
+
localServiceContacts: dict[str, dict[str, Any]] = {}
|
|
132
|
+
|
|
133
|
+
index = 0
|
|
134
|
+
while index < len(route):
|
|
135
|
+
domain = route[index]
|
|
136
|
+
if domain == "hunt":
|
|
137
|
+
if self._isLocalServiceRequest(request, payload, nlp):
|
|
138
|
+
localServiceFlow = True
|
|
139
|
+
mapResult = self._verifiedLocalServiceMapResult(host, request, payload, nlp) or host.tools.searchMap(
|
|
140
|
+
{
|
|
141
|
+
"request": request,
|
|
142
|
+
"entities": nlp.get("entities", {}),
|
|
143
|
+
"allowNetwork": payload.get("allowNetwork", False),
|
|
144
|
+
"places": payload.get("places", payload.get("mapResults", [])),
|
|
145
|
+
"entity": payload.get("entity", payload.get("service", "")),
|
|
146
|
+
"location": payload.get("location", metadata.get("location", "")),
|
|
147
|
+
"radiusKm": payload.get("radiusKm", payload.get("distanceKm", "")),
|
|
148
|
+
"googlePlacesApiKey": payload.get("googlePlacesApiKey", metadata.get("googlePlacesApiKey", "")),
|
|
149
|
+
"googleMapsApiKey": payload.get("googleMapsApiKey", metadata.get("googleMapsApiKey", "")),
|
|
150
|
+
"googlePlacesBaseUrl": payload.get("googlePlacesBaseUrl", metadata.get("googlePlacesBaseUrl", "")),
|
|
151
|
+
}
|
|
152
|
+
)
|
|
153
|
+
mapOffers = self._placesToOffers(request, mapResult)
|
|
154
|
+
localServiceContacts = self._localServiceContactRefs(mapResult, mapOffers)
|
|
155
|
+
self._cacheLocalServiceLeads(host, localServiceContacts)
|
|
156
|
+
browser = self._mapBrowserTelemetry(mapResult, mapOffers, country=str(payload.get("country", metadata.get("country", "")) or ""))
|
|
157
|
+
toolResults["mapTool"] = self._publicMapResult(mapResult)
|
|
158
|
+
event(
|
|
159
|
+
"tools",
|
|
160
|
+
"mapTool",
|
|
161
|
+
str(mapResult.get("status", "searched")),
|
|
162
|
+
"MapTool searched source-bound local places for HUNT.",
|
|
163
|
+
{
|
|
164
|
+
"provider": mapResult.get("provider", ""),
|
|
165
|
+
"entity": mapResult.get("query", {}).get("entity", "") if isinstance(mapResult.get("query"), dict) else "",
|
|
166
|
+
"location": mapResult.get("query", {}).get("location", "") if isinstance(mapResult.get("query"), dict) else "",
|
|
167
|
+
"radiusKm": mapResult.get("query", {}).get("radiusKm", 0) if isinstance(mapResult.get("query"), dict) else 0,
|
|
168
|
+
"placeCount": len(mapResult.get("places", [])) if isinstance(mapResult.get("places"), list) else 0,
|
|
169
|
+
"offerCount": len(mapOffers),
|
|
170
|
+
"errors": mapResult.get("errors", []),
|
|
171
|
+
},
|
|
172
|
+
)
|
|
173
|
+
else:
|
|
174
|
+
browser = host.tools.readBrowser(
|
|
175
|
+
{
|
|
176
|
+
"request": request,
|
|
177
|
+
"entities": nlp.get("entities", {}),
|
|
178
|
+
"urls": payload.get("urls", []),
|
|
179
|
+
"marketplaces": payload.get("marketplaces", payload.get("sources", [])),
|
|
180
|
+
"allowNetwork": payload.get("allowNetwork", False),
|
|
181
|
+
"country": payload.get("country", metadata.get("country", "")),
|
|
182
|
+
"region": payload.get("region", metadata.get("region", "")),
|
|
183
|
+
"locale": payload.get("locale", metadata.get("locale", "")),
|
|
184
|
+
"location": payload.get("location", metadata.get("location", "")),
|
|
185
|
+
"currency": payload.get("currency", metadata.get("currency", "")),
|
|
186
|
+
"budgetCurrency": payload.get("budgetCurrency", metadata.get("budgetCurrency", "")),
|
|
187
|
+
"fxRates": payload.get("fxRates", metadata.get("fxRates", {})),
|
|
188
|
+
}
|
|
189
|
+
)
|
|
190
|
+
toolResults["browserReader"] = browser
|
|
191
|
+
event(
|
|
192
|
+
"tools",
|
|
193
|
+
"browserReader",
|
|
194
|
+
str(browser.get("status", "read")),
|
|
195
|
+
"BrowserReader attempted source-bound discovery without fabricating offers.",
|
|
196
|
+
{
|
|
197
|
+
"targetCount": len(browser.get("searchTargets", [])),
|
|
198
|
+
"observationCount": len(browser.get("observations", [])),
|
|
199
|
+
"offerCount": len(browser.get("offers", [])),
|
|
200
|
+
"errorCount": len(browser.get("errors", [])),
|
|
201
|
+
"adapter": browser.get("adapter", ""),
|
|
202
|
+
"rendered": browser.get("rendered", {}),
|
|
203
|
+
"marketplaceSet": browser.get("marketplaceSet", {}),
|
|
204
|
+
"sourceSearches": [
|
|
205
|
+
{
|
|
206
|
+
"source": item.get("source", ""),
|
|
207
|
+
"search": item.get("search", {}),
|
|
208
|
+
"listingCount": item.get("listingCount", 0),
|
|
209
|
+
}
|
|
210
|
+
for item in browser.get("marketplaceAttempts", [])
|
|
211
|
+
if isinstance(item, dict)
|
|
212
|
+
],
|
|
213
|
+
"attemptedSources": [item.get("source", "") for item in browser.get("marketplaceAttempts", []) if isinstance(item, dict)],
|
|
214
|
+
"failedSources": [item.get("source", "") for item in browser.get("marketplaceAttempts", []) if isinstance(item, dict) and not item.get("ok")],
|
|
215
|
+
},
|
|
216
|
+
)
|
|
217
|
+
huntPayload = self._huntPayload(request, payload, taskId=taskId, browser=browser, clientId=clientId, channel=channel)
|
|
218
|
+
event("hunt", "discover.request", "sent", "HUNT received discovery criteria and source telemetry.", {"endpoint": "/v1/hunt/discover"})
|
|
219
|
+
hunt = host.huntDiscover(huntPayload)
|
|
220
|
+
results["hunt"] = hunt
|
|
221
|
+
selected = hunt.get("selected", {}) if isinstance(hunt.get("selected"), dict) else {}
|
|
222
|
+
selectedVendor = str(selected.get("sourceId", "") or "")
|
|
223
|
+
selectedAmount = float(selected.get("netUsd", 0.0) or 0.0)
|
|
224
|
+
status = "selected" if hunt.get("ok") else "needsSources"
|
|
225
|
+
msg = f"HUNT selected {selectedVendor}." if selectedVendor else "HUNT could not rank options without source-bound offer telemetry."
|
|
226
|
+
sourceResponse = hunt.get("sourceResponse", {}) if isinstance(hunt.get("sourceResponse"), dict) else {}
|
|
227
|
+
event("hunt", "discover.response", status, msg, {"selected": selected, "candidateCount": len(hunt.get("candidates", [])), "sourceResponse": sourceResponse})
|
|
228
|
+
huntStream = hunt.get("stream", []) if isinstance(hunt.get("stream"), list) else []
|
|
229
|
+
aggregate = hunt.get("sourceAggregate", {}) if isinstance(hunt.get("sourceAggregate"), dict) else {}
|
|
230
|
+
if huntStream:
|
|
231
|
+
event(
|
|
232
|
+
"hunt",
|
|
233
|
+
"sourceAggregate.stream",
|
|
234
|
+
"returned",
|
|
235
|
+
"HUNT streamed aggregated source and result events back to the orchestrator or handoff source.",
|
|
236
|
+
{
|
|
237
|
+
"eventCount": len(huntStream),
|
|
238
|
+
"aggregateId": aggregate.get("aggregateId", ""),
|
|
239
|
+
"delivery": aggregate.get("delivery", {}),
|
|
240
|
+
"handoff": hunt.get("handoff", {}),
|
|
241
|
+
},
|
|
242
|
+
)
|
|
243
|
+
for streamEvent in huntStream[:50]:
|
|
244
|
+
if isinstance(streamEvent, dict):
|
|
245
|
+
stream("domainStream", {"domain": "hunt", "event": streamEvent})
|
|
246
|
+
agentConversation.append("[Hunt] Collecting signals and ranking...")
|
|
247
|
+
huntSummary = self._huntConversationSummary(request, hunt)
|
|
248
|
+
if huntSummary:
|
|
249
|
+
agentConversation.append(f"[Hunt] {huntSummary}")
|
|
250
|
+
stream("conversation", {"line": agentConversation[-1]})
|
|
251
|
+
if aggregate:
|
|
252
|
+
delivery = aggregate.get("delivery", {}) if isinstance(aggregate.get("delivery"), dict) else {}
|
|
253
|
+
agentConversation.append("[Hunt] Sending aggregated result back to source.")
|
|
254
|
+
stream("conversation", {"line": agentConversation[-1]})
|
|
255
|
+
agentConversation.append("[Hunt] Selected the best source-bound match for review." if selectedVendor else "[Hunt] I could not find a source-backed match under your filters yet.")
|
|
256
|
+
stream("conversation", {"line": agentConversation[-1]})
|
|
257
|
+
if localServiceFlow and hunt.get("ok"):
|
|
258
|
+
self._ensureRouteAfter(route, after="hunt", domains=["bridge", "proxy"])
|
|
259
|
+
elif domain == "bridge":
|
|
260
|
+
installed = host._ensureReadyConnectors()
|
|
261
|
+
localServiceContact = self._selectedLocalServiceContact(results.get("hunt", {}), localServiceContacts)
|
|
262
|
+
if localServiceContact:
|
|
263
|
+
event("bridge", "connectorMatch.request", "sent", "BRIDGE checked client-registered voice connector capability for Proxy availability confirmation.", {"endpoint": "/v1/bridge/connectors/match", "caps": ["voice.call", "call.create", "availability.confirm"]})
|
|
264
|
+
bridge = host.connectorBook.matchConnectors({"domain": "bridge", "caps": ["voice.call", "call.create", "availability.confirm"]})
|
|
265
|
+
connector = self._selectBridgeConnector(bridge, preferred=("twilioVoice", "whatsapp"))
|
|
266
|
+
connectorId = str(connector.get("connectorId", "") or "")
|
|
267
|
+
preflight = self._connectorPreflight(host, connectorId, action="call", resource="twilio.calls") if connectorId else {"ok": False, "status": "blocked", "errors": ["missingConnector"]}
|
|
268
|
+
publicProvider = self._publicLocalServiceContact(localServiceContact)
|
|
269
|
+
bridgeStatus = "ready" if preflight.get("ok") else "needsClientCredentials"
|
|
270
|
+
contactPlan = {
|
|
271
|
+
"mode": "proxyAvailabilityConfirmation",
|
|
272
|
+
"target": publicProvider,
|
|
273
|
+
"connectorId": connectorId,
|
|
274
|
+
"connectorStatus": bridgeStatus,
|
|
275
|
+
"credentialContract": {
|
|
276
|
+
"mode": "clientRegisteredBridgeResource",
|
|
277
|
+
"registerEndpoint": "/v1/bridge/connectors",
|
|
278
|
+
"preflightEndpoint": f"/v1/bridge/connectors/{connectorId or '{connectorId}'}/preflight",
|
|
279
|
+
"requiredCaps": ["voice.call", "call.create", "availability.confirm"],
|
|
280
|
+
"requiredSecretRefs": ["PICUX_TWILIO_ACCOUNT_SID", "PICUX_TWILIO_AUTH_TOKEN", "PICUX_TWILIO_VOICE_FROM", "PICUX_VOICE_PUBLIC_BASE_URL"],
|
|
281
|
+
},
|
|
282
|
+
}
|
|
283
|
+
bridgePayload = {
|
|
284
|
+
"ok": bool(connectorId),
|
|
285
|
+
"status": bridgeStatus,
|
|
286
|
+
"mode": "clientConnector",
|
|
287
|
+
"readyConnectors": installed,
|
|
288
|
+
"connectors": bridge,
|
|
289
|
+
"preflight": preflight,
|
|
290
|
+
"contactPlan": contactPlan,
|
|
291
|
+
"handoffs": [{"from": "bridge", "to": "proxy", "connectorId": connectorId, "action": "confirmAvailabilityByVoice", "status": "sent" if connectorId else "missingConnector"}],
|
|
292
|
+
}
|
|
293
|
+
results["bridge"] = bridgePayload
|
|
294
|
+
event(
|
|
295
|
+
"bridge",
|
|
296
|
+
"connectorMatch.response",
|
|
297
|
+
bridgeStatus,
|
|
298
|
+
"BRIDGE prepared the voice connector contract for PROXY without exposing provider phone details.",
|
|
299
|
+
{"connectorId": connectorId, "connectorCount": bridge.get("count", 0), "preflight": {"ok": preflight.get("ok"), "status": preflight.get("status"), "errors": preflight.get("errors", [])}, "target": publicProvider},
|
|
300
|
+
)
|
|
301
|
+
event("bridge", "handoff.proxy", "sent" if connectorId else "missingConnector", "BRIDGE handed the local provider contact reference to PROXY for availability confirmation.", {"connectorId": connectorId, "contactRef": publicProvider.get("contactRef", ""), "action": "confirmAvailabilityByVoice"})
|
|
302
|
+
agentConversation.append("[Bridge] Preparing a voice connector so Proxy can confirm availability.")
|
|
303
|
+
else:
|
|
304
|
+
event("bridge", "connectorMatch.request", "sent", "BRIDGE checked connector capability for outbound contact.", {"endpoint": "/v1/bridge/connectors/match"})
|
|
305
|
+
bridge = host.connectorBook.matchConnectors({"domain": "bridge", "caps": ["contact.write", "messages.send", "voice.call"]})
|
|
306
|
+
target = self._target(request, payload)
|
|
307
|
+
contactPlan = host.resolve.contactPlan(request={"preferredChannel": self._preferredChannel(request)}, target=target)
|
|
308
|
+
bridgePayload: dict[str, Any] = {"connectors": bridge, "contactPlan": contactPlan, "readyConnectors": installed}
|
|
309
|
+
resolveResult = results.get("resolve", {}) if isinstance(results.get("resolve"), dict) else {}
|
|
310
|
+
claimDraft = resolveResult.get("claimDraft", {}) if isinstance(resolveResult.get("claimDraft"), dict) else {}
|
|
311
|
+
recovery = resolveResult.get("recovery", {}) if isinstance(resolveResult.get("recovery"), dict) else {}
|
|
312
|
+
if claimDraft:
|
|
313
|
+
bridgePayload["claimDraft"] = claimDraft
|
|
314
|
+
bridgePayload["handoffs"] = [{"from": "resolve", "to": "bridge", "action": "routeClaimDraft", "status": "sent", "target": contactPlan.get("target", {})}]
|
|
315
|
+
if recovery:
|
|
316
|
+
bridgePayload["recovery"] = recovery
|
|
317
|
+
bridgePayload.setdefault("handoffs", []).append({"from": "resolve", "to": "bridge", "action": "launchRecoveryWorkflow", "status": str(recovery.get("status", "queued") or "queued")})
|
|
318
|
+
results["bridge"] = bridgePayload
|
|
319
|
+
status = "ready" if bridge.get("count", 0) else "adapterNeutral"
|
|
320
|
+
event("bridge", "connectorMatch.response", status, "BRIDGE returned contact routing without binding to a vendor adapter.", {"contactPlan": contactPlan, "connectorCount": bridge.get("count", 0), "hasClaimDraft": bool(claimDraft)})
|
|
321
|
+
agentConversation.append("[Bridge] Routing the Resolve claim draft to the right contact channel and connector contract..." if claimDraft else "[Bridge] Finding the right contact channel and connector contract...")
|
|
322
|
+
elif domain == "resolve":
|
|
323
|
+
documents = payload.get("attachments", payload.get("documents", []))
|
|
324
|
+
documents = documents if isinstance(documents, list) else []
|
|
325
|
+
image = host.tools.readImage({"request": request, "images": payload.get("images", []), "attachments": documents, "documents": documents})
|
|
326
|
+
if image.get("count", 0):
|
|
327
|
+
toolResults["imageReader"] = image
|
|
328
|
+
documents = [*documents, *image.get("evidence", [])]
|
|
329
|
+
event(
|
|
330
|
+
"tools",
|
|
331
|
+
"imageReader",
|
|
332
|
+
"read",
|
|
333
|
+
"ImageReader extracted query-relevant evidence from image attachments.",
|
|
334
|
+
{"imageCount": image.get("count", 0), "evidenceCount": len(image.get("evidence", []))},
|
|
335
|
+
)
|
|
336
|
+
event("resolve", "scamTriage.request", "sent", "RESOLVE received claim text and evidence references.", {"endpoint": "/v1/resolve/scamTriage", "documentCount": len(documents)})
|
|
337
|
+
resolve = host.resolveScamTriage({"claim": request, "text": request, "documents": documents, "attachments": documents, "taskId": taskId})
|
|
338
|
+
target = self._target(request, payload)
|
|
339
|
+
if target:
|
|
340
|
+
resolve["claimDraft"] = host.resolve.draftClaim(request={"issueType": self._issueType(request), "attachments": documents}, audit={}, target=target)
|
|
341
|
+
recovery = self._startResolveRecovery(host, request=request, payload=payload, target=target, documents=documents, taskId=taskId, conversationId=conversationId)
|
|
342
|
+
if recovery:
|
|
343
|
+
resolve["recovery"] = recovery
|
|
344
|
+
event("resolve", "recovery.queue", str(recovery.get("status", "queued") or "queued"), "RESOLVE launched the recovery workflow and queued Bridge or portal work from the case.", {"recovery": recovery})
|
|
345
|
+
results["resolve"] = resolve
|
|
346
|
+
triage = resolve.get("triage", {}) if isinstance(resolve.get("triage"), dict) else {}
|
|
347
|
+
nextAction = str(triage.get("nextAction", "prepareReviewableRecord") or "prepareReviewableRecord")
|
|
348
|
+
event("resolve", "scamTriage.response", nextAction, "RESOLVE produced a reviewable evidence map and next action.", {"risk": triage.get("risk", {}), "openQuestions": triage.get("openQuestions", [])})
|
|
349
|
+
agentConversation.append("[Resolve] Checking risk, evidence, chronology, and safe next action...")
|
|
350
|
+
escalation = triage.get("escalation", {}) if isinstance(triage.get("escalation"), dict) else {}
|
|
351
|
+
if escalation.get("recommended"):
|
|
352
|
+
event("resolve", "escalation.check", "recommended", "RESOLVE recommended human review; PROXY opens when the request needs follow-up, voice, logistics, or explicit human review.", escalation)
|
|
353
|
+
if escalation.get("recommended") and "proxy" not in route and self._shouldCreateProxy(request, payload):
|
|
354
|
+
insertAt = route.index("bridge") + 1 if "bridge" in route and route.index("bridge") > index else index + 1
|
|
355
|
+
route.insert(insertAt, "proxy")
|
|
356
|
+
elif domain == "proxy":
|
|
357
|
+
localServiceContact = self._selectedLocalServiceContact(results.get("hunt", {}), localServiceContacts)
|
|
358
|
+
if localServiceContact:
|
|
359
|
+
bridgeResult = results.get("bridge", {}) if isinstance(results.get("bridge"), dict) else {}
|
|
360
|
+
publicProvider = self._publicLocalServiceContact(localServiceContact)
|
|
361
|
+
contactRef = str(localServiceContact.get("contactRef", publicProvider.get("contactRef", "")) or "")
|
|
362
|
+
connectorId = str(bridgeResult.get("contactPlan", {}).get("connectorId", "") if isinstance(bridgeResult.get("contactPlan"), dict) else "")
|
|
363
|
+
preflight = bridgeResult.get("preflight", {}) if isinstance(bridgeResult.get("preflight"), dict) else {}
|
|
364
|
+
hasPrivateContact = bool(str(localServiceContact.get("phone", "") or ""))
|
|
365
|
+
callStatus = "readyToCall" if hasPrivateContact and preflight.get("ok") else ("needsClientCredentials" if connectorId else "missingVoiceConnector")
|
|
366
|
+
if not hasPrivateContact:
|
|
367
|
+
callStatus = "needsProviderContact"
|
|
368
|
+
voiceCall = {
|
|
369
|
+
"phoneRef": contactRef,
|
|
370
|
+
"status": callStatus,
|
|
371
|
+
"connectorId": connectorId,
|
|
372
|
+
"prompt": f"Confirm availability for this Picux local service request. Provider: {publicProvider.get('name', '')}. User need: {request}",
|
|
373
|
+
}
|
|
374
|
+
event("proxy", "mission.request", "sent", "PROXY received the local provider as a callable human/business node.", {"endpoint": "/v1/proxy/missions", "reason": "localServiceAvailability", "contactRef": contactRef, "connectorId": connectorId, "callStatus": callStatus})
|
|
375
|
+
proxyStatus, proxy = host.createProxyMission(
|
|
376
|
+
{
|
|
377
|
+
"taskId": taskId,
|
|
378
|
+
"userId": clientId,
|
|
379
|
+
"kind": "call",
|
|
380
|
+
"reason": "localServiceAvailability",
|
|
381
|
+
"task": f"Call {publicProvider.get('name') or 'the selected provider'} to confirm availability.",
|
|
382
|
+
"context": {
|
|
383
|
+
"missionState": {"route": route, "request": request, "clientId": clientId, "conversationId": conversationId},
|
|
384
|
+
"decisionFork": {"options": ["providerAvailable", "providerUnavailable", "requestAnotherProvider"]},
|
|
385
|
+
"logisticsBrief": {
|
|
386
|
+
"why": "HUNT found a local service provider; PROXY must confirm real-world availability before presenting the provider as verified.",
|
|
387
|
+
"selectedProvider": publicProvider,
|
|
388
|
+
},
|
|
389
|
+
"reason": "localServiceAvailability",
|
|
390
|
+
"selectedProvider": publicProvider,
|
|
391
|
+
"contactRef": contactRef,
|
|
392
|
+
"connectorId": connectorId,
|
|
393
|
+
"bridgePreflight": {"ok": preflight.get("ok"), "status": preflight.get("status"), "errors": preflight.get("errors", [])},
|
|
394
|
+
"availabilityVerification": {"status": callStatus, "providerName": publicProvider.get("name", ""), "contactRef": contactRef},
|
|
395
|
+
},
|
|
396
|
+
"voiceCall": voiceCall,
|
|
397
|
+
"proofReq": ["callTranscript", "availabilityWindow", "mechanicName", "consentRecord"],
|
|
398
|
+
"resume": {"conversationId": conversationId, "candidateId": str(publicProvider.get("providerId", "") or contactRef), "action": "proxyOutcome"},
|
|
399
|
+
}
|
|
400
|
+
)
|
|
401
|
+
proxyCreated = proxyStatus < 400 and bool(proxy.get("ok"))
|
|
402
|
+
results["proxy"] = proxy
|
|
403
|
+
event("proxy", "mission.response", "awaitingProxy" if proxyCreated else "blocked", "PROXY opened the availability-confirmation mission for the selected provider." if proxyCreated else "PROXY mission could not be created.", {"proxyId": proxy.get("proxyId", ""), "status": proxyStatus, "callStatus": callStatus})
|
|
404
|
+
agentConversation.append("[Proxy] Taking over the availability call and waiting for the provider's confirmation proof...")
|
|
405
|
+
else:
|
|
406
|
+
resolveResult = results.get("resolve", {}) if isinstance(results.get("resolve"), dict) else {}
|
|
407
|
+
bridgeResult = results.get("bridge", {}) if isinstance(results.get("bridge"), dict) else {}
|
|
408
|
+
triage = resolveResult.get("triage", {}) if isinstance(resolveResult.get("triage"), dict) else {}
|
|
409
|
+
claimDraft = resolveResult.get("claimDraft", {}) if isinstance(resolveResult.get("claimDraft"), dict) else {}
|
|
410
|
+
contactPlan = bridgeResult.get("contactPlan", {}) if isinstance(bridgeResult.get("contactPlan"), dict) else {}
|
|
411
|
+
proxyReason = "resolveEscalationReview" if resolveResult else "humanReviewOrContact"
|
|
412
|
+
event("proxy", "mission.request", "sent", "PROXY received a suspend-resume mission for human review or contact.", {"endpoint": "/v1/proxy/missions", "reason": proxyReason})
|
|
413
|
+
proxyStatus, proxy = host.createProxyMission(
|
|
414
|
+
{
|
|
415
|
+
"taskId": taskId,
|
|
416
|
+
"userId": clientId,
|
|
417
|
+
"kind": "call" if self._hasPhone(request, payload) else "review",
|
|
418
|
+
"reason": proxyReason,
|
|
419
|
+
"task": request,
|
|
420
|
+
"context": {
|
|
421
|
+
"missionState": {"route": route, "request": request, "clientId": clientId},
|
|
422
|
+
"decisionFork": {"options": ["continue", "requestMoreEvidence", "stop"]},
|
|
423
|
+
"logisticsBrief": {"why": "Human judgment or real-world contact is required before the agent can continue."},
|
|
424
|
+
"phone": self._phone(request, payload),
|
|
425
|
+
"resolveTriage": triage,
|
|
426
|
+
"claimDraft": claimDraft,
|
|
427
|
+
"contactPlan": contactPlan,
|
|
428
|
+
},
|
|
429
|
+
"voiceCall": {"phone": self._phone(request, payload), "prompt": request},
|
|
430
|
+
}
|
|
431
|
+
)
|
|
432
|
+
proxyCreated = proxyStatus < 400 and bool(proxy.get("ok"))
|
|
433
|
+
results["proxy"] = proxy
|
|
434
|
+
event("proxy", "mission.response", "awaitingProxy" if proxyCreated else "blocked", "PROXY suspended the run until a human outcome is returned." if proxyCreated else "PROXY mission could not be created.", {"proxyId": proxy.get("proxyId", ""), "status": proxyStatus})
|
|
435
|
+
agentConversation.append("[Proxy] Human review, voice, chat, or logistics handoff is waiting for completion proof...")
|
|
436
|
+
elif domain == "pay":
|
|
437
|
+
amount = selectedAmount or self._budget(request, payload) or 0.0
|
|
438
|
+
if not selectedVendor and not payload.get("payeeId"):
|
|
439
|
+
results["pay"] = {"ok": False, "error": "missingSourceBoundPayee", "reason": "PAY requires a selected source-bound payee or explicit payeeId."}
|
|
440
|
+
event("pay", "prepare.blocked", "blocked", "PAY refused to prepare a mandate without a selected source-bound payee.", {"amount": amount})
|
|
441
|
+
agentConversation.append("[Pay] Skipped mandate preparation because no source-bound payee was selected.")
|
|
442
|
+
index += 1
|
|
443
|
+
continue
|
|
444
|
+
selectedVendor = selectedVendor or str(payload.get("payeeId", "") or "")
|
|
445
|
+
mandate = host.createMandate(
|
|
446
|
+
{
|
|
447
|
+
"mandateId": self._stableId("mandate", {"taskId": taskId, "request": request}),
|
|
448
|
+
"issuer": {
|
|
449
|
+
"entityId": clientId,
|
|
450
|
+
"publicKey": str(payload.get("publicKey", "clientPublicKey") or "clientPublicKey"),
|
|
451
|
+
},
|
|
452
|
+
"constraints": {
|
|
453
|
+
"maxSpend": {"amount": self._budget(request, payload) or max(amount, 1.0), "currency": "USD"},
|
|
454
|
+
"allowedVendors": [selectedVendor],
|
|
455
|
+
"resolveRules": ["sourceBound", "proofBeforeSettlement"],
|
|
456
|
+
},
|
|
457
|
+
"validUntil": "2099-01-01T00:00:00Z",
|
|
458
|
+
"signature": str(payload.get("signature", "clientSignature") or "clientSignature"),
|
|
459
|
+
}
|
|
460
|
+
)
|
|
461
|
+
escrow = host.createEscrow(
|
|
462
|
+
{
|
|
463
|
+
"taskId": taskId,
|
|
464
|
+
"mandateId": mandate.get("mandateId", ""),
|
|
465
|
+
"status": "prepared",
|
|
466
|
+
"amount": {"amount": amount, "currency": "USD"},
|
|
467
|
+
"request": {"taskId": taskId, "vendorId": selectedVendor, "amount": {"amount": amount, "currency": "USD"}},
|
|
468
|
+
}
|
|
469
|
+
)
|
|
470
|
+
results["pay"] = {"mandate": mandate, "escrow": escrow}
|
|
471
|
+
event("pay", "prepare.response", "prepared", "PAY prepared a mandate and escrow boundary; settlement remains proof-gated.", {"mandateId": mandate.get("mandateId", ""), "escrowId": escrow.get("escrow", {}).get("escrowId", "")})
|
|
472
|
+
agentConversation.append("[Pay] Preparing mandate, settlement boundary, and proof-gated escrow...")
|
|
473
|
+
index += 1
|
|
474
|
+
|
|
475
|
+
status = "awaitingProxy" if proxyCreated else "ready"
|
|
476
|
+
if results.get("hunt") and not results["hunt"].get("ok"):
|
|
477
|
+
status = "needsSourceTelemetry"
|
|
478
|
+
if isinstance(results.get("pay"), dict) and results["pay"].get("ok") is False:
|
|
479
|
+
status = "blocked"
|
|
480
|
+
event("orchestrator", "proofPack", "created", "Picux created a portable proof card for the orchestrated run.", {"status": status})
|
|
481
|
+
proof = host._proofPackForRun(
|
|
482
|
+
taskId=taskId,
|
|
483
|
+
domain="orchestrator",
|
|
484
|
+
title="Picux orchestrated run",
|
|
485
|
+
summary=f"Picux routed the request through {', '.join(route).upper()} and produced a reviewable case file.",
|
|
486
|
+
payload={"request": request, "route": route, "results": results, "client": {"clientId": clientId, "channel": channel}},
|
|
487
|
+
decisions=[{"label": item["name"], "reason": item["status"]} for item in trace if item["phase"] != "orchestrator"],
|
|
488
|
+
evidenceStatus="sourceBound" if results else "needsEvidence",
|
|
489
|
+
confidence=0.72 if status == "ready" else 0.58,
|
|
490
|
+
openQuestions=["awaitingProxyOutcome"] if proxyCreated else ([] if status == "ready" else ["sourceTelemetryRequired"]),
|
|
491
|
+
escalation={"recommended": proxyCreated or status != "ready", "routeDomain": "proxy" if proxyCreated else "", "reason": status},
|
|
492
|
+
)
|
|
493
|
+
agentConversation.append("[Picux] I prepared the result with evidence status, open questions, and next steps.")
|
|
494
|
+
stream("conversation", {"line": agentConversation[-1]})
|
|
495
|
+
host.taskBook.updateTask(
|
|
496
|
+
taskId,
|
|
497
|
+
{
|
|
498
|
+
"status": ProtocolTaskStatus.AWAITING_PROXY.value if proxyCreated else (ProtocolTaskStatus.FAILED.value if status in {"blocked", "needsSourceTelemetry"} else ProtocolTaskStatus.COMPLETED.value),
|
|
499
|
+
"outData": {"conversationId": conversationId, "route": route, "status": status, "results": results, "decisionTree": self._decisionTree(results.get("hunt", {})), "proofPack": proof.get("proofPack", {})},
|
|
500
|
+
},
|
|
501
|
+
)
|
|
502
|
+
return {
|
|
503
|
+
"ok": True,
|
|
504
|
+
"scenario": "picuxOrchestrator",
|
|
505
|
+
"status": status,
|
|
506
|
+
"conversationId": conversationId,
|
|
507
|
+
"request": request,
|
|
508
|
+
"route": route,
|
|
509
|
+
"task": task,
|
|
510
|
+
"trace": trace,
|
|
511
|
+
"agentConversation": agentConversation,
|
|
512
|
+
"results": results,
|
|
513
|
+
"decisionTree": self._decisionTree(results.get("hunt", {})),
|
|
514
|
+
"mandate": mandate.get("mandate", mandate),
|
|
515
|
+
"receipt": None,
|
|
516
|
+
"freezeReceipt": None,
|
|
517
|
+
"proofPack": proof.get("proofPack", {}),
|
|
518
|
+
"proofCard": proof.get("proofPack", {}).get("proofCard", {}),
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
def _runResume(
|
|
522
|
+
self,
|
|
523
|
+
*,
|
|
524
|
+
payload: dict[str, Any],
|
|
525
|
+
request: str,
|
|
526
|
+
clientId: str,
|
|
527
|
+
channel: str,
|
|
528
|
+
metadata: dict[str, Any],
|
|
529
|
+
route: list[str],
|
|
530
|
+
toolResults: dict[str, Any],
|
|
531
|
+
trace: list[dict[str, Any]],
|
|
532
|
+
agentConversation: list[str],
|
|
533
|
+
event: Any,
|
|
534
|
+
stream: Any,
|
|
535
|
+
) -> dict[str, Any]:
|
|
536
|
+
host = self.host
|
|
537
|
+
action = self._followUpAction(payload)
|
|
538
|
+
conversationId = self._conversationId(payload)
|
|
539
|
+
selectedItemId = self._selectedItemId(payload)
|
|
540
|
+
if not conversationId:
|
|
541
|
+
event("orchestrator", "conversation.load", "missing", "Picux could not resume because conversationId is missing.", {"action": action, "itemId": selectedItemId})
|
|
542
|
+
return self._resumeFailure(
|
|
543
|
+
request=request,
|
|
544
|
+
route=route,
|
|
545
|
+
status="conversationNotFound",
|
|
546
|
+
error="missing:conversationId",
|
|
547
|
+
conversationId="",
|
|
548
|
+
clientId=clientId,
|
|
549
|
+
channel=channel,
|
|
550
|
+
trace=trace,
|
|
551
|
+
agentConversation=agentConversation,
|
|
552
|
+
results={"tools": toolResults},
|
|
553
|
+
event=event,
|
|
554
|
+
stream=stream,
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
taskLookup = host.taskBook.getTaskByConversationId(conversationId) if hasattr(host.taskBook, "getTaskByConversationId") else host.taskBook.getTask(conversationId)
|
|
558
|
+
if not taskLookup.get("ok"):
|
|
559
|
+
event("orchestrator", "conversation.load", "missing", "Picux could not find the conversation state for this follow-up.", {"conversationId": conversationId, "error": taskLookup.get("error", "")})
|
|
560
|
+
return self._resumeFailure(
|
|
561
|
+
request=request,
|
|
562
|
+
route=route,
|
|
563
|
+
status="conversationNotFound",
|
|
564
|
+
error=str(taskLookup.get("error", "conversationNotFound") or "conversationNotFound"),
|
|
565
|
+
conversationId=conversationId,
|
|
566
|
+
clientId=clientId,
|
|
567
|
+
channel=channel,
|
|
568
|
+
trace=trace,
|
|
569
|
+
agentConversation=agentConversation,
|
|
570
|
+
results={"tools": toolResults},
|
|
571
|
+
event=event,
|
|
572
|
+
stream=stream,
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
task = taskLookup.get("task", {}) if isinstance(taskLookup.get("task"), dict) else {}
|
|
576
|
+
taskId = str(task.get("taskId", "") or conversationId)
|
|
577
|
+
priorOut = task.get("outData", {}) if isinstance(task.get("outData"), dict) else {}
|
|
578
|
+
priorResults = priorOut.get("results", {}) if isinstance(priorOut.get("results"), dict) else {}
|
|
579
|
+
hunt = priorResults.get("hunt", {}) if isinstance(priorResults.get("hunt"), dict) else {}
|
|
580
|
+
event("orchestrator", "conversation.load", "loaded", "Picux loaded the previous conversation state.", {"conversationId": conversationId, "taskId": taskId, "action": action})
|
|
581
|
+
if not hunt:
|
|
582
|
+
resume = {"action": action, "status": "missingHuntState", "conversationId": conversationId, "requestedItemId": selectedItemId}
|
|
583
|
+
results = {"tools": toolResults, "hunt": {"ok": False, "error": "missingHuntState", "conversationId": conversationId}}
|
|
584
|
+
results["resume"] = resume
|
|
585
|
+
event("hunt", "candidate.validate", "blocked", "HUNT has no previous candidate state to validate.", {"conversationId": conversationId})
|
|
586
|
+
return self._finalizeResume(
|
|
587
|
+
task=task,
|
|
588
|
+
priorOut=priorOut,
|
|
589
|
+
request=request,
|
|
590
|
+
conversationId=conversationId,
|
|
591
|
+
route=["hunt"],
|
|
592
|
+
status="missingHuntState",
|
|
593
|
+
ok=False,
|
|
594
|
+
results=results,
|
|
595
|
+
trace=trace,
|
|
596
|
+
agentConversation=agentConversation,
|
|
597
|
+
event=event,
|
|
598
|
+
stream=stream,
|
|
599
|
+
taskStatus=ProtocolTaskStatus.PROCESSING.value,
|
|
600
|
+
evidenceStatus="needsEvidence",
|
|
601
|
+
confidence=0.35,
|
|
602
|
+
openQuestions=["initialHuntResultRequired"],
|
|
603
|
+
escalation={"recommended": True, "routeDomain": "hunt", "reason": "missingHuntState"},
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
if not selectedItemId:
|
|
607
|
+
selectedItemId = self._selectedItemId(payload, hunt=hunt, request=request)
|
|
608
|
+
|
|
609
|
+
candidate = self._candidateById(hunt, selectedItemId)
|
|
610
|
+
if not candidate:
|
|
611
|
+
refs = self._candidateRefs(hunt)
|
|
612
|
+
resume = {
|
|
613
|
+
"action": action,
|
|
614
|
+
"status": "invalidCandidate",
|
|
615
|
+
"conversationId": conversationId,
|
|
616
|
+
"requestedItemId": selectedItemId,
|
|
617
|
+
"availableCandidates": refs,
|
|
618
|
+
}
|
|
619
|
+
results = {
|
|
620
|
+
"tools": toolResults,
|
|
621
|
+
"hunt": {
|
|
622
|
+
**hunt,
|
|
623
|
+
"resume": resume,
|
|
624
|
+
},
|
|
625
|
+
"resume": resume,
|
|
626
|
+
}
|
|
627
|
+
event("hunt", "candidate.validate", "invalid", "HUNT rejected the follow-up because the selected item does not belong to this conversation.", {"itemId": selectedItemId, "availableCandidates": refs[:8]})
|
|
628
|
+
agentConversation.append("[Hunt] I could not match that selected item to the source-bound options in this conversation.")
|
|
629
|
+
stream("conversation", {"line": agentConversation[-1]})
|
|
630
|
+
return self._finalizeResume(
|
|
631
|
+
task=task,
|
|
632
|
+
priorOut=priorOut,
|
|
633
|
+
request=request,
|
|
634
|
+
conversationId=conversationId,
|
|
635
|
+
route=["hunt"],
|
|
636
|
+
status="invalidCandidate",
|
|
637
|
+
ok=False,
|
|
638
|
+
results=results,
|
|
639
|
+
trace=trace,
|
|
640
|
+
agentConversation=agentConversation,
|
|
641
|
+
event=event,
|
|
642
|
+
stream=stream,
|
|
643
|
+
taskStatus=ProtocolTaskStatus.PROCESSING.value,
|
|
644
|
+
evidenceStatus="sourceBound",
|
|
645
|
+
confidence=0.42,
|
|
646
|
+
openQuestions=["validCandidateIdRequired"],
|
|
647
|
+
escalation={"recommended": True, "routeDomain": "hunt", "reason": "invalidCandidate"},
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
candidateRef = self._candidateRef(candidate)
|
|
651
|
+
event("hunt", "candidate.validate", "valid", "HUNT validated the selected source-bound candidate.", {"conversationId": conversationId, "candidate": candidateRef})
|
|
652
|
+
agentConversation.append(f"[Hunt] Resumed this conversation and validated {candidateRef.get('title') or candidateRef.get('candidateId')} from {self._displaySource(str(candidateRef.get('source', '') or 'the source'))}.")
|
|
653
|
+
stream("conversation", {"line": agentConversation[-1]})
|
|
654
|
+
|
|
655
|
+
if action == "pass":
|
|
656
|
+
rejected = self._rejectedCandidateIds(priorOut)
|
|
657
|
+
if candidateRef["candidateId"] not in rejected:
|
|
658
|
+
rejected.append(candidateRef["candidateId"])
|
|
659
|
+
nextCandidate = self._nextCandidateRef(hunt, rejected)
|
|
660
|
+
resume = {
|
|
661
|
+
"action": "pass",
|
|
662
|
+
"status": "candidatePassed",
|
|
663
|
+
"conversationId": conversationId,
|
|
664
|
+
"passedCandidate": candidateRef,
|
|
665
|
+
"nextCandidate": nextCandidate,
|
|
666
|
+
"rejectedCandidateIds": rejected,
|
|
667
|
+
}
|
|
668
|
+
results = {"tools": toolResults, "hunt": {**hunt, "resume": resume}, "resume": resume}
|
|
669
|
+
if nextCandidate:
|
|
670
|
+
agentConversation.append(f"[Hunt] You passed on that option. The next source-bound option is {nextCandidate.get('title') or nextCandidate.get('candidateId')}.")
|
|
671
|
+
else:
|
|
672
|
+
agentConversation.append("[Hunt] You passed on that option. I do not have another source-bound candidate from the current result set.")
|
|
673
|
+
stream("conversation", {"line": agentConversation[-1]})
|
|
674
|
+
return self._finalizeResume(
|
|
675
|
+
task=task,
|
|
676
|
+
priorOut=priorOut,
|
|
677
|
+
request=request,
|
|
678
|
+
conversationId=conversationId,
|
|
679
|
+
route=["hunt"],
|
|
680
|
+
status="candidatePassed",
|
|
681
|
+
ok=True,
|
|
682
|
+
results=results,
|
|
683
|
+
trace=trace,
|
|
684
|
+
agentConversation=agentConversation,
|
|
685
|
+
event=event,
|
|
686
|
+
stream=stream,
|
|
687
|
+
taskStatus=ProtocolTaskStatus.PROCESSING.value,
|
|
688
|
+
evidenceStatus="sourceBound",
|
|
689
|
+
confidence=0.68,
|
|
690
|
+
openQuestions=[] if nextCandidate else ["noMoreSourceBoundOptions"],
|
|
691
|
+
escalation={"recommended": not bool(nextCandidate), "routeDomain": "hunt" if not nextCandidate else "", "reason": "candidatePassed"},
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
if self._isLocalServiceCandidate(candidateRef):
|
|
695
|
+
return self._runLocalServiceResume(
|
|
696
|
+
payload=payload,
|
|
697
|
+
task=task,
|
|
698
|
+
priorOut=priorOut,
|
|
699
|
+
hunt=hunt,
|
|
700
|
+
candidateRef=candidateRef,
|
|
701
|
+
request=request,
|
|
702
|
+
conversationId=conversationId,
|
|
703
|
+
taskId=taskId,
|
|
704
|
+
clientId=clientId,
|
|
705
|
+
channel=channel,
|
|
706
|
+
toolResults=toolResults,
|
|
707
|
+
trace=trace,
|
|
708
|
+
agentConversation=agentConversation,
|
|
709
|
+
event=event,
|
|
710
|
+
stream=stream,
|
|
711
|
+
)
|
|
712
|
+
|
|
713
|
+
if action == "contact":
|
|
714
|
+
return self._runCandidateContactResume(
|
|
715
|
+
payload=payload,
|
|
716
|
+
task=task,
|
|
717
|
+
priorOut=priorOut,
|
|
718
|
+
hunt=hunt,
|
|
719
|
+
candidateRef=candidateRef,
|
|
720
|
+
request=request,
|
|
721
|
+
conversationId=conversationId,
|
|
722
|
+
taskId=taskId,
|
|
723
|
+
clientId=clientId,
|
|
724
|
+
toolResults=toolResults,
|
|
725
|
+
trace=trace,
|
|
726
|
+
agentConversation=agentConversation,
|
|
727
|
+
event=event,
|
|
728
|
+
stream=stream,
|
|
729
|
+
)
|
|
730
|
+
|
|
731
|
+
if action == "refine":
|
|
732
|
+
resume = {
|
|
733
|
+
"action": "refine",
|
|
734
|
+
"status": "NEEDS_INPUT",
|
|
735
|
+
"conversationId": conversationId,
|
|
736
|
+
"selectedCandidate": candidateRef,
|
|
737
|
+
"requestedKeys": ["refinement"],
|
|
738
|
+
}
|
|
739
|
+
results = {"tools": toolResults, "hunt": {**hunt, "resume": resume}, "resume": resume}
|
|
740
|
+
event("hunt", "refine.awaitingInput", "NEEDS_INPUT", "HUNT needs the updated filter, budget, or constraint for this follow-up.", {"candidate": candidateRef})
|
|
741
|
+
agentConversation.append("[Hunt] Tell me what to change in the search: budget, location, source, condition, or another filter.")
|
|
742
|
+
stream("conversation", {"line": agentConversation[-1]})
|
|
743
|
+
return self._finalizeResume(
|
|
744
|
+
task=task,
|
|
745
|
+
priorOut=priorOut,
|
|
746
|
+
request=request,
|
|
747
|
+
conversationId=conversationId,
|
|
748
|
+
route=["hunt"],
|
|
749
|
+
status="NEEDS_INPUT",
|
|
750
|
+
ok=True,
|
|
751
|
+
results=results,
|
|
752
|
+
trace=trace,
|
|
753
|
+
agentConversation=agentConversation,
|
|
754
|
+
event=event,
|
|
755
|
+
stream=stream,
|
|
756
|
+
taskStatus=ProtocolTaskStatus.PROCESSING.value,
|
|
757
|
+
evidenceStatus="sourceBound",
|
|
758
|
+
confidence=0.62,
|
|
759
|
+
openQuestions=["refinement"],
|
|
760
|
+
escalation={"recommended": False, "routeDomain": "hunt", "reason": "awaitingRefinement"},
|
|
761
|
+
)
|
|
762
|
+
|
|
763
|
+
purchaseInputs = self._purchaseInputs(payload)
|
|
764
|
+
missingContactKeys = self._missingContactKeys(purchaseInputs)
|
|
765
|
+
missingPaymentKeys = [] if missingContactKeys else self._missingPaymentKeys(purchaseInputs)
|
|
766
|
+
missingKeys = missingContactKeys or missingPaymentKeys
|
|
767
|
+
resumeStatus = "NEEDS_INPUT" if missingContactKeys else ("NEEDS_PAYMENT_INFO" if missingPaymentKeys else "purchasePrepared")
|
|
768
|
+
purchaseIntent = self._purchaseIntent(conversationId, taskId, candidateRef, purchaseInputs, missingKeys, resumeStatus)
|
|
769
|
+
resume = {
|
|
770
|
+
"action": "buy",
|
|
771
|
+
"status": resumeStatus,
|
|
772
|
+
"conversationId": conversationId,
|
|
773
|
+
"selectedCandidate": candidateRef,
|
|
774
|
+
"purchaseIntent": purchaseIntent,
|
|
775
|
+
"requestedKeys": missingKeys,
|
|
776
|
+
"resumeToken": self._stableId("resume", {"conversationId": conversationId, "candidateId": candidateRef["candidateId"], "action": "buy"}),
|
|
777
|
+
}
|
|
778
|
+
results = {
|
|
779
|
+
"tools": toolResults,
|
|
780
|
+
"hunt": {**hunt, "resume": {"action": "buy", "status": "candidateValidated", "selectedCandidate": candidateRef}},
|
|
781
|
+
"purchaseIntent": purchaseIntent,
|
|
782
|
+
}
|
|
783
|
+
bridgeRoute = ["hunt", "bridge", "pay"]
|
|
784
|
+
if missingContactKeys:
|
|
785
|
+
bridge = self._bridgeNeedsInput(candidateRef, missingKeys)
|
|
786
|
+
pay = {
|
|
787
|
+
"ok": False,
|
|
788
|
+
"status": "AWAITING_INPUT",
|
|
789
|
+
"requestedKeys": [],
|
|
790
|
+
"supportedPaymentMethods": self._supportedPaymentMethods(),
|
|
791
|
+
"paymentSchema": self._paymentSchema(),
|
|
792
|
+
"settlementGate": {"status": "locked", "reason": "missingContactOrLogisticsInput", "proofRequired": True},
|
|
793
|
+
}
|
|
794
|
+
results["bridge"] = bridge
|
|
795
|
+
results["pay"] = pay
|
|
796
|
+
results["resume"] = resume
|
|
797
|
+
event("bridge", "checkout.prepare", "NEEDS_INPUT", "BRIDGE paused checkout routing until the client supplies required purchase inputs.", {"requestedKeys": missingKeys, "candidate": candidateRef})
|
|
798
|
+
event("pay", "prepare.awaitingInput", "AWAITING_INPUT", "PAY discovered supported payment methods but is waiting for contact and logistics input before collecting payment data.", {"supportedPaymentMethods": self._supportedPaymentMethods()})
|
|
799
|
+
agentConversation.append(f"[Bridge] I need {', '.join(missingKeys)} before I can prepare checkout routing.")
|
|
800
|
+
stream("conversation", {"line": agentConversation[-1]})
|
|
801
|
+
agentConversation.append("[Pay] I found the supported payment methods, but payment collection waits until the required contact and logistics input is complete.")
|
|
802
|
+
stream("conversation", {"line": agentConversation[-1]})
|
|
803
|
+
return self._finalizeResume(
|
|
804
|
+
task=task,
|
|
805
|
+
priorOut=priorOut,
|
|
806
|
+
request=request,
|
|
807
|
+
conversationId=conversationId,
|
|
808
|
+
route=bridgeRoute,
|
|
809
|
+
status="NEEDS_INPUT",
|
|
810
|
+
ok=True,
|
|
811
|
+
results=results,
|
|
812
|
+
trace=trace,
|
|
813
|
+
agentConversation=agentConversation,
|
|
814
|
+
event=event,
|
|
815
|
+
stream=stream,
|
|
816
|
+
taskStatus=ProtocolTaskStatus.PROCESSING.value,
|
|
817
|
+
evidenceStatus="sourceBound",
|
|
818
|
+
confidence=0.64,
|
|
819
|
+
openQuestions=missingKeys,
|
|
820
|
+
escalation={"recommended": True, "routeDomain": "bridge", "reason": "missingPurchaseInput"},
|
|
821
|
+
)
|
|
822
|
+
|
|
823
|
+
if missingPaymentKeys:
|
|
824
|
+
bridge = {
|
|
825
|
+
"ok": True,
|
|
826
|
+
"status": "inputReady",
|
|
827
|
+
"mode": "adapterNeutral",
|
|
828
|
+
"checkout": {
|
|
829
|
+
"action": "preparePaymentInfo",
|
|
830
|
+
"source": candidateRef.get("source", ""),
|
|
831
|
+
"url": candidateRef.get("url", ""),
|
|
832
|
+
"candidateId": candidateRef.get("candidateId", ""),
|
|
833
|
+
},
|
|
834
|
+
}
|
|
835
|
+
pay = {
|
|
836
|
+
"ok": False,
|
|
837
|
+
"status": "NEEDS_PAYMENT_INFO",
|
|
838
|
+
"requestedKeys": missingPaymentKeys,
|
|
839
|
+
"supportedPaymentMethods": self._supportedPaymentMethods(),
|
|
840
|
+
"paymentSchema": self._paymentSchema(),
|
|
841
|
+
"settlementGate": {"status": "locked", "reason": "missingPaymentInfo", "proofRequired": True},
|
|
842
|
+
}
|
|
843
|
+
results["bridge"] = bridge
|
|
844
|
+
results["pay"] = pay
|
|
845
|
+
results["resume"] = resume
|
|
846
|
+
event("bridge", "checkout.prepare", "inputReady", "BRIDGE has enough contact and logistics input to prepare checkout routing.", {"candidate": candidateRef})
|
|
847
|
+
event("pay", "prepare.needsPaymentInfo", "NEEDS_PAYMENT_INFO", "PAY discovered supported payment methods and requested method-specific payment information.", {"requestedKeys": missingPaymentKeys, "supportedPaymentMethods": self._supportedPaymentMethods()})
|
|
848
|
+
agentConversation.append("[Bridge] Contact and logistics input is ready for checkout routing.")
|
|
849
|
+
stream("conversation", {"line": agentConversation[-1]})
|
|
850
|
+
agentConversation.append("[Pay] Choose a supported payment method and provide the method-specific payment details.")
|
|
851
|
+
stream("conversation", {"line": agentConversation[-1]})
|
|
852
|
+
return self._finalizeResume(
|
|
853
|
+
task=task,
|
|
854
|
+
priorOut=priorOut,
|
|
855
|
+
request=request,
|
|
856
|
+
conversationId=conversationId,
|
|
857
|
+
route=bridgeRoute,
|
|
858
|
+
status="NEEDS_PAYMENT_INFO",
|
|
859
|
+
ok=True,
|
|
860
|
+
results=results,
|
|
861
|
+
trace=trace,
|
|
862
|
+
agentConversation=agentConversation,
|
|
863
|
+
event=event,
|
|
864
|
+
stream=stream,
|
|
865
|
+
taskStatus=ProtocolTaskStatus.PROCESSING.value,
|
|
866
|
+
evidenceStatus="sourceBound",
|
|
867
|
+
confidence=0.68,
|
|
868
|
+
openQuestions=missingPaymentKeys,
|
|
869
|
+
escalation={"recommended": True, "routeDomain": "pay", "reason": "missingPaymentInfo"},
|
|
870
|
+
)
|
|
871
|
+
|
|
872
|
+
installed = host._ensureReadyConnectors()
|
|
873
|
+
checkoutConnectors = host.connectorBook.matchConnectors({"domain": "bridge", "caps": ["marketplace.checkout", "browser.checkout", "purchase.execute"]})
|
|
874
|
+
connectorRecords = checkoutConnectors.get("connectors", []) if isinstance(checkoutConnectors.get("connectors"), list) else []
|
|
875
|
+
checkoutConnector = connectorRecords[0] if connectorRecords and isinstance(connectorRecords[0], dict) else {}
|
|
876
|
+
checkoutConnectorId = str(checkoutConnector.get("connectorId", "") or "")
|
|
877
|
+
bridge = {
|
|
878
|
+
"ok": True,
|
|
879
|
+
"status": "purchaseAttempting",
|
|
880
|
+
"mode": "browserCheckout",
|
|
881
|
+
"readyConnectors": installed,
|
|
882
|
+
"connectors": checkoutConnectors,
|
|
883
|
+
"checkout": {
|
|
884
|
+
"action": "browserPurchase",
|
|
885
|
+
"connectorId": checkoutConnectorId,
|
|
886
|
+
"source": candidateRef.get("source", ""),
|
|
887
|
+
"url": candidateRef.get("url", ""),
|
|
888
|
+
"candidateId": candidateRef["candidateId"],
|
|
889
|
+
"note": "Picux is attempting source-bound checkout through the browser tool and will fail closed if purchase confirmation is not observed.",
|
|
890
|
+
},
|
|
891
|
+
"handoffs": [
|
|
892
|
+
{"from": "bridge", "to": "pay", "action": "prepareMandateEscrow", "status": "sent"},
|
|
893
|
+
{"from": "bridge", "to": "browserCheckout", "connectorId": checkoutConnectorId, "action": "executePurchase", "status": "sent" if checkoutConnectorId else "blocked"},
|
|
894
|
+
],
|
|
895
|
+
}
|
|
896
|
+
event("bridge", "checkout.connectorMatch", "matched" if checkoutConnectorId else "missingConnector", "BRIDGE matched a checkout connector for marketplace purchase execution." if checkoutConnectorId else "BRIDGE could not find a checkout connector for marketplace purchase execution.", {"connectorId": checkoutConnectorId, "connectorCount": checkoutConnectors.get("count", 0), "caps": ["marketplace.checkout", "browser.checkout", "purchase.execute"]})
|
|
897
|
+
event("bridge", "handoff.pay", "sent", "BRIDGE handed the purchase intent to PAY for mandate and escrow preparation.", {"target": "pay", "purchaseIntentId": purchaseIntent["intentId"], "candidateId": candidateRef["candidateId"]})
|
|
898
|
+
event("bridge", "checkout.prepare", "purchaseAttempting", "BRIDGE prepared the source-bound browser checkout attempt.", {"connectorCount": checkoutConnectors.get("count", 0), "connectorId": checkoutConnectorId, "candidate": candidateRef})
|
|
899
|
+
amount = float(candidateRef.get("netUsd", 0.0) or 0.0)
|
|
900
|
+
vendorId = str(candidateRef.get("sourceId", "") or candidateRef["candidateId"])
|
|
901
|
+
event("pay", "prepare.request", "received", "PAY received the purchase intent from BRIDGE and is preparing mandate plus escrow.", {"purchaseIntentId": purchaseIntent["intentId"], "amount": amount, "vendorId": vendorId})
|
|
902
|
+
mandate = host.createMandate(
|
|
903
|
+
{
|
|
904
|
+
"mandateId": self._stableId("mandate", {"conversationId": conversationId, "candidateId": candidateRef["candidateId"], "intentId": purchaseIntent["intentId"]}),
|
|
905
|
+
"issuer": {
|
|
906
|
+
"entityId": clientId,
|
|
907
|
+
"publicKey": str(payload.get("publicKey", "clientPublicKey") or "clientPublicKey"),
|
|
908
|
+
},
|
|
909
|
+
"constraints": {
|
|
910
|
+
"maxSpend": {"amount": max(amount, 1.0), "currency": "USD"},
|
|
911
|
+
"allowedVendors": [vendorId],
|
|
912
|
+
"resolveRules": ["sourceBound", "purchaseIntent", "verifiedCompletionProof", "userConfirmedPurchase"],
|
|
913
|
+
},
|
|
914
|
+
"validUntil": "2099-01-01T00:00:00Z",
|
|
915
|
+
"signature": str(payload.get("signature", "clientSignature") or "clientSignature"),
|
|
916
|
+
"meta": {"conversationId": conversationId, "purchaseIntentId": purchaseIntent["intentId"]},
|
|
917
|
+
}
|
|
918
|
+
)
|
|
919
|
+
escrow = host.createEscrow(
|
|
920
|
+
{
|
|
921
|
+
"taskId": taskId,
|
|
922
|
+
"mandateId": mandate.get("mandateId", ""),
|
|
923
|
+
"status": "prepared",
|
|
924
|
+
"amount": {"amount": amount, "currency": "USD"},
|
|
925
|
+
"request": {
|
|
926
|
+
"taskId": taskId,
|
|
927
|
+
"conversationId": conversationId,
|
|
928
|
+
"purchaseIntentId": purchaseIntent["intentId"],
|
|
929
|
+
"vendorId": vendorId,
|
|
930
|
+
"amount": {"amount": amount, "currency": "USD"},
|
|
931
|
+
},
|
|
932
|
+
}
|
|
933
|
+
)
|
|
934
|
+
pay = {
|
|
935
|
+
"ok": True,
|
|
936
|
+
"status": "prepared",
|
|
937
|
+
"mandate": mandate,
|
|
938
|
+
"escrow": escrow,
|
|
939
|
+
"settlementGate": {"status": "locked", "reason": "awaitingVerifiedCompletionProof", "proofRequired": True},
|
|
940
|
+
}
|
|
941
|
+
if checkoutConnectorId:
|
|
942
|
+
event("bridge", "handoff.browserCheckout", "sent", "BRIDGE handed purchase execution to the Browser Checkout connector.", {"connectorId": checkoutConnectorId, "endpoint": checkoutConnector.get("endpoint", ""), "purchaseIntentId": purchaseIntent["intentId"], "candidateId": candidateRef["candidateId"]})
|
|
943
|
+
checkout = host.tools.checkoutBrowser(
|
|
944
|
+
{
|
|
945
|
+
**self._safePurchaseInputs(purchaseInputs),
|
|
946
|
+
"connectorId": checkoutConnectorId,
|
|
947
|
+
"url": candidateRef.get("url", ""),
|
|
948
|
+
"source": candidateRef.get("source", ""),
|
|
949
|
+
"candidateId": candidateRef["candidateId"],
|
|
950
|
+
"purchaseIntentId": purchaseIntent["intentId"],
|
|
951
|
+
"executePurchase": bool(payload.get("executePurchase", payload.get("allowPurchaseExecution", False))),
|
|
952
|
+
}
|
|
953
|
+
)
|
|
954
|
+
else:
|
|
955
|
+
checkout = {
|
|
956
|
+
"ok": False,
|
|
957
|
+
"tool": "browserCheckout",
|
|
958
|
+
"status": "blocked",
|
|
959
|
+
"reason": "bridgeCheckoutConnectorMissing",
|
|
960
|
+
"message": "Sorry, I am unable to make the purchase due to bridgeCheckoutConnectorMissing.",
|
|
961
|
+
"candidateId": candidateRef["candidateId"],
|
|
962
|
+
"source": candidateRef.get("source", ""),
|
|
963
|
+
"url": candidateRef.get("url", ""),
|
|
964
|
+
"executePurchase": bool(payload.get("executePurchase", payload.get("allowPurchaseExecution", False))),
|
|
965
|
+
}
|
|
966
|
+
toolResults["browserCheckout"] = checkout
|
|
967
|
+
purchaseOk = bool(checkout.get("ok"))
|
|
968
|
+
checkoutNeedsInput = self._checkoutNeedsInput(checkout)
|
|
969
|
+
purchaseStatus = "purchaseCompleted" if purchaseOk else ("NEEDS_INPUT" if checkoutNeedsInput else "purchaseBlocked")
|
|
970
|
+
purchaseOutcome = {
|
|
971
|
+
"ok": purchaseOk,
|
|
972
|
+
"status": "purchased" if purchaseOk else ("needsInput" if checkoutNeedsInput else "blocked"),
|
|
973
|
+
"message": "Item successfully purchased" if purchaseOk else f"Sorry, I am unable to make the purchase due to {checkout.get('reason', 'purchaseFailed')}.",
|
|
974
|
+
"reason": str(checkout.get("reason", "") or ""),
|
|
975
|
+
"checkout": checkout,
|
|
976
|
+
}
|
|
977
|
+
payExecution: dict[str, Any] = {}
|
|
978
|
+
if purchaseOk:
|
|
979
|
+
payExecution = self._settleCheckoutProof(
|
|
980
|
+
host,
|
|
981
|
+
escrow=escrow,
|
|
982
|
+
purchaseIntent=purchaseIntent,
|
|
983
|
+
candidateRef=candidateRef,
|
|
984
|
+
checkout=checkout,
|
|
985
|
+
event=event,
|
|
986
|
+
)
|
|
987
|
+
if payExecution:
|
|
988
|
+
pay = {**pay, **payExecution}
|
|
989
|
+
else:
|
|
990
|
+
pay["settlementGate"] = {"status": "proofReady", "reason": "browserPurchaseConfirmed", "proofRequired": True}
|
|
991
|
+
else:
|
|
992
|
+
pay["settlementGate"] = {"status": "locked", "reason": str(checkout.get("reason", "purchaseFailed") or "purchaseFailed"), "proofRequired": True}
|
|
993
|
+
if checkoutNeedsInput:
|
|
994
|
+
needs = checkout.get("needsInput", []) if isinstance(checkout.get("needsInput"), list) else []
|
|
995
|
+
requested = [str(item.get("key", "") or "") for item in needs if isinstance(item, dict) and str(item.get("key", "") or "")]
|
|
996
|
+
purchaseIntent = {**purchaseIntent, "status": "needsInput", "requestedKeys": requested, "needsInput": needs}
|
|
997
|
+
bridge = {**bridge, "status": "NEEDS_INPUT", "needsInput": needs, "requestedKeys": requested}
|
|
998
|
+
pay = {**pay, "status": "AWAITING_CHECKOUT_INPUT", "requestedKeys": requested, "settlementGate": {"status": "locked", "reason": str(checkout.get("reason", "checkoutInputRequired") or "checkoutInputRequired"), "proofRequired": True}}
|
|
999
|
+
results["bridge"] = bridge
|
|
1000
|
+
results["pay"] = pay
|
|
1001
|
+
results["browserCheckout"] = checkout
|
|
1002
|
+
results["purchaseIntent"] = purchaseIntent
|
|
1003
|
+
results["hunt"] = {**(results.get("hunt", {}) if isinstance(results.get("hunt"), dict) else {}), "purchaseOutcome": purchaseOutcome}
|
|
1004
|
+
results["resume"] = resume
|
|
1005
|
+
event("pay", "prepare.response", "prepared", "PAY prepared a mandate and proof-gated escrow for the purchase intent.", {"mandateId": mandate.get("mandateId", ""), "escrowId": escrow.get("escrow", {}).get("escrowId", "")})
|
|
1006
|
+
event("tools", "browserCheckout", str(checkout.get("status", "blocked")), "Browser tool attempted source-bound purchase execution and returned a concrete outcome.", {"ok": checkout.get("ok"), "reason": checkout.get("reason", ""), "candidateId": candidateRef["candidateId"]})
|
|
1007
|
+
event("hunt", "purchase.outcome", "purchased" if purchaseOk else "blocked", "HUNT received the purchase outcome from the browser checkout attempt.", purchaseOutcome)
|
|
1008
|
+
agentConversation.append("[Bridge] Making your purchase...")
|
|
1009
|
+
stream("conversation", {"line": agentConversation[-1]})
|
|
1010
|
+
if purchaseOk and pay.get("status") == "settled":
|
|
1011
|
+
agentConversation.append("[Pay] Payment settled against the verified checkout proof.")
|
|
1012
|
+
elif purchaseOk and payExecution:
|
|
1013
|
+
agentConversation.append(f"[Pay] Payment is still locked: {payExecution.get('reason', 'settlementNotReleased')}.")
|
|
1014
|
+
elif checkoutNeedsInput:
|
|
1015
|
+
agentConversation.append("[Pay] Payment remains locked while checkout waits for the required input.")
|
|
1016
|
+
else:
|
|
1017
|
+
agentConversation.append("[Pay] Mandate and escrow are prepared; settlement remains proof-gated.")
|
|
1018
|
+
stream("conversation", {"line": agentConversation[-1]})
|
|
1019
|
+
agentConversation.append("[Hunt] Item successfully purchased." if purchaseOk else purchaseOutcome["message"])
|
|
1020
|
+
stream("conversation", {"line": agentConversation[-1]})
|
|
1021
|
+
return self._finalizeResume(
|
|
1022
|
+
task=task,
|
|
1023
|
+
priorOut=priorOut,
|
|
1024
|
+
request=request,
|
|
1025
|
+
conversationId=conversationId,
|
|
1026
|
+
route=bridgeRoute,
|
|
1027
|
+
status=purchaseStatus,
|
|
1028
|
+
ok=True,
|
|
1029
|
+
results=results,
|
|
1030
|
+
trace=trace,
|
|
1031
|
+
agentConversation=agentConversation,
|
|
1032
|
+
event=event,
|
|
1033
|
+
stream=stream,
|
|
1034
|
+
taskStatus=ProtocolTaskStatus.PROCESSING.value,
|
|
1035
|
+
evidenceStatus="verified" if purchaseOk else "sourceBound",
|
|
1036
|
+
confidence=0.82 if purchaseOk else 0.58,
|
|
1037
|
+
openQuestions=[] if purchaseOk else ([str(item.get("key", "") or "") for item in checkout.get("needsInput", []) if isinstance(item, dict) and str(item.get("key", "") or "")] or [str(checkout.get("reason", "purchaseFailed") or "purchaseFailed")]),
|
|
1038
|
+
escalation={"recommended": not purchaseOk, "routeDomain": "bridge" if checkoutNeedsInput else ("hunt" if not purchaseOk else ""), "reason": "purchaseCompleted" if purchaseOk else str(checkout.get("reason", "purchaseFailed") or "purchaseFailed")},
|
|
1039
|
+
)
|
|
1040
|
+
|
|
1041
|
+
def _runLocalServiceResume(
|
|
1042
|
+
self,
|
|
1043
|
+
*,
|
|
1044
|
+
payload: dict[str, Any],
|
|
1045
|
+
task: dict[str, Any],
|
|
1046
|
+
priorOut: dict[str, Any],
|
|
1047
|
+
hunt: dict[str, Any],
|
|
1048
|
+
candidateRef: dict[str, Any],
|
|
1049
|
+
request: str,
|
|
1050
|
+
conversationId: str,
|
|
1051
|
+
taskId: str,
|
|
1052
|
+
clientId: str,
|
|
1053
|
+
channel: str,
|
|
1054
|
+
toolResults: dict[str, Any],
|
|
1055
|
+
trace: list[dict[str, Any]],
|
|
1056
|
+
agentConversation: list[str],
|
|
1057
|
+
event: Any,
|
|
1058
|
+
stream: Any,
|
|
1059
|
+
) -> dict[str, Any]:
|
|
1060
|
+
host = self.host
|
|
1061
|
+
originalRequest = self._originalTaskRequest(task) or request
|
|
1062
|
+
inputs = self._localServiceInputs(payload, candidateRef, defaultBrief=originalRequest)
|
|
1063
|
+
missingKeys = self._missingLocalServiceKeys(inputs, candidateRef)
|
|
1064
|
+
route = ["hunt", "bridge", "proxy"]
|
|
1065
|
+
proxyIntent = self._proxyContactIntent(conversationId, taskId, candidateRef, inputs, missingKeys)
|
|
1066
|
+
resumeStatus = "NEEDS_INPUT" if missingKeys else "awaitingProxy"
|
|
1067
|
+
resume = {
|
|
1068
|
+
"action": "contact",
|
|
1069
|
+
"status": resumeStatus,
|
|
1070
|
+
"conversationId": conversationId,
|
|
1071
|
+
"selectedCandidate": candidateRef,
|
|
1072
|
+
"proxyIntent": proxyIntent,
|
|
1073
|
+
"requestedKeys": missingKeys,
|
|
1074
|
+
"resumeToken": self._stableId("resume", {"conversationId": conversationId, "candidateId": candidateRef["candidateId"], "action": "contact"}),
|
|
1075
|
+
}
|
|
1076
|
+
installed = host._ensureReadyConnectors()
|
|
1077
|
+
bridgeConnectors = host.connectorBook.matchConnectors({"domain": "bridge", "caps": ["messages.send", "identity.verify", "consent.capture", "voice.call"]})
|
|
1078
|
+
connectorRecords = bridgeConnectors.get("connectors", []) if isinstance(bridgeConnectors.get("connectors"), list) else []
|
|
1079
|
+
connector = next((item for item in connectorRecords if isinstance(item, dict) and item.get("connectorId") == "whatsapp"), connectorRecords[0] if connectorRecords and isinstance(connectorRecords[0], dict) else {})
|
|
1080
|
+
connectorId = str(connector.get("connectorId", "") or "")
|
|
1081
|
+
event(
|
|
1082
|
+
"bridge",
|
|
1083
|
+
"connectorMatch.response",
|
|
1084
|
+
"matched" if connectorId else "missingConnector",
|
|
1085
|
+
"BRIDGE matched a human-contact connector for Proxy handoff." if connectorId else "BRIDGE could not match a human-contact connector for Proxy handoff.",
|
|
1086
|
+
{"connectorId": connectorId, "connectorCount": bridgeConnectors.get("count", 0), "caps": ["messages.send", "identity.verify", "consent.capture", "voice.call"]},
|
|
1087
|
+
)
|
|
1088
|
+
results: dict[str, Any] = {
|
|
1089
|
+
"tools": toolResults,
|
|
1090
|
+
"hunt": {**hunt, "resume": {"action": "contact", "status": "candidateValidated", "selectedCandidate": candidateRef}},
|
|
1091
|
+
"proxyIntent": proxyIntent,
|
|
1092
|
+
"resume": resume,
|
|
1093
|
+
}
|
|
1094
|
+
if missingKeys:
|
|
1095
|
+
bridge = {
|
|
1096
|
+
"ok": False,
|
|
1097
|
+
"status": "NEEDS_INPUT",
|
|
1098
|
+
"mode": "proxyContact",
|
|
1099
|
+
"requestedKeys": missingKeys,
|
|
1100
|
+
"readyConnectors": installed,
|
|
1101
|
+
"connectors": bridgeConnectors,
|
|
1102
|
+
"contact": {
|
|
1103
|
+
"action": "requestProxyContactInput",
|
|
1104
|
+
"connectorId": connectorId,
|
|
1105
|
+
"candidateId": candidateRef.get("candidateId", ""),
|
|
1106
|
+
"businessPhone": candidateRef.get("phone", ""),
|
|
1107
|
+
"preferredChannel": inputs.get("preferredChannel", "whatsapp"),
|
|
1108
|
+
},
|
|
1109
|
+
}
|
|
1110
|
+
proxy = {
|
|
1111
|
+
"ok": False,
|
|
1112
|
+
"status": "NEEDS_INPUT",
|
|
1113
|
+
"requestedKeys": missingKeys,
|
|
1114
|
+
"reason": "identityVerificationAndConsentRequiresInput",
|
|
1115
|
+
"proxyIntent": proxyIntent,
|
|
1116
|
+
}
|
|
1117
|
+
results["bridge"] = bridge
|
|
1118
|
+
results["proxy"] = proxy
|
|
1119
|
+
event("bridge", "proxy.prepare", "NEEDS_INPUT", "BRIDGE paused Proxy handoff until required consent and contact inputs are supplied.", {"requestedKeys": missingKeys, "candidate": candidateRef})
|
|
1120
|
+
event("proxy", "mission.awaitingInput", "NEEDS_INPUT", "PROXY is waiting for human-contact details before making a call or WhatsApp handoff.", {"requestedKeys": missingKeys})
|
|
1121
|
+
agentConversation.append(f"[Bridge] I need {', '.join(missingKeys)} before Proxy can contact the selected service.")
|
|
1122
|
+
stream("conversation", {"line": agentConversation[-1]})
|
|
1123
|
+
agentConversation.append("[Proxy] Waiting for consent and contact details before calling the selected human or business.")
|
|
1124
|
+
stream("conversation", {"line": agentConversation[-1]})
|
|
1125
|
+
return self._finalizeResume(
|
|
1126
|
+
task=task,
|
|
1127
|
+
priorOut=priorOut,
|
|
1128
|
+
request=request,
|
|
1129
|
+
conversationId=conversationId,
|
|
1130
|
+
route=route,
|
|
1131
|
+
status="NEEDS_INPUT",
|
|
1132
|
+
ok=True,
|
|
1133
|
+
results=results,
|
|
1134
|
+
trace=trace,
|
|
1135
|
+
agentConversation=agentConversation,
|
|
1136
|
+
event=event,
|
|
1137
|
+
stream=stream,
|
|
1138
|
+
taskStatus=ProtocolTaskStatus.PROCESSING.value,
|
|
1139
|
+
evidenceStatus="sourceBound",
|
|
1140
|
+
confidence=0.66,
|
|
1141
|
+
openQuestions=missingKeys,
|
|
1142
|
+
escalation={"recommended": True, "routeDomain": "proxy", "reason": "missingProxyContactInput"},
|
|
1143
|
+
)
|
|
1144
|
+
|
|
1145
|
+
businessPhone = str(inputs.get("businessPhone", candidateRef.get("phone", "")) or "")
|
|
1146
|
+
channelMsg = {
|
|
1147
|
+
"channel": str(inputs.get("preferredChannel", "whatsapp") or "whatsapp"),
|
|
1148
|
+
"to": businessPhone,
|
|
1149
|
+
"text": f"Picux request: verify identity and consent for {candidateRef.get('title', 'selected service')}. User need: {inputs.get('issueBrief', originalRequest)}",
|
|
1150
|
+
"connectorId": connectorId,
|
|
1151
|
+
}
|
|
1152
|
+
voiceCall = {
|
|
1153
|
+
"phone": businessPhone,
|
|
1154
|
+
"status": "readyToCall",
|
|
1155
|
+
"prompt": f"Verify identity, availability, and consent for a Picux user request. Service: {candidateRef.get('title', '')}. Brief: {inputs.get('issueBrief', originalRequest)}",
|
|
1156
|
+
"connectorId": connectorId,
|
|
1157
|
+
}
|
|
1158
|
+
bridge = {
|
|
1159
|
+
"ok": True,
|
|
1160
|
+
"status": "proxyHandoffReady",
|
|
1161
|
+
"mode": "proxyContact",
|
|
1162
|
+
"readyConnectors": installed,
|
|
1163
|
+
"connectors": bridgeConnectors,
|
|
1164
|
+
"handoffs": [{"from": "bridge", "to": "proxy", "connectorId": connectorId, "action": "identityVerificationAndConsent", "status": "sent" if connectorId else "adapterNeutral"}],
|
|
1165
|
+
"contact": {"connectorId": connectorId, "channelMsg": channelMsg, "voiceCall": voiceCall, "candidate": candidateRef},
|
|
1166
|
+
}
|
|
1167
|
+
event("bridge", "handoff.proxy", "sent", "BRIDGE handed selected service contact to PROXY for identity verification and consent.", {"connectorId": connectorId, "candidateId": candidateRef["candidateId"], "businessPhone": businessPhone})
|
|
1168
|
+
event("proxy", "mission.request", "sent", "PROXY received the selected service as a callable human/business node.", {"endpoint": "/v1/proxy/missions", "candidateId": candidateRef["candidateId"]})
|
|
1169
|
+
proxyStatus, proxy = host.createProxyMission(
|
|
1170
|
+
{
|
|
1171
|
+
"taskId": taskId,
|
|
1172
|
+
"userId": clientId,
|
|
1173
|
+
"kind": "call",
|
|
1174
|
+
"reason": "identityVerificationAndConsent",
|
|
1175
|
+
"task": f"Contact {candidateRef.get('title') or candidateRef.get('candidateId')} for identity verification and consent.",
|
|
1176
|
+
"context": {
|
|
1177
|
+
"missionState": {"route": route, "request": originalRequest, "clientId": clientId, "conversationId": conversationId},
|
|
1178
|
+
"decisionFork": {"options": ["verifyIdentityAndAvailability", "requestAnotherProvider", "stop"]},
|
|
1179
|
+
"logisticsBrief": {
|
|
1180
|
+
"why": "The user selected a local service candidate; a human/business must confirm identity, availability, and consent before the workflow can proceed.",
|
|
1181
|
+
"selectedService": candidateRef,
|
|
1182
|
+
"userPhone": inputs.get("userPhone", ""),
|
|
1183
|
+
"issueBrief": inputs.get("issueBrief", originalRequest),
|
|
1184
|
+
},
|
|
1185
|
+
"reason": "identityVerificationAndConsent",
|
|
1186
|
+
"channelMsg": channelMsg,
|
|
1187
|
+
"voiceCall": voiceCall,
|
|
1188
|
+
"connectorId": connectorId,
|
|
1189
|
+
},
|
|
1190
|
+
"channelMsg": channelMsg,
|
|
1191
|
+
"voiceCall": voiceCall,
|
|
1192
|
+
"executeProxy": True,
|
|
1193
|
+
"proofReq": ["callTranscript", "consentRecord", "identityConfirmation"],
|
|
1194
|
+
"resume": {"conversationId": conversationId, "candidateId": candidateRef["candidateId"], "action": "proxyOutcome"},
|
|
1195
|
+
}
|
|
1196
|
+
)
|
|
1197
|
+
proxyCreated = proxyStatus < 400 and bool(proxy.get("ok"))
|
|
1198
|
+
results["bridge"] = bridge
|
|
1199
|
+
results["proxy"] = proxy
|
|
1200
|
+
event("proxy", "mission.response", "awaitingProxy" if proxyCreated else "blocked", "PROXY suspended the run until the selected human/business responds." if proxyCreated else "PROXY mission could not be created.", {"proxyId": proxy.get("proxyId", ""), "status": proxyStatus})
|
|
1201
|
+
agentConversation.append("[Bridge] Routing the selected service through WhatsApp or voice contact.")
|
|
1202
|
+
stream("conversation", {"line": agentConversation[-1]})
|
|
1203
|
+
agentConversation.append("[Proxy] Calling the selected human or business for identity verification and consent.")
|
|
1204
|
+
stream("conversation", {"line": agentConversation[-1]})
|
|
1205
|
+
return self._finalizeResume(
|
|
1206
|
+
task=task,
|
|
1207
|
+
priorOut=priorOut,
|
|
1208
|
+
request=request,
|
|
1209
|
+
conversationId=conversationId,
|
|
1210
|
+
route=route,
|
|
1211
|
+
status="awaitingProxy" if proxyCreated else "proxyBlocked",
|
|
1212
|
+
ok=proxyCreated,
|
|
1213
|
+
results=results,
|
|
1214
|
+
trace=trace,
|
|
1215
|
+
agentConversation=agentConversation,
|
|
1216
|
+
event=event,
|
|
1217
|
+
stream=stream,
|
|
1218
|
+
taskStatus=ProtocolTaskStatus.AWAITING_PROXY.value if proxyCreated else ProtocolTaskStatus.PROCESSING.value,
|
|
1219
|
+
evidenceStatus="sourceBound",
|
|
1220
|
+
confidence=0.74 if proxyCreated else 0.5,
|
|
1221
|
+
openQuestions=["awaitingProxyOutcome"] if proxyCreated else ["proxyMissionRequired"],
|
|
1222
|
+
escalation={"recommended": True, "routeDomain": "proxy", "reason": "awaitingProxy" if proxyCreated else "proxyBlocked"},
|
|
1223
|
+
)
|
|
1224
|
+
|
|
1225
|
+
def _runCandidateContactResume(
|
|
1226
|
+
self,
|
|
1227
|
+
*,
|
|
1228
|
+
payload: dict[str, Any],
|
|
1229
|
+
task: dict[str, Any],
|
|
1230
|
+
priorOut: dict[str, Any],
|
|
1231
|
+
hunt: dict[str, Any],
|
|
1232
|
+
candidateRef: dict[str, Any],
|
|
1233
|
+
request: str,
|
|
1234
|
+
conversationId: str,
|
|
1235
|
+
taskId: str,
|
|
1236
|
+
clientId: str,
|
|
1237
|
+
toolResults: dict[str, Any],
|
|
1238
|
+
trace: list[dict[str, Any]],
|
|
1239
|
+
agentConversation: list[str],
|
|
1240
|
+
event: Any,
|
|
1241
|
+
stream: Any,
|
|
1242
|
+
) -> dict[str, Any]:
|
|
1243
|
+
host = self.host
|
|
1244
|
+
contact = str(payload.get("sellerContact", payload.get("contact", payload.get("to", ""))) or "")
|
|
1245
|
+
message = str(payload.get("message", payload.get("text", request)) or request)
|
|
1246
|
+
missing = []
|
|
1247
|
+
if not contact:
|
|
1248
|
+
missing.append("sellerContact")
|
|
1249
|
+
if not message.strip():
|
|
1250
|
+
missing.append("message")
|
|
1251
|
+
route = ["hunt", "bridge", "proxy"]
|
|
1252
|
+
resume = {
|
|
1253
|
+
"action": "contact",
|
|
1254
|
+
"status": "NEEDS_INPUT" if missing else "awaitingProxy",
|
|
1255
|
+
"conversationId": conversationId,
|
|
1256
|
+
"selectedCandidate": candidateRef,
|
|
1257
|
+
"requestedKeys": missing,
|
|
1258
|
+
}
|
|
1259
|
+
results: dict[str, Any] = {"tools": toolResults, "hunt": {**hunt, "resume": resume}, "resume": resume}
|
|
1260
|
+
if missing:
|
|
1261
|
+
results["bridge"] = {"ok": False, "status": "NEEDS_INPUT", "requestedKeys": missing, "candidate": candidateRef}
|
|
1262
|
+
results["proxy"] = {"ok": False, "status": "NEEDS_INPUT", "requestedKeys": missing, "reason": "sellerContactRequired"}
|
|
1263
|
+
event("bridge", "sellerContact.awaitingInput", "NEEDS_INPUT", "BRIDGE needs a seller contact or channel reference before Proxy can contact this source.", {"candidate": candidateRef, "requestedKeys": missing})
|
|
1264
|
+
agentConversation.append("[Bridge] I need the seller contact or channel reference before Proxy can contact this option.")
|
|
1265
|
+
stream("conversation", {"line": agentConversation[-1]})
|
|
1266
|
+
return self._finalizeResume(
|
|
1267
|
+
task=task,
|
|
1268
|
+
priorOut=priorOut,
|
|
1269
|
+
request=request,
|
|
1270
|
+
conversationId=conversationId,
|
|
1271
|
+
route=route,
|
|
1272
|
+
status="NEEDS_INPUT",
|
|
1273
|
+
ok=True,
|
|
1274
|
+
results=results,
|
|
1275
|
+
trace=trace,
|
|
1276
|
+
agentConversation=agentConversation,
|
|
1277
|
+
event=event,
|
|
1278
|
+
stream=stream,
|
|
1279
|
+
taskStatus=ProtocolTaskStatus.PROCESSING.value,
|
|
1280
|
+
evidenceStatus="sourceBound",
|
|
1281
|
+
confidence=0.6,
|
|
1282
|
+
openQuestions=missing,
|
|
1283
|
+
escalation={"recommended": True, "routeDomain": "bridge", "reason": "sellerContactRequired"},
|
|
1284
|
+
)
|
|
1285
|
+
installed = host._ensureReadyConnectors()
|
|
1286
|
+
isPhone = bool(re.search(r"\+?\d[\d\s().-]{6,}", contact))
|
|
1287
|
+
bridgeConnectors = host.connectorBook.matchConnectors({"domain": "bridge", "caps": ["voice.call" if isPhone else "messages.send"]})
|
|
1288
|
+
connectorRecords = bridgeConnectors.get("connectors", []) if isinstance(bridgeConnectors.get("connectors"), list) else []
|
|
1289
|
+
preferred = "twilioVoice" if isPhone else "whatsapp"
|
|
1290
|
+
connector = next((item for item in connectorRecords if isinstance(item, dict) and item.get("connectorId") == preferred), connectorRecords[0] if connectorRecords and isinstance(connectorRecords[0], dict) else {})
|
|
1291
|
+
connectorId = str(connector.get("connectorId", "") or "")
|
|
1292
|
+
voiceCall = {"phone": contact, "status": "readyToCall", "prompt": message, "connectorId": connectorId} if isPhone else {}
|
|
1293
|
+
channelMsg = {"channel": "message", "to": contact, "text": message, "connectorId": connectorId} if not isPhone else {}
|
|
1294
|
+
proxyStatus, proxy = host.createProxyMission(
|
|
1295
|
+
{
|
|
1296
|
+
"taskId": taskId,
|
|
1297
|
+
"userId": clientId,
|
|
1298
|
+
"kind": "call" if isPhone else "message",
|
|
1299
|
+
"reason": "sellerContact",
|
|
1300
|
+
"task": f"Contact the selected source-bound option: {candidateRef.get('title') or candidateRef.get('candidateId')}.",
|
|
1301
|
+
"context": {
|
|
1302
|
+
"missionState": {"route": route, "request": request, "clientId": clientId, "conversationId": conversationId},
|
|
1303
|
+
"selectedCandidate": candidateRef,
|
|
1304
|
+
"sellerContact": contact,
|
|
1305
|
+
"reason": "sellerContact",
|
|
1306
|
+
"connectorId": connectorId,
|
|
1307
|
+
},
|
|
1308
|
+
"voiceCall": voiceCall,
|
|
1309
|
+
"channelMsg": channelMsg,
|
|
1310
|
+
"executeProxy": True,
|
|
1311
|
+
"proofReq": ["messageTranscript", "availabilityConfirmation", "consentRecord"],
|
|
1312
|
+
"resume": {"conversationId": conversationId, "candidateId": candidateRef["candidateId"], "action": "proxyOutcome"},
|
|
1313
|
+
}
|
|
1314
|
+
)
|
|
1315
|
+
proxyCreated = proxyStatus < 400 and bool(proxy.get("ok"))
|
|
1316
|
+
results["bridge"] = {"ok": bool(connectorId), "status": "proxyHandoffReady" if connectorId else "missingConnector", "readyConnectors": installed, "connectors": bridgeConnectors}
|
|
1317
|
+
results["proxy"] = proxy
|
|
1318
|
+
event("proxy", "mission.response", "awaitingProxy" if proxyCreated else "blocked", "PROXY started seller contact for the selected source-bound option." if proxyCreated else "PROXY could not start seller contact.", {"proxyId": proxy.get("proxyId", ""), "connectorId": connectorId})
|
|
1319
|
+
agentConversation.append("[Proxy] Contacting the selected source and waiting for confirmation proof.")
|
|
1320
|
+
stream("conversation", {"line": agentConversation[-1]})
|
|
1321
|
+
return self._finalizeResume(
|
|
1322
|
+
task=task,
|
|
1323
|
+
priorOut=priorOut,
|
|
1324
|
+
request=request,
|
|
1325
|
+
conversationId=conversationId,
|
|
1326
|
+
route=route,
|
|
1327
|
+
status="awaitingProxy" if proxyCreated else "proxyBlocked",
|
|
1328
|
+
ok=proxyCreated,
|
|
1329
|
+
results=results,
|
|
1330
|
+
trace=trace,
|
|
1331
|
+
agentConversation=agentConversation,
|
|
1332
|
+
event=event,
|
|
1333
|
+
stream=stream,
|
|
1334
|
+
taskStatus=ProtocolTaskStatus.AWAITING_PROXY.value if proxyCreated else ProtocolTaskStatus.PROCESSING.value,
|
|
1335
|
+
evidenceStatus="sourceBound",
|
|
1336
|
+
confidence=0.7 if proxyCreated else 0.48,
|
|
1337
|
+
openQuestions=["awaitingProxyOutcome"] if proxyCreated else ["proxyMissionRequired"],
|
|
1338
|
+
escalation={"recommended": True, "routeDomain": "proxy", "reason": "awaitingProxy" if proxyCreated else "proxyBlocked"},
|
|
1339
|
+
)
|
|
1340
|
+
|
|
1341
|
+
def _finalizeResume(
|
|
1342
|
+
self,
|
|
1343
|
+
*,
|
|
1344
|
+
task: dict[str, Any],
|
|
1345
|
+
priorOut: dict[str, Any],
|
|
1346
|
+
request: str,
|
|
1347
|
+
conversationId: str,
|
|
1348
|
+
route: list[str],
|
|
1349
|
+
status: str,
|
|
1350
|
+
ok: bool,
|
|
1351
|
+
results: dict[str, Any],
|
|
1352
|
+
trace: list[dict[str, Any]],
|
|
1353
|
+
agentConversation: list[str],
|
|
1354
|
+
event: Any,
|
|
1355
|
+
stream: Any,
|
|
1356
|
+
taskStatus: str,
|
|
1357
|
+
evidenceStatus: str,
|
|
1358
|
+
confidence: float,
|
|
1359
|
+
openQuestions: list[str],
|
|
1360
|
+
escalation: dict[str, Any],
|
|
1361
|
+
) -> dict[str, Any]:
|
|
1362
|
+
host = self.host
|
|
1363
|
+
taskId = str(task.get("taskId", "") or conversationId or self._stableId("task", {"request": request, "status": status}))
|
|
1364
|
+
priorResults = priorOut.get("results", {}) if isinstance(priorOut.get("results"), dict) else {}
|
|
1365
|
+
mergedResults = {**priorResults, **results}
|
|
1366
|
+
decisionTree = self._decisionTree(mergedResults.get("hunt", {}))
|
|
1367
|
+
event("orchestrator", "proofPack", "created", "Picux updated the portable proof card for the resumed conversation.", {"status": status, "conversationId": conversationId})
|
|
1368
|
+
proof = host._proofPackForRun(
|
|
1369
|
+
taskId=taskId,
|
|
1370
|
+
domain="orchestrator",
|
|
1371
|
+
title="Picux resumed conversation",
|
|
1372
|
+
summary=f"Picux resumed {conversationId or taskId} through {', '.join(route).upper()} with status {status}.",
|
|
1373
|
+
payload={"request": request, "conversationId": conversationId, "route": route, "results": mergedResults},
|
|
1374
|
+
decisions=[{"label": item["name"], "reason": item["status"]} for item in trace if item["phase"] != "orchestrator"],
|
|
1375
|
+
evidenceStatus=evidenceStatus,
|
|
1376
|
+
confidence=confidence,
|
|
1377
|
+
openQuestions=openQuestions,
|
|
1378
|
+
escalation=escalation,
|
|
1379
|
+
)
|
|
1380
|
+
agentConversation.append("[Picux] I updated the conversation state, next action, evidence status, open questions, and proof card.")
|
|
1381
|
+
stream("conversation", {"line": agentConversation[-1]})
|
|
1382
|
+
resumeState = results.get("resume", {}) if isinstance(results.get("resume"), dict) else {}
|
|
1383
|
+
nextOut = {
|
|
1384
|
+
**priorOut,
|
|
1385
|
+
"conversationId": conversationId,
|
|
1386
|
+
"route": route,
|
|
1387
|
+
"status": status,
|
|
1388
|
+
"results": mergedResults,
|
|
1389
|
+
"resume": resumeState,
|
|
1390
|
+
"decisionTree": decisionTree,
|
|
1391
|
+
"proofPack": proof.get("proofPack", {}),
|
|
1392
|
+
}
|
|
1393
|
+
nextMeta = {**(task.get("meta", {}) if isinstance(task.get("meta"), dict) else {}), "conversationId": conversationId}
|
|
1394
|
+
updated = host.taskBook.updateTask(taskId, {"status": taskStatus, "outData": nextOut, "meta": nextMeta})
|
|
1395
|
+
if updated.get("ok"):
|
|
1396
|
+
task = updated["task"]
|
|
1397
|
+
pay = mergedResults.get("pay", {}) if isinstance(mergedResults.get("pay"), dict) else {}
|
|
1398
|
+
mandateResult = pay.get("mandate", {}) if isinstance(pay.get("mandate"), dict) else {}
|
|
1399
|
+
return {
|
|
1400
|
+
"ok": ok,
|
|
1401
|
+
"scenario": "picuxOrchestrator",
|
|
1402
|
+
"status": status,
|
|
1403
|
+
"conversationId": conversationId,
|
|
1404
|
+
"request": request,
|
|
1405
|
+
"route": route,
|
|
1406
|
+
"task": task,
|
|
1407
|
+
"trace": trace,
|
|
1408
|
+
"agentConversation": agentConversation,
|
|
1409
|
+
"results": mergedResults,
|
|
1410
|
+
"decisionTree": decisionTree,
|
|
1411
|
+
"mandate": mandateResult.get("mandate", mandateResult) if isinstance(mandateResult, dict) else {},
|
|
1412
|
+
"receipt": None,
|
|
1413
|
+
"freezeReceipt": None,
|
|
1414
|
+
"proofPack": proof.get("proofPack", {}),
|
|
1415
|
+
"proofCard": proof.get("proofPack", {}).get("proofCard", {}),
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
def _resumeFailure(
|
|
1419
|
+
self,
|
|
1420
|
+
*,
|
|
1421
|
+
request: str,
|
|
1422
|
+
route: list[str],
|
|
1423
|
+
status: str,
|
|
1424
|
+
error: str,
|
|
1425
|
+
conversationId: str,
|
|
1426
|
+
clientId: str,
|
|
1427
|
+
channel: str,
|
|
1428
|
+
trace: list[dict[str, Any]],
|
|
1429
|
+
agentConversation: list[str],
|
|
1430
|
+
results: dict[str, Any],
|
|
1431
|
+
event: Any,
|
|
1432
|
+
stream: Any,
|
|
1433
|
+
) -> dict[str, Any]:
|
|
1434
|
+
taskId = self._stableId("resume", {"request": request, "conversationId": conversationId, "error": error})
|
|
1435
|
+
event("orchestrator", "proofPack", "created", "Picux created a proof card for the failed resume attempt.", {"status": status, "error": error})
|
|
1436
|
+
proof = self.host._proofPackForRun(
|
|
1437
|
+
taskId=taskId,
|
|
1438
|
+
domain="orchestrator",
|
|
1439
|
+
title="Picux resume failed",
|
|
1440
|
+
summary=f"Picux could not resume the conversation: {error}.",
|
|
1441
|
+
payload={"request": request, "conversationId": conversationId, "client": {"clientId": clientId, "channel": channel}, "results": results},
|
|
1442
|
+
decisions=[{"label": item["name"], "reason": item["status"]} for item in trace if item["phase"] != "orchestrator"],
|
|
1443
|
+
evidenceStatus="needsEvidence",
|
|
1444
|
+
confidence=0.2,
|
|
1445
|
+
openQuestions=["validConversationIdRequired"],
|
|
1446
|
+
escalation={"recommended": True, "routeDomain": "orchestrator", "reason": error},
|
|
1447
|
+
)
|
|
1448
|
+
agentConversation.append("[Picux] I could not resume that conversation. Send a valid conversationId from the previous result.")
|
|
1449
|
+
stream("conversation", {"line": agentConversation[-1]})
|
|
1450
|
+
return {
|
|
1451
|
+
"ok": False,
|
|
1452
|
+
"scenario": "picuxOrchestrator",
|
|
1453
|
+
"status": status,
|
|
1454
|
+
"conversationId": conversationId,
|
|
1455
|
+
"request": request,
|
|
1456
|
+
"route": route,
|
|
1457
|
+
"task": {},
|
|
1458
|
+
"trace": trace,
|
|
1459
|
+
"agentConversation": agentConversation,
|
|
1460
|
+
"results": {**results, "resume": {"ok": False, "error": error, "conversationId": conversationId}},
|
|
1461
|
+
"decisionTree": {"root": "userRequest", "selected": "", "paths": []},
|
|
1462
|
+
"mandate": {},
|
|
1463
|
+
"receipt": None,
|
|
1464
|
+
"freezeReceipt": None,
|
|
1465
|
+
"proofPack": proof.get("proofPack", {}),
|
|
1466
|
+
"proofCard": proof.get("proofPack", {}).get("proofCard", {}),
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
def _route(self, request: str, payload: dict[str, Any]) -> list[str]:
|
|
1470
|
+
rawRoute = payload.get("domainRoute", payload.get("route", []))
|
|
1471
|
+
route = [str(item).strip().lower() for item in rawRoute if isinstance(item, str)] if isinstance(rawRoute, list) else []
|
|
1472
|
+
route = [item for item in route if item in self.domains]
|
|
1473
|
+
resolveCase = self._isResolveCase(request)
|
|
1474
|
+
if not route:
|
|
1475
|
+
seed = self.host.router.classify(request).domain.value
|
|
1476
|
+
explicitResolve = resolveCase
|
|
1477
|
+
explicitPay = any(token in request.lower() for token in ("pay", "payment", "settle", "settlement", "escrow"))
|
|
1478
|
+
if seed == "pay" and not explicitPay:
|
|
1479
|
+
seed = "resolve" if explicitResolve else "hunt"
|
|
1480
|
+
if seed == "proxy" and not self._shouldCreateProxy(request, payload):
|
|
1481
|
+
seed = "resolve" if explicitResolve else "hunt"
|
|
1482
|
+
if explicitResolve and seed in {"hunt", "bridge"}:
|
|
1483
|
+
seed = "resolve"
|
|
1484
|
+
route = [seed]
|
|
1485
|
+
|
|
1486
|
+
lowered = request.lower()
|
|
1487
|
+
if not resolveCase and self._isLocalServiceRequest(request, payload, {"entities": {}}) and "hunt" not in route:
|
|
1488
|
+
route.insert(0, "hunt")
|
|
1489
|
+
if not resolveCase and any(token in lowered for token in ("find", "near", "within", "km", "buy", "shop", "shopping", "best", "option", "lead", "opportunity", "source", "market", "mechanic", "plumber", "electrician", "repair")) and "hunt" not in route:
|
|
1490
|
+
route.insert(0, "hunt")
|
|
1491
|
+
if resolveCase and "resolve" not in route:
|
|
1492
|
+
route.append("resolve")
|
|
1493
|
+
if any(token in lowered for token in ("email", "merchant", "bank", "service", "support", "api", "webhook", "connector", "whatsapp", "chat", "representative", "followup", "follow up")) and "bridge" not in route:
|
|
1494
|
+
insertAt = route.index("resolve") + 1 if "resolve" in route else len(route)
|
|
1495
|
+
route.insert(insertAt, "bridge")
|
|
1496
|
+
if self._shouldCreateProxy(request, payload) and "proxy" not in route:
|
|
1497
|
+
route.append("proxy")
|
|
1498
|
+
if any(token in lowered for token in ("pay", "payment", "settle", "settlement", "escrow")) and "pay" not in route:
|
|
1499
|
+
route.append("pay")
|
|
1500
|
+
|
|
1501
|
+
if "pay" in route and "resolve" not in route:
|
|
1502
|
+
route.insert(max(1, route.index("pay")), "resolve")
|
|
1503
|
+
route = list(dict.fromkeys(route or ["hunt"]))
|
|
1504
|
+
if resolveCase and "hunt" not in route:
|
|
1505
|
+
order = ["resolve", "bridge", "proxy", "pay"]
|
|
1506
|
+
return [domain for domain in order if domain in route]
|
|
1507
|
+
return route
|
|
1508
|
+
|
|
1509
|
+
@staticmethod
|
|
1510
|
+
def _ensureRouteAfter(route: list[str], *, after: str, domains: list[str]) -> None:
|
|
1511
|
+
insertAt = route.index(after) + 1 if after in route else len(route)
|
|
1512
|
+
for domain in domains:
|
|
1513
|
+
if domain in route:
|
|
1514
|
+
continue
|
|
1515
|
+
route.insert(insertAt, domain)
|
|
1516
|
+
insertAt += 1
|
|
1517
|
+
|
|
1518
|
+
def _verifiedLocalServiceMapResult(self, host: Any, request: str, payload: dict[str, Any], nlp: dict[str, Any]) -> dict[str, Any]:
|
|
1519
|
+
if not hasattr(host, "localProviderBook"):
|
|
1520
|
+
return {}
|
|
1521
|
+
entities = nlp.get("entities", {}) if isinstance(nlp.get("entities"), dict) else {}
|
|
1522
|
+
entity = str(payload.get("entity", payload.get("service", entities.get("service", entities.get("entity", "")))) or "")
|
|
1523
|
+
location = str(payload.get("location", payload.get("near", entities.get("location", ""))) or "")
|
|
1524
|
+
radiusKm = self._float(payload.get("radiusKm", payload.get("distanceKm", entities.get("radiusKm", 10.0))))
|
|
1525
|
+
places = host.localProviderBook.toPlaces(service=entity, location=location, limit=10)
|
|
1526
|
+
if not places:
|
|
1527
|
+
return {}
|
|
1528
|
+
return {
|
|
1529
|
+
"ok": True,
|
|
1530
|
+
"tool": "mapTool",
|
|
1531
|
+
"provider": "verifiedProviders",
|
|
1532
|
+
"status": "found",
|
|
1533
|
+
"query": {
|
|
1534
|
+
"text": request,
|
|
1535
|
+
"entity": entity or self._itemId(request),
|
|
1536
|
+
"location": location,
|
|
1537
|
+
"radiusKm": radiusKm or 10.0,
|
|
1538
|
+
"radiusMeters": int(max(1.0, radiusKm or 10.0) * 1000),
|
|
1539
|
+
},
|
|
1540
|
+
"places": places,
|
|
1541
|
+
"errors": [],
|
|
1542
|
+
"networkAttempted": False,
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
@classmethod
|
|
1546
|
+
def _publicMapResult(cls, mapResult: dict[str, Any]) -> dict[str, Any]:
|
|
1547
|
+
public = json.loads(json.dumps(mapResult, ensure_ascii=True, default=str))
|
|
1548
|
+
places = public.get("places", []) if isinstance(public.get("places"), list) else []
|
|
1549
|
+
public["places"] = [cls._publicPlace(place) for place in places if isinstance(place, dict)]
|
|
1550
|
+
return public
|
|
1551
|
+
|
|
1552
|
+
@staticmethod
|
|
1553
|
+
def _publicPlace(place: dict[str, Any]) -> dict[str, Any]:
|
|
1554
|
+
clean = dict(place)
|
|
1555
|
+
for key in ("phone", "phoneNumber", "international_phone_number", "formatted_phone_number"):
|
|
1556
|
+
clean.pop(key, None)
|
|
1557
|
+
return clean
|
|
1558
|
+
|
|
1559
|
+
@classmethod
|
|
1560
|
+
def _localServiceContactRefs(cls, mapResult: dict[str, Any], offers: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
|
1561
|
+
places = mapResult.get("places", []) if isinstance(mapResult.get("places"), list) else []
|
|
1562
|
+
query = mapResult.get("query", {}) if isinstance(mapResult.get("query"), dict) else {}
|
|
1563
|
+
offersByPlaceId = {}
|
|
1564
|
+
offersByIndex = {}
|
|
1565
|
+
for offer in offers:
|
|
1566
|
+
meta = offer.get("meta", {}) if isinstance(offer.get("meta"), dict) else {}
|
|
1567
|
+
placeId = str(meta.get("placeId", "") or "")
|
|
1568
|
+
if placeId:
|
|
1569
|
+
offersByPlaceId[placeId] = offer
|
|
1570
|
+
offersByIndex[int(meta.get("placeIndex", 0) or 0)] = offer
|
|
1571
|
+
contacts: dict[str, dict[str, Any]] = {}
|
|
1572
|
+
for index, place in enumerate(places[:10], start=1):
|
|
1573
|
+
if not isinstance(place, dict):
|
|
1574
|
+
continue
|
|
1575
|
+
placeId = str(place.get("placeId", "") or "")
|
|
1576
|
+
offer = offersByPlaceId.get(placeId) or offersByIndex.get(index) or {}
|
|
1577
|
+
meta = offer.get("meta", {}) if isinstance(offer.get("meta"), dict) else {}
|
|
1578
|
+
candidateId = str(offer.get("candidateId", offer.get("sourceId", meta.get("candidateId", ""))) or "")
|
|
1579
|
+
contactRef = str(meta.get("contactRef", place.get("contactRef", "")) or cls._contactRefForPlace(place, candidateId))
|
|
1580
|
+
contact = {
|
|
1581
|
+
"providerId": contactRef,
|
|
1582
|
+
"contactRef": contactRef,
|
|
1583
|
+
"candidateId": candidateId or contactRef,
|
|
1584
|
+
"service": str(query.get("entity", place.get("entity", "")) or ""),
|
|
1585
|
+
"entity": str(query.get("entity", place.get("entity", "")) or ""),
|
|
1586
|
+
"location": str(query.get("location", "") or ""),
|
|
1587
|
+
"name": str(place.get("name", "") or ""),
|
|
1588
|
+
"category": str(place.get("category", "") or ""),
|
|
1589
|
+
"address": str(place.get("address", "") or ""),
|
|
1590
|
+
"phone": str(place.get("phone", "") or ""),
|
|
1591
|
+
"website": str(place.get("website", "") or ""),
|
|
1592
|
+
"rating": place.get("rating", 0),
|
|
1593
|
+
"lat": place.get("lat", 0),
|
|
1594
|
+
"lng": place.get("lng", 0),
|
|
1595
|
+
"distanceKm": place.get("distanceKm", 0),
|
|
1596
|
+
"source": str(place.get("source", mapResult.get("provider", "")) or ""),
|
|
1597
|
+
"sourceUrl": str(place.get("sourceUrl", "") or ""),
|
|
1598
|
+
"meta": {"placeId": placeId, "provider": mapResult.get("provider", ""), "sourceBound": bool(place.get("sourceBound"))},
|
|
1599
|
+
}
|
|
1600
|
+
contacts[contact["candidateId"]] = contact
|
|
1601
|
+
contacts[contactRef] = contact
|
|
1602
|
+
if placeId:
|
|
1603
|
+
contacts[placeId] = contact
|
|
1604
|
+
return contacts
|
|
1605
|
+
|
|
1606
|
+
@staticmethod
|
|
1607
|
+
def _cacheLocalServiceLeads(host: Any, contacts: dict[str, dict[str, Any]]) -> None:
|
|
1608
|
+
if not hasattr(host, "localProviderBook"):
|
|
1609
|
+
return
|
|
1610
|
+
seen: set[str] = set()
|
|
1611
|
+
for contact in contacts.values():
|
|
1612
|
+
contactRef = str(contact.get("contactRef", "") or "")
|
|
1613
|
+
if not contactRef or contactRef in seen:
|
|
1614
|
+
continue
|
|
1615
|
+
seen.add(contactRef)
|
|
1616
|
+
try:
|
|
1617
|
+
host.localProviderBook.saveLead(contact)
|
|
1618
|
+
except Exception:
|
|
1619
|
+
continue
|
|
1620
|
+
|
|
1621
|
+
@classmethod
|
|
1622
|
+
def _selectedLocalServiceContact(cls, hunt: dict[str, Any], contacts: dict[str, dict[str, Any]]) -> dict[str, Any]:
|
|
1623
|
+
if not isinstance(hunt, dict):
|
|
1624
|
+
return {}
|
|
1625
|
+
selected = hunt.get("selected", {}) if isinstance(hunt.get("selected"), dict) else {}
|
|
1626
|
+
meta = selected.get("meta", {}) if isinstance(selected.get("meta"), dict) else {}
|
|
1627
|
+
keys = [
|
|
1628
|
+
str(selected.get("candidateId", "") or ""),
|
|
1629
|
+
str(selected.get("sourceId", "") or ""),
|
|
1630
|
+
str(meta.get("contactRef", "") or ""),
|
|
1631
|
+
str(meta.get("placeId", "") or ""),
|
|
1632
|
+
]
|
|
1633
|
+
for key in keys:
|
|
1634
|
+
if key and key in contacts:
|
|
1635
|
+
return contacts[key]
|
|
1636
|
+
if selected and cls._isLocalServiceCandidate(cls._candidateRef(selected)):
|
|
1637
|
+
ref = cls._candidateRef(selected)
|
|
1638
|
+
contactRef = str(meta.get("contactRef", ref.get("candidateId", "")) or "")
|
|
1639
|
+
return {
|
|
1640
|
+
"providerId": contactRef or ref.get("candidateId", ""),
|
|
1641
|
+
"contactRef": contactRef or ref.get("candidateId", ""),
|
|
1642
|
+
"candidateId": ref.get("candidateId", ""),
|
|
1643
|
+
"service": str(meta.get("entity", selected.get("itemId", "")) or ""),
|
|
1644
|
+
"entity": str(meta.get("entity", selected.get("itemId", "")) or ""),
|
|
1645
|
+
"location": "",
|
|
1646
|
+
"name": ref.get("title", ""),
|
|
1647
|
+
"category": "",
|
|
1648
|
+
"address": ref.get("address", ""),
|
|
1649
|
+
"website": ref.get("website", ""),
|
|
1650
|
+
"rating": ref.get("rating", 0),
|
|
1651
|
+
"source": ref.get("source", ""),
|
|
1652
|
+
"sourceUrl": ref.get("url", ""),
|
|
1653
|
+
"meta": {"sourceBound": True},
|
|
1654
|
+
}
|
|
1655
|
+
return {}
|
|
1656
|
+
|
|
1657
|
+
@staticmethod
|
|
1658
|
+
def _publicLocalServiceContact(contact: dict[str, Any]) -> dict[str, Any]:
|
|
1659
|
+
return {
|
|
1660
|
+
"providerId": str(contact.get("providerId", contact.get("contactRef", "")) or ""),
|
|
1661
|
+
"contactRef": str(contact.get("contactRef", contact.get("providerId", "")) or ""),
|
|
1662
|
+
"candidateId": str(contact.get("candidateId", "") or ""),
|
|
1663
|
+
"service": str(contact.get("service", contact.get("entity", "")) or ""),
|
|
1664
|
+
"location": str(contact.get("location", "") or ""),
|
|
1665
|
+
"name": str(contact.get("name", "") or ""),
|
|
1666
|
+
"category": str(contact.get("category", "") or ""),
|
|
1667
|
+
"address": str(contact.get("address", "") or ""),
|
|
1668
|
+
"website": str(contact.get("website", "") or ""),
|
|
1669
|
+
"rating": contact.get("rating", 0),
|
|
1670
|
+
"distanceKm": contact.get("distanceKm", 0),
|
|
1671
|
+
"source": str(contact.get("source", "") or ""),
|
|
1672
|
+
"sourceUrl": str(contact.get("sourceUrl", "") or ""),
|
|
1673
|
+
"status": str(contact.get("status", "lead") or "lead"),
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
@staticmethod
|
|
1677
|
+
def _selectBridgeConnector(match: dict[str, Any], *, preferred: tuple[str, ...]) -> dict[str, Any]:
|
|
1678
|
+
records = [item for item in match.get("connectors", []) if isinstance(item, dict)] if isinstance(match, dict) else []
|
|
1679
|
+
if not records:
|
|
1680
|
+
return {}
|
|
1681
|
+
preferredIndex = {connectorId: index for index, connectorId in enumerate(preferred)}
|
|
1682
|
+
|
|
1683
|
+
def score(item: dict[str, Any]) -> tuple[int, int, int]:
|
|
1684
|
+
meta = item.get("meta", {}) if isinstance(item.get("meta"), dict) else {}
|
|
1685
|
+
connectorId = str(item.get("connectorId", "") or "")
|
|
1686
|
+
return (
|
|
1687
|
+
1 if not meta.get("readyMade") else 0,
|
|
1688
|
+
len(preferred) - preferredIndex.get(connectorId, len(preferred)),
|
|
1689
|
+
int(item.get("createdAt", 0) or 0),
|
|
1690
|
+
)
|
|
1691
|
+
|
|
1692
|
+
records.sort(key=score, reverse=True)
|
|
1693
|
+
return records[0]
|
|
1694
|
+
|
|
1695
|
+
@staticmethod
|
|
1696
|
+
def _connectorPreflight(host: Any, connectorId: str, *, action: str, resource: str) -> dict[str, Any]:
|
|
1697
|
+
if not connectorId or not hasattr(host, "preflightConnector"):
|
|
1698
|
+
return {"ok": False, "status": "blocked", "connectorId": connectorId, "errors": ["missingConnector"]}
|
|
1699
|
+
try:
|
|
1700
|
+
return host.preflightConnector(connectorId, {"action": action, "resource": resource})
|
|
1701
|
+
except Exception as exc:
|
|
1702
|
+
return {"ok": False, "status": "blocked", "connectorId": connectorId, "errors": [type(exc).__name__]}
|
|
1703
|
+
|
|
1704
|
+
@staticmethod
|
|
1705
|
+
def _followUpAction(payload: dict[str, Any]) -> str:
|
|
1706
|
+
raw = payload.get("action", payload.get("intent", payload.get("key", "")))
|
|
1707
|
+
if isinstance(raw, bool):
|
|
1708
|
+
raw = "buy" if raw else ""
|
|
1709
|
+
action = str(raw or "").strip().lower()
|
|
1710
|
+
if not action and payload.get("buy") is not None:
|
|
1711
|
+
action = "buy" if bool(payload.get("buy")) else ""
|
|
1712
|
+
if not action and payload.get("pass") is not None:
|
|
1713
|
+
action = "pass" if bool(payload.get("pass")) else ""
|
|
1714
|
+
aliases = {
|
|
1715
|
+
"purchase": "buy",
|
|
1716
|
+
"checkout": "buy",
|
|
1717
|
+
"select": "buy",
|
|
1718
|
+
"selected": "buy",
|
|
1719
|
+
"accept": "buy",
|
|
1720
|
+
"ask": "contact",
|
|
1721
|
+
"message": "contact",
|
|
1722
|
+
"call": "contact",
|
|
1723
|
+
"contact": "contact",
|
|
1724
|
+
"availability": "contact",
|
|
1725
|
+
"pay": "paymentInfo",
|
|
1726
|
+
"payment": "paymentInfo",
|
|
1727
|
+
"refine": "refine",
|
|
1728
|
+
"change": "refine",
|
|
1729
|
+
"skip": "pass",
|
|
1730
|
+
"reject": "pass",
|
|
1731
|
+
"decline": "pass",
|
|
1732
|
+
}
|
|
1733
|
+
action = aliases.get(action, action)
|
|
1734
|
+
return action if action in {"buy", "pass", "contact", "paymentInfo", "refine"} else ""
|
|
1735
|
+
|
|
1736
|
+
@staticmethod
|
|
1737
|
+
def _followUpTextAction(request: str) -> str:
|
|
1738
|
+
text = f" {str(request or '').strip().lower()} "
|
|
1739
|
+
if not text.strip():
|
|
1740
|
+
return ""
|
|
1741
|
+
if re.search(r"\b(buy|purchase|checkout|order|get it|take it|select|choose|pick)\b", text):
|
|
1742
|
+
return "buy"
|
|
1743
|
+
if re.search(r"\b(ask|message|contact|call|availability|available|deliver|delivery|negotiate)\b", text):
|
|
1744
|
+
return "contact"
|
|
1745
|
+
if re.search(r"\b(pay|payment|swish|stripe|paypal|card|bank|bitcoin|wallet)\b", text):
|
|
1746
|
+
return "paymentInfo"
|
|
1747
|
+
if re.search(r"\b(change|increase|decrease|budget|cheaper|closer|different|refine|filter)\b", text):
|
|
1748
|
+
return "refine"
|
|
1749
|
+
if re.search(r"\b(pass|skip|decline|reject|next option|not this|no thanks)\b", text):
|
|
1750
|
+
return "pass"
|
|
1751
|
+
return ""
|
|
1752
|
+
|
|
1753
|
+
@staticmethod
|
|
1754
|
+
def _hasConversationState(host: Any, conversationId: str) -> bool:
|
|
1755
|
+
if not conversationId or not hasattr(host, "taskBook"):
|
|
1756
|
+
return False
|
|
1757
|
+
try:
|
|
1758
|
+
lookup = host.taskBook.getTaskByConversationId(conversationId) if hasattr(host.taskBook, "getTaskByConversationId") else host.taskBook.getTask(conversationId)
|
|
1759
|
+
except Exception:
|
|
1760
|
+
return False
|
|
1761
|
+
return bool(lookup.get("ok"))
|
|
1762
|
+
|
|
1763
|
+
@staticmethod
|
|
1764
|
+
def _resumeRequestLabel(action: str, payload: dict[str, Any]) -> str:
|
|
1765
|
+
itemId = PicuxMultiAgentOrchestrator._selectedItemId(payload)
|
|
1766
|
+
if action == "pass":
|
|
1767
|
+
return f"Pass on selected item {itemId}".strip()
|
|
1768
|
+
if action == "buy":
|
|
1769
|
+
return f"Buy selected item {itemId}".strip()
|
|
1770
|
+
return "Resume Picux conversation"
|
|
1771
|
+
|
|
1772
|
+
@staticmethod
|
|
1773
|
+
def _conversationId(payload: dict[str, Any]) -> str:
|
|
1774
|
+
raw = (
|
|
1775
|
+
payload.get("conversationId")
|
|
1776
|
+
or payload.get("conversationid")
|
|
1777
|
+
or payload.get("conversationID")
|
|
1778
|
+
or payload.get("taskId")
|
|
1779
|
+
or payload.get("resumeToken")
|
|
1780
|
+
or ""
|
|
1781
|
+
)
|
|
1782
|
+
return str(raw or "").strip()
|
|
1783
|
+
|
|
1784
|
+
@classmethod
|
|
1785
|
+
def _selectedItemId(cls, payload: dict[str, Any], hunt: dict[str, Any] | None = None, request: str = "") -> str:
|
|
1786
|
+
raw = payload.get("itemId") or payload.get("candidateId") or payload.get("sourceId") or payload.get("vendorId") or payload.get("selectedId") or ""
|
|
1787
|
+
selected = payload.get("selected") if isinstance(payload.get("selected"), dict) else {}
|
|
1788
|
+
if not raw and selected:
|
|
1789
|
+
raw = selected.get("itemId") or selected.get("candidateId") or selected.get("sourceId") or selected.get("vendorId") or ""
|
|
1790
|
+
if raw:
|
|
1791
|
+
return str(raw or "").strip()
|
|
1792
|
+
if hunt:
|
|
1793
|
+
ordinal = cls._selectedOrdinal(payload, request=request)
|
|
1794
|
+
if ordinal:
|
|
1795
|
+
candidates = cls._candidateList(hunt)
|
|
1796
|
+
if 0 < ordinal <= len(candidates):
|
|
1797
|
+
ref = cls._candidateRef(candidates[ordinal - 1])
|
|
1798
|
+
return str(ref.get("candidateId", ref.get("sourceId", ref.get("itemId", ""))) or "")
|
|
1799
|
+
return ""
|
|
1800
|
+
|
|
1801
|
+
@staticmethod
|
|
1802
|
+
def _selectedOrdinal(payload: dict[str, Any], request: str = "") -> int:
|
|
1803
|
+
raw = payload.get("index", payload.get("itemIndex", payload.get("rank", payload.get("position", ""))))
|
|
1804
|
+
if isinstance(raw, int):
|
|
1805
|
+
return raw
|
|
1806
|
+
text = f" {str(raw or '')} {str(request or payload.get('request', payload.get('query', payload.get('message', ''))) or '')} ".lower()
|
|
1807
|
+
if not text.strip():
|
|
1808
|
+
return 0
|
|
1809
|
+
numeric = re.search(r"(?:#|item|option|result|candidate|the)?\s*(\d+)(?:st|nd|rd|th)?\b", text)
|
|
1810
|
+
if numeric:
|
|
1811
|
+
try:
|
|
1812
|
+
return int(numeric.group(1))
|
|
1813
|
+
except ValueError:
|
|
1814
|
+
return 0
|
|
1815
|
+
words = {
|
|
1816
|
+
"first": 1,
|
|
1817
|
+
"second": 2,
|
|
1818
|
+
"third": 3,
|
|
1819
|
+
"fourth": 4,
|
|
1820
|
+
"fifth": 5,
|
|
1821
|
+
"sixth": 6,
|
|
1822
|
+
"seventh": 7,
|
|
1823
|
+
"eighth": 8,
|
|
1824
|
+
"ninth": 9,
|
|
1825
|
+
"tenth": 10,
|
|
1826
|
+
"one": 1,
|
|
1827
|
+
"two": 2,
|
|
1828
|
+
"three": 3,
|
|
1829
|
+
"four": 4,
|
|
1830
|
+
"five": 5,
|
|
1831
|
+
"six": 6,
|
|
1832
|
+
"seven": 7,
|
|
1833
|
+
"eight": 8,
|
|
1834
|
+
"nine": 9,
|
|
1835
|
+
"ten": 10,
|
|
1836
|
+
}
|
|
1837
|
+
for word, ordinal in words.items():
|
|
1838
|
+
if re.search(rf"\b{word}\b", text):
|
|
1839
|
+
return ordinal
|
|
1840
|
+
return 0
|
|
1841
|
+
|
|
1842
|
+
@staticmethod
|
|
1843
|
+
def _resumeRoute(action: str) -> list[str]:
|
|
1844
|
+
if action == "buy":
|
|
1845
|
+
return ["hunt", "bridge", "pay"]
|
|
1846
|
+
if action == "contact":
|
|
1847
|
+
return ["hunt", "bridge", "proxy"]
|
|
1848
|
+
if action == "paymentInfo":
|
|
1849
|
+
return ["hunt", "pay"]
|
|
1850
|
+
if action == "refine":
|
|
1851
|
+
return ["hunt"]
|
|
1852
|
+
return ["hunt"]
|
|
1853
|
+
|
|
1854
|
+
@classmethod
|
|
1855
|
+
def _candidateById(cls, hunt: dict[str, Any], itemId: str) -> dict[str, Any] | None:
|
|
1856
|
+
itemId = str(itemId or "").strip()
|
|
1857
|
+
if not itemId:
|
|
1858
|
+
return None
|
|
1859
|
+
candidates = cls._candidateList(hunt)
|
|
1860
|
+
exactKeys = ("candidateId", "sourceId", "vendorId", "id")
|
|
1861
|
+
for candidate in candidates:
|
|
1862
|
+
meta = candidate.get("meta", {}) if isinstance(candidate.get("meta"), dict) else {}
|
|
1863
|
+
values = [str(candidate.get(key, "") or "") for key in exactKeys]
|
|
1864
|
+
values.extend(str(meta.get(key, "") or "") for key in ("candidateId", "sourceId", "vendorId", "id"))
|
|
1865
|
+
if itemId in values:
|
|
1866
|
+
return candidate
|
|
1867
|
+
semanticMatches = [candidate for candidate in candidates if str(candidate.get("itemId", "") or "") == itemId]
|
|
1868
|
+
return semanticMatches[0] if len(semanticMatches) == 1 else None
|
|
1869
|
+
|
|
1870
|
+
@staticmethod
|
|
1871
|
+
def _candidateList(hunt: dict[str, Any]) -> list[dict[str, Any]]:
|
|
1872
|
+
candidates = hunt.get("candidates", []) if isinstance(hunt.get("candidates"), list) else []
|
|
1873
|
+
clean = [item for item in candidates if isinstance(item, dict)]
|
|
1874
|
+
if clean:
|
|
1875
|
+
return clean
|
|
1876
|
+
selected = hunt.get("selected", {}) if isinstance(hunt.get("selected"), dict) else {}
|
|
1877
|
+
return [selected] if selected else []
|
|
1878
|
+
|
|
1879
|
+
@classmethod
|
|
1880
|
+
def _candidateRefs(cls, hunt: dict[str, Any]) -> list[dict[str, Any]]:
|
|
1881
|
+
return [cls._candidateRef(candidate) for candidate in cls._candidateList(hunt)]
|
|
1882
|
+
|
|
1883
|
+
@staticmethod
|
|
1884
|
+
def _candidateRef(candidate: dict[str, Any]) -> dict[str, Any]:
|
|
1885
|
+
meta = candidate.get("meta", {}) if isinstance(candidate.get("meta"), dict) else {}
|
|
1886
|
+
price = meta.get("price", {}) if isinstance(meta.get("price"), dict) else {}
|
|
1887
|
+
sourceId = str(candidate.get("sourceId", candidate.get("vendorId", "")) or "")
|
|
1888
|
+
candidateId = str(candidate.get("candidateId", meta.get("candidateId", sourceId)) or sourceId)
|
|
1889
|
+
return {
|
|
1890
|
+
"candidateId": candidateId,
|
|
1891
|
+
"sourceId": sourceId,
|
|
1892
|
+
"itemId": str(candidate.get("itemId", "") or ""),
|
|
1893
|
+
"title": str(candidate.get("title", meta.get("listingTitle", candidate.get("itemId", ""))) or ""),
|
|
1894
|
+
"source": str(meta.get("source", "") or ""),
|
|
1895
|
+
"url": str(candidate.get("url", meta.get("sourceUrl", "")) or ""),
|
|
1896
|
+
"kind": str(meta.get("kind", meta.get("placeKind", "")) or ""),
|
|
1897
|
+
"address": str(meta.get("address", "") or ""),
|
|
1898
|
+
"website": str(meta.get("website", "") or ""),
|
|
1899
|
+
"rating": meta.get("rating", 0),
|
|
1900
|
+
"distanceKm": meta.get("distanceKm", 0),
|
|
1901
|
+
"price": price,
|
|
1902
|
+
"netUsd": float(candidate.get("netUsd", candidate.get("offerUsd", 0.0)) or 0.0),
|
|
1903
|
+
"eligible": bool(candidate.get("eligible", True)),
|
|
1904
|
+
"reasons": candidate.get("reasons", []) if isinstance(candidate.get("reasons"), list) else [],
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
@staticmethod
|
|
1908
|
+
def _rejectedCandidateIds(priorOut: dict[str, Any]) -> list[str]:
|
|
1909
|
+
resume = priorOut.get("resume", {}) if isinstance(priorOut.get("resume"), dict) else {}
|
|
1910
|
+
rejected = resume.get("rejectedCandidateIds", []) if isinstance(resume.get("rejectedCandidateIds"), list) else []
|
|
1911
|
+
return [str(item) for item in rejected if str(item)]
|
|
1912
|
+
|
|
1913
|
+
@classmethod
|
|
1914
|
+
def _nextCandidateRef(cls, hunt: dict[str, Any], rejected: list[str]) -> dict[str, Any]:
|
|
1915
|
+
rejectedSet = set(rejected)
|
|
1916
|
+
for candidate in cls._candidateList(hunt):
|
|
1917
|
+
ref = cls._candidateRef(candidate)
|
|
1918
|
+
if ref["candidateId"] in rejectedSet:
|
|
1919
|
+
continue
|
|
1920
|
+
if candidate.get("eligible", True):
|
|
1921
|
+
return ref
|
|
1922
|
+
return {}
|
|
1923
|
+
|
|
1924
|
+
@staticmethod
|
|
1925
|
+
def _purchaseInputs(payload: dict[str, Any]) -> dict[str, Any]:
|
|
1926
|
+
rawInputs = payload.get("inputs") if isinstance(payload.get("inputs"), dict) else {}
|
|
1927
|
+
values = payload.get("values") if isinstance(payload.get("values"), dict) else {}
|
|
1928
|
+
contact = payload.get("contact") if isinstance(payload.get("contact"), dict) else {}
|
|
1929
|
+
shipping = payload.get("shipping") if isinstance(payload.get("shipping"), dict) else {}
|
|
1930
|
+
billing = payload.get("billing") if isinstance(payload.get("billing"), dict) else {}
|
|
1931
|
+
payment = payload.get("payment") if isinstance(payload.get("payment"), dict) else {}
|
|
1932
|
+
inputs = {**rawInputs, **values}
|
|
1933
|
+
email = payload.get("email") or inputs.get("email") or contact.get("email") or ""
|
|
1934
|
+
phone = payload.get("phone") or inputs.get("phone") or contact.get("phone") or ""
|
|
1935
|
+
shippingAddress = payload.get("shippingAddress") or inputs.get("shippingAddress") or payload.get("address") or inputs.get("address") or shipping.get("address") or shipping.get("shippingAddress") or ""
|
|
1936
|
+
billingAddress = payload.get("billingAddress") or inputs.get("billingAddress") or billing.get("address") or billing.get("billingAddress") or ""
|
|
1937
|
+
if not billingAddress and bool(payload.get("billingSameAsShipping", inputs.get("billingSameAsShipping", False))):
|
|
1938
|
+
billingAddress = shippingAddress
|
|
1939
|
+
return {
|
|
1940
|
+
"email": email,
|
|
1941
|
+
"phone": phone,
|
|
1942
|
+
"shippingAddress": shippingAddress,
|
|
1943
|
+
"billingAddress": billingAddress,
|
|
1944
|
+
"paymentMethod": payload.get("paymentMethod") or inputs.get("paymentMethod") or payment.get("method") or payment.get("paymentMethod") or "",
|
|
1945
|
+
"walletAddress": payload.get("walletAddress") or inputs.get("walletAddress") or payment.get("walletAddress") or "",
|
|
1946
|
+
"chainType": payload.get("chainType") or inputs.get("chainType") or payment.get("chainType") or payment.get("chain") or "",
|
|
1947
|
+
"cardNumber": payload.get("cardNumber") or inputs.get("cardNumber") or payment.get("cardNumber") or "",
|
|
1948
|
+
"expiry": payload.get("expiry") or inputs.get("expiry") or payment.get("expiry") or "",
|
|
1949
|
+
"cvv": payload.get("cvv") or inputs.get("cvv") or payment.get("cvv") or "",
|
|
1950
|
+
"account": payload.get("account") or inputs.get("account") or payment.get("account") or "",
|
|
1951
|
+
"number": payload.get("number") or inputs.get("number") or payment.get("number") or "",
|
|
1952
|
+
"bank": payload.get("bank") or inputs.get("bank") or payment.get("bank") or "",
|
|
1953
|
+
"card": payload.get("card") or inputs.get("card") or payment.get("card") or "",
|
|
1954
|
+
"paymentToken": payload.get("paymentToken") or inputs.get("paymentToken") or payment.get("token") or "",
|
|
1955
|
+
"mandateId": payload.get("mandateId") or inputs.get("mandateId") or payment.get("mandateId") or "",
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
@staticmethod
|
|
1959
|
+
def _missingContactKeys(inputs: dict[str, Any]) -> list[str]:
|
|
1960
|
+
missing = []
|
|
1961
|
+
if not str(inputs.get("email", "") or "").strip():
|
|
1962
|
+
missing.append("email")
|
|
1963
|
+
if not str(inputs.get("shippingAddress", "") or "").strip():
|
|
1964
|
+
missing.append("shippingAddress")
|
|
1965
|
+
if not str(inputs.get("billingAddress", "") or "").strip():
|
|
1966
|
+
missing.append("billingAddress")
|
|
1967
|
+
return missing
|
|
1968
|
+
|
|
1969
|
+
@classmethod
|
|
1970
|
+
def _missingPaymentKeys(cls, inputs: dict[str, Any]) -> list[str]:
|
|
1971
|
+
method = str(inputs.get("paymentMethod", "") or "").strip().lower()
|
|
1972
|
+
if not method or method not in cls._supportedPaymentMethods():
|
|
1973
|
+
return ["paymentMethod"]
|
|
1974
|
+
required = cls._paymentFieldsFor(method)
|
|
1975
|
+
return [key for key in required if not str(inputs.get(key, "") or "").strip()]
|
|
1976
|
+
|
|
1977
|
+
@classmethod
|
|
1978
|
+
def _purchaseIntent(cls, conversationId: str, taskId: str, candidateRef: dict[str, Any], inputs: dict[str, Any], missingKeys: list[str], resumeStatus: str) -> dict[str, Any]:
|
|
1979
|
+
intentId = cls._stableId("purchase", {"conversationId": conversationId, "taskId": taskId, "candidateId": candidateRef.get("candidateId", "")})
|
|
1980
|
+
status = {"NEEDS_INPUT": "needsInput", "NEEDS_PAYMENT_INFO": "needsPaymentInfo"}.get(resumeStatus, "readyForCheckout")
|
|
1981
|
+
return {
|
|
1982
|
+
"intentId": intentId,
|
|
1983
|
+
"conversationId": conversationId,
|
|
1984
|
+
"taskId": taskId,
|
|
1985
|
+
"status": status,
|
|
1986
|
+
"candidate": candidateRef,
|
|
1987
|
+
"requestedKeys": missingKeys,
|
|
1988
|
+
"requiredKeys": ["email", "shippingAddress", "billingAddress"],
|
|
1989
|
+
"supportedPaymentMethods": cls._supportedPaymentMethods(),
|
|
1990
|
+
"paymentSchema": cls._paymentSchema(),
|
|
1991
|
+
"inputs": cls._safePurchaseInputs(inputs),
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
@staticmethod
|
|
1995
|
+
def _safePurchaseInputs(inputs: dict[str, Any]) -> dict[str, Any]:
|
|
1996
|
+
safe: dict[str, Any] = {}
|
|
1997
|
+
for key in ("email", "phone", "shippingAddress", "billingAddress", "paymentMethod", "walletAddress", "chainType", "account", "number", "bank", "mandateId"):
|
|
1998
|
+
if inputs.get(key):
|
|
1999
|
+
safe[key] = inputs[key]
|
|
2000
|
+
for key in ("card", "cardNumber", "expiry", "cvv", "paymentToken"):
|
|
2001
|
+
if inputs.get(key):
|
|
2002
|
+
safe[key] = "[redacted]"
|
|
2003
|
+
return safe
|
|
2004
|
+
|
|
2005
|
+
@classmethod
|
|
2006
|
+
def _settleCheckoutProof(
|
|
2007
|
+
cls,
|
|
2008
|
+
host: Any,
|
|
2009
|
+
*,
|
|
2010
|
+
escrow: dict[str, Any],
|
|
2011
|
+
purchaseIntent: dict[str, Any],
|
|
2012
|
+
candidateRef: dict[str, Any],
|
|
2013
|
+
checkout: dict[str, Any],
|
|
2014
|
+
event: Any,
|
|
2015
|
+
) -> dict[str, Any]:
|
|
2016
|
+
escrowRecord = escrow.get("escrow", {}) if isinstance(escrow.get("escrow"), dict) else {}
|
|
2017
|
+
escrowId = str(escrowRecord.get("escrowId", "") or "")
|
|
2018
|
+
receipt = checkout.get("receipt", {}) if isinstance(checkout.get("receipt"), dict) else {}
|
|
2019
|
+
receiptId = str(receipt.get("receiptId", "") or "")
|
|
2020
|
+
if not escrowId or not receiptId or not hasattr(host, "submitEscrowProof"):
|
|
2021
|
+
return {}
|
|
2022
|
+
rules = ["sourceBound", "purchaseIntent", "verifiedCompletionProof", "userConfirmedPurchase"]
|
|
2023
|
+
proof = {
|
|
2024
|
+
"proofRef": receiptId,
|
|
2025
|
+
"kind": "checkoutReceipt",
|
|
2026
|
+
"status": "verified",
|
|
2027
|
+
"rules": rules,
|
|
2028
|
+
"source": "bridge.browserCheckout",
|
|
2029
|
+
"payload": {
|
|
2030
|
+
"purchaseIntentId": purchaseIntent.get("intentId", ""),
|
|
2031
|
+
"candidateId": candidateRef.get("candidateId", ""),
|
|
2032
|
+
"source": candidateRef.get("source", ""),
|
|
2033
|
+
"sourceUrl": candidateRef.get("url", ""),
|
|
2034
|
+
"checkout": checkout,
|
|
2035
|
+
},
|
|
2036
|
+
}
|
|
2037
|
+
submitted = host.submitEscrowProof(escrowId, {"proof": proof})
|
|
2038
|
+
event(
|
|
2039
|
+
"pay",
|
|
2040
|
+
"proof.submit",
|
|
2041
|
+
"verified" if submitted.get("ok") else "proofRejected",
|
|
2042
|
+
"PAY received the browser checkout proof and attached it to escrow.",
|
|
2043
|
+
{"escrowId": escrowId, "proofRef": receiptId, "rules": rules},
|
|
2044
|
+
)
|
|
2045
|
+
if not submitted.get("ok"):
|
|
2046
|
+
return {
|
|
2047
|
+
"ok": False,
|
|
2048
|
+
"status": "proofRejected",
|
|
2049
|
+
"reason": str(submitted.get("error", "proofRejected") or "proofRejected"),
|
|
2050
|
+
"proof": proof,
|
|
2051
|
+
"proofSubmission": submitted,
|
|
2052
|
+
"settlementGate": {"status": "locked", "reason": str(submitted.get("error", "proofRejected") or "proofRejected"), "proofRequired": True},
|
|
2053
|
+
}
|
|
2054
|
+
prepared = (
|
|
2055
|
+
host.prepareSettlement({"escrowId": escrowId, "adapter": {"adapterId": "picuxLedgerAdapter", "rail": "picuxLedger", "mode": "execute"}})
|
|
2056
|
+
if hasattr(host, "prepareSettlement")
|
|
2057
|
+
else {}
|
|
2058
|
+
)
|
|
2059
|
+
prepareStatus = "ready" if prepared.get("ok") else "rejected"
|
|
2060
|
+
prepareReason = cls._payFailureReason(prepared)
|
|
2061
|
+
event(
|
|
2062
|
+
"pay",
|
|
2063
|
+
"settlement.prepare",
|
|
2064
|
+
prepareStatus,
|
|
2065
|
+
"PAY prepared settlement from the verified checkout proof." if prepared.get("ok") else f"PAY could not prepare settlement: {prepareReason}.",
|
|
2066
|
+
{"escrowId": escrowId, "settlementId": (prepared.get("instruction", {}) if isinstance(prepared.get("instruction"), dict) else {}).get("settlementId", ""), "errors": cls._payErrors(prepared)},
|
|
2067
|
+
)
|
|
2068
|
+
settlement: dict[str, Any] = {}
|
|
2069
|
+
if prepared.get("ok") and hasattr(host, "settle"):
|
|
2070
|
+
settlement = host.settle({"escrowId": escrowId})
|
|
2071
|
+
event(
|
|
2072
|
+
"pay",
|
|
2073
|
+
"settlement.response",
|
|
2074
|
+
"settled" if settlement.get("ok") else "rejected",
|
|
2075
|
+
"PAY released settlement against the verified checkout proof." if settlement.get("ok") else f"PAY rejected settlement: {cls._payFailureReason(settlement)}.",
|
|
2076
|
+
{"escrowId": escrowId, "receiptId": (settlement.get("receipt", {}) if isinstance(settlement.get("receipt"), dict) else {}).get("receiptId", ""), "errors": cls._payErrors(settlement)},
|
|
2077
|
+
)
|
|
2078
|
+
ok = bool(settlement.get("ok"))
|
|
2079
|
+
reason = "" if ok else prepareReason or cls._payFailureReason(settlement) or "settlementNotReleased"
|
|
2080
|
+
return {
|
|
2081
|
+
"ok": ok,
|
|
2082
|
+
"status": "settled" if ok else "settlementRejected",
|
|
2083
|
+
"reason": reason,
|
|
2084
|
+
"proof": proof,
|
|
2085
|
+
"proofSubmission": submitted,
|
|
2086
|
+
"prepare": prepared,
|
|
2087
|
+
"settlement": settlement,
|
|
2088
|
+
"settlementGate": {"status": "released" if ok else "locked", "reason": "checkoutProofSettled" if ok else reason, "proofRequired": True},
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
@staticmethod
|
|
2092
|
+
def _checkoutNeedsInput(checkout: dict[str, Any]) -> bool:
|
|
2093
|
+
needs = checkout.get("needsInput", []) if isinstance(checkout.get("needsInput"), list) else []
|
|
2094
|
+
return bool(needs) or str(checkout.get("status", "") or "") in {"needsInput", "needsProxy"}
|
|
2095
|
+
|
|
2096
|
+
@staticmethod
|
|
2097
|
+
def _payErrors(result: dict[str, Any]) -> list[str]:
|
|
2098
|
+
if not isinstance(result, dict):
|
|
2099
|
+
return []
|
|
2100
|
+
decision = result.get("decision", {}) if isinstance(result.get("decision"), dict) else {}
|
|
2101
|
+
errors = decision.get("errors", result.get("errors", []))
|
|
2102
|
+
return [str(item) for item in errors if str(item)] if isinstance(errors, list) else ([str(errors)] if str(errors or "") else [])
|
|
2103
|
+
|
|
2104
|
+
@classmethod
|
|
2105
|
+
def _payFailureReason(cls, result: dict[str, Any]) -> str:
|
|
2106
|
+
return ",".join(cls._payErrors(result)) or str(result.get("error", result.get("status", "")) or "")
|
|
2107
|
+
|
|
2108
|
+
@staticmethod
|
|
2109
|
+
def _supportedPaymentMethods() -> list[str]:
|
|
2110
|
+
return ["stripe", "paypal", "card", "bank", "swish", "bitcoin"]
|
|
2111
|
+
|
|
2112
|
+
@classmethod
|
|
2113
|
+
def _paymentSchema(cls) -> dict[str, Any]:
|
|
2114
|
+
return {
|
|
2115
|
+
"methods": [
|
|
2116
|
+
{"method": method, "fields": cls._paymentFieldsFor(method)}
|
|
2117
|
+
for method in cls._supportedPaymentMethods()
|
|
2118
|
+
]
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
@staticmethod
|
|
2122
|
+
def _paymentFieldsFor(method: str) -> list[str]:
|
|
2123
|
+
return {
|
|
2124
|
+
"stripe": ["account"],
|
|
2125
|
+
"paypal": ["account"],
|
|
2126
|
+
"card": ["cardNumber", "expiry", "cvv"],
|
|
2127
|
+
"bank": ["bank", "account", "number"],
|
|
2128
|
+
"swish": ["number"],
|
|
2129
|
+
"bitcoin": ["walletAddress", "chainType"],
|
|
2130
|
+
}.get(str(method or "").lower(), [])
|
|
2131
|
+
|
|
2132
|
+
@staticmethod
|
|
2133
|
+
def _bridgeNeedsInput(candidateRef: dict[str, Any], missingKeys: list[str]) -> dict[str, Any]:
|
|
2134
|
+
return {
|
|
2135
|
+
"ok": False,
|
|
2136
|
+
"status": "NEEDS_INPUT",
|
|
2137
|
+
"mode": "adapterNeutral",
|
|
2138
|
+
"requestedKeys": missingKeys,
|
|
2139
|
+
"checkout": {
|
|
2140
|
+
"action": "requestInput",
|
|
2141
|
+
"source": candidateRef.get("source", ""),
|
|
2142
|
+
"url": candidateRef.get("url", ""),
|
|
2143
|
+
"candidateId": candidateRef.get("candidateId", ""),
|
|
2144
|
+
},
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
@staticmethod
|
|
2148
|
+
def _isLocalServiceCandidate(candidateRef: dict[str, Any]) -> bool:
|
|
2149
|
+
kind = str(candidateRef.get("kind", "") or "").lower()
|
|
2150
|
+
item = str(candidateRef.get("itemId", "") or "").lower()
|
|
2151
|
+
source = str(candidateRef.get("source", "") or "").lower()
|
|
2152
|
+
return kind == "localservice" or source in {"googleplaces", "openstreetmapnominatim", "clientplaces"} or any(token in item for token in ("mechanic", "plumber", "electrician", "garage", "workshop", "repair"))
|
|
2153
|
+
|
|
2154
|
+
@staticmethod
|
|
2155
|
+
def _originalTaskRequest(task: dict[str, Any]) -> str:
|
|
2156
|
+
inData = task.get("inData", {}) if isinstance(task.get("inData"), dict) else {}
|
|
2157
|
+
return str(inData.get("request", task.get("goal", "")) or "").strip()
|
|
2158
|
+
|
|
2159
|
+
@staticmethod
|
|
2160
|
+
def _localServiceInputs(payload: dict[str, Any], candidateRef: dict[str, Any], *, defaultBrief: str) -> dict[str, Any]:
|
|
2161
|
+
rawInputs = payload.get("inputs") if isinstance(payload.get("inputs"), dict) else {}
|
|
2162
|
+
values = payload.get("values") if isinstance(payload.get("values"), dict) else {}
|
|
2163
|
+
contact = payload.get("contact") if isinstance(payload.get("contact"), dict) else {}
|
|
2164
|
+
inputs = {**rawInputs, **values}
|
|
2165
|
+
return {
|
|
2166
|
+
"userName": payload.get("userName") or inputs.get("userName") or contact.get("name") or "",
|
|
2167
|
+
"userPhone": payload.get("userPhone") or payload.get("phone") or inputs.get("userPhone") or inputs.get("phone") or contact.get("phone") or "",
|
|
2168
|
+
"businessPhone": payload.get("businessPhone") or payload.get("selectedPhone") or inputs.get("businessPhone") or candidateRef.get("phone", "") or "",
|
|
2169
|
+
"issueBrief": payload.get("issueBrief") or payload.get("brief") or inputs.get("issueBrief") or inputs.get("brief") or defaultBrief,
|
|
2170
|
+
"preferredChannel": payload.get("preferredChannel") or inputs.get("preferredChannel") or "whatsapp",
|
|
2171
|
+
"consentToCall": payload.get("consentToCall", inputs.get("consentToCall", payload.get("consent", inputs.get("consent", "")))),
|
|
2172
|
+
}
|
|
2173
|
+
|
|
2174
|
+
@classmethod
|
|
2175
|
+
def _missingLocalServiceKeys(cls, inputs: dict[str, Any], candidateRef: dict[str, Any]) -> list[str]:
|
|
2176
|
+
missing = []
|
|
2177
|
+
if not str(inputs.get("userPhone", "") or "").strip():
|
|
2178
|
+
missing.append("userPhone")
|
|
2179
|
+
if not cls._truthy(inputs.get("consentToCall", "")):
|
|
2180
|
+
missing.append("consentToCall")
|
|
2181
|
+
return missing
|
|
2182
|
+
|
|
2183
|
+
@classmethod
|
|
2184
|
+
def _proxyContactIntent(cls, conversationId: str, taskId: str, candidateRef: dict[str, Any], inputs: dict[str, Any], missingKeys: list[str]) -> dict[str, Any]:
|
|
2185
|
+
intentId = cls._stableId("proxyIntent", {"conversationId": conversationId, "taskId": taskId, "candidateId": candidateRef.get("candidateId", "")})
|
|
2186
|
+
return {
|
|
2187
|
+
"intentId": intentId,
|
|
2188
|
+
"conversationId": conversationId,
|
|
2189
|
+
"taskId": taskId,
|
|
2190
|
+
"status": "needsInput" if missingKeys else "readyForProxy",
|
|
2191
|
+
"candidate": candidateRef,
|
|
2192
|
+
"requestedKeys": missingKeys,
|
|
2193
|
+
"requiredKeys": ["userPhone", "consentToCall"],
|
|
2194
|
+
"channel": str(inputs.get("preferredChannel", "whatsapp") or "whatsapp"),
|
|
2195
|
+
"inputs": {
|
|
2196
|
+
key: value
|
|
2197
|
+
for key, value in {
|
|
2198
|
+
"userName": inputs.get("userName", ""),
|
|
2199
|
+
"userPhone": inputs.get("userPhone", ""),
|
|
2200
|
+
"businessPhone": inputs.get("businessPhone", ""),
|
|
2201
|
+
"issueBrief": inputs.get("issueBrief", ""),
|
|
2202
|
+
"preferredChannel": inputs.get("preferredChannel", "whatsapp"),
|
|
2203
|
+
"consentToCall": bool(cls._truthy(inputs.get("consentToCall", ""))),
|
|
2204
|
+
}.items()
|
|
2205
|
+
if value not in {"", None}
|
|
2206
|
+
},
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
@staticmethod
|
|
2210
|
+
def _truthy(value: Any) -> bool:
|
|
2211
|
+
if isinstance(value, bool):
|
|
2212
|
+
return value
|
|
2213
|
+
return str(value or "").strip().lower() in {"1", "true", "yes", "y", "approved", "approve", "consent", "consented", "ok"}
|
|
2214
|
+
|
|
2215
|
+
@staticmethod
|
|
2216
|
+
def _isLocalServiceRequest(request: str, payload: dict[str, Any], nlp: dict[str, Any]) -> bool:
|
|
2217
|
+
entities = nlp.get("entities", {}) if isinstance(nlp.get("entities"), dict) else {}
|
|
2218
|
+
if str(entities.get("market", "") or "") == "localService":
|
|
2219
|
+
return True
|
|
2220
|
+
if str(payload.get("market", "") or "").lower() in {"localservice", "local_service", "places", "maps"}:
|
|
2221
|
+
return True
|
|
2222
|
+
lowered = request.lower()
|
|
2223
|
+
service = str(entities.get("service", entities.get("entity", "")) or "").lower()
|
|
2224
|
+
placeHints = ("near", "nearby", "within", "around", "close to", "km", "kilometer", "kilometre", "goteborg", "gothenburg", "hisingen", "hisigen")
|
|
2225
|
+
serviceHints = ("mechanic", "plumber", "electrician", "repair", "garage", "workshop", "lawyer", "consultant", "doctor", "dentist", "cleaner", "contractor", "installer", "handyman")
|
|
2226
|
+
return bool(service or any(token in lowered for token in serviceHints)) and any(token in lowered for token in placeHints)
|
|
2227
|
+
|
|
2228
|
+
@classmethod
|
|
2229
|
+
def _placesToOffers(cls, request: str, mapResult: dict[str, Any]) -> list[dict[str, Any]]:
|
|
2230
|
+
places = mapResult.get("places", []) if isinstance(mapResult.get("places"), list) else []
|
|
2231
|
+
query = mapResult.get("query", {}) if isinstance(mapResult.get("query"), dict) else {}
|
|
2232
|
+
itemId = str(query.get("entity", "") or cls._itemId(request) or "localService")
|
|
2233
|
+
offers = []
|
|
2234
|
+
for index, place in enumerate(places[:10], start=1):
|
|
2235
|
+
if not isinstance(place, dict):
|
|
2236
|
+
continue
|
|
2237
|
+
name = str(place.get("name", "") or "").strip()
|
|
2238
|
+
if not name:
|
|
2239
|
+
continue
|
|
2240
|
+
source = str(place.get("source", mapResult.get("provider", "mapTool")) or "mapTool")
|
|
2241
|
+
sourceUrl = str(place.get("sourceUrl", "") or "")
|
|
2242
|
+
placeId = str(place.get("placeId", "") or "")
|
|
2243
|
+
candidateId = placeId if placeId.startswith("place_") else cls._stableId("place", {"placeId": placeId, "name": name, "source": source, "sourceUrl": sourceUrl})
|
|
2244
|
+
contactRef = cls._contactRefForPlace(place, candidateId)
|
|
2245
|
+
rating = cls._float(place.get("rating", 0.0))
|
|
2246
|
+
distanceKm = cls._float(place.get("distanceKm", 0.0))
|
|
2247
|
+
stock = 1 if bool(place.get("sourceBound")) else 0
|
|
2248
|
+
offers.append(
|
|
2249
|
+
{
|
|
2250
|
+
"sourceId": candidateId,
|
|
2251
|
+
"candidateId": candidateId,
|
|
2252
|
+
"itemId": itemId,
|
|
2253
|
+
"listUsd": 0.0,
|
|
2254
|
+
"offerUsd": 0.0,
|
|
2255
|
+
"feesUsd": 0.0,
|
|
2256
|
+
"stock": stock,
|
|
2257
|
+
"meta": {
|
|
2258
|
+
"kind": "localService",
|
|
2259
|
+
"placeKind": "localService",
|
|
2260
|
+
"reader": "mapTool",
|
|
2261
|
+
"source": source,
|
|
2262
|
+
"sourceUrl": sourceUrl,
|
|
2263
|
+
"listingTitle": name,
|
|
2264
|
+
"address": str(place.get("address", "") or ""),
|
|
2265
|
+
"website": str(place.get("website", "") or ""),
|
|
2266
|
+
"rating": rating,
|
|
2267
|
+
"distanceKm": distanceKm,
|
|
2268
|
+
"placeId": placeId,
|
|
2269
|
+
"contactRef": contactRef,
|
|
2270
|
+
"contactStatus": "private",
|
|
2271
|
+
"verified": bool(place.get("verified")),
|
|
2272
|
+
"placeIndex": index,
|
|
2273
|
+
"entity": itemId,
|
|
2274
|
+
"price": {},
|
|
2275
|
+
"sourceBound": bool(place.get("sourceBound")),
|
|
2276
|
+
},
|
|
2277
|
+
}
|
|
2278
|
+
)
|
|
2279
|
+
return offers
|
|
2280
|
+
|
|
2281
|
+
@classmethod
|
|
2282
|
+
def _contactRefForPlace(cls, place: dict[str, Any], candidateId: str) -> str:
|
|
2283
|
+
explicit = str(place.get("contactRef", "") or "")
|
|
2284
|
+
if explicit:
|
|
2285
|
+
return explicit
|
|
2286
|
+
return cls._stableId(
|
|
2287
|
+
"localContact",
|
|
2288
|
+
{
|
|
2289
|
+
"candidateId": candidateId,
|
|
2290
|
+
"placeId": str(place.get("placeId", "") or ""),
|
|
2291
|
+
"name": str(place.get("name", "") or ""),
|
|
2292
|
+
"source": str(place.get("source", "") or ""),
|
|
2293
|
+
},
|
|
2294
|
+
)
|
|
2295
|
+
|
|
2296
|
+
@staticmethod
|
|
2297
|
+
def _mapBrowserTelemetry(mapResult: dict[str, Any], offers: list[dict[str, Any]], *, country: str) -> dict[str, Any]:
|
|
2298
|
+
provider = str(mapResult.get("provider", "mapTool") or "mapTool")
|
|
2299
|
+
query = mapResult.get("query", {}) if isinstance(mapResult.get("query"), dict) else {}
|
|
2300
|
+
places = mapResult.get("places", []) if isinstance(mapResult.get("places"), list) else []
|
|
2301
|
+
ok = bool(places) or str(mapResult.get("status", "") or "") == "searchedNoPlaces"
|
|
2302
|
+
search = {
|
|
2303
|
+
"mode": "placesSearch",
|
|
2304
|
+
"query": " ".join(str(query.get(key, "") or "") for key in ("entity", "location")).strip(),
|
|
2305
|
+
"submitted": bool(mapResult.get("networkAttempted") or places),
|
|
2306
|
+
"radiusKm": query.get("radiusKm", 0),
|
|
2307
|
+
}
|
|
2308
|
+
return {
|
|
2309
|
+
"adapter": provider,
|
|
2310
|
+
"rendered": {},
|
|
2311
|
+
"marketplaceSet": {
|
|
2312
|
+
"market": "localService",
|
|
2313
|
+
"country": country.lower(),
|
|
2314
|
+
"sources": [provider],
|
|
2315
|
+
"localSources": [provider],
|
|
2316
|
+
"entity": str(query.get("entity", "") or ""),
|
|
2317
|
+
"location": str(query.get("location", "") or ""),
|
|
2318
|
+
"radiusKm": query.get("radiusKm", 0),
|
|
2319
|
+
},
|
|
2320
|
+
"budget": {},
|
|
2321
|
+
"fx": {},
|
|
2322
|
+
"networkAttempted": bool(mapResult.get("networkAttempted")),
|
|
2323
|
+
"searchTargets": [{"source": provider, "url": "googlePlaces://textSearch" if provider == "googlePlaces" else provider, "kind": "placesSearch"}],
|
|
2324
|
+
"marketplaceAttempts": [
|
|
2325
|
+
{
|
|
2326
|
+
"source": provider,
|
|
2327
|
+
"url": "googlePlaces://textSearch" if provider == "googlePlaces" else provider,
|
|
2328
|
+
"ok": ok,
|
|
2329
|
+
"adapter": provider,
|
|
2330
|
+
"statusCode": 200 if ok else 0,
|
|
2331
|
+
"title": f"{query.get('entity', 'places')} near {query.get('location', '')}".strip(),
|
|
2332
|
+
"matched": ok,
|
|
2333
|
+
"listingCount": len(places),
|
|
2334
|
+
"filteredListingCount": len(offers),
|
|
2335
|
+
"search": search,
|
|
2336
|
+
"error": "; ".join(str(item) for item in mapResult.get("errors", []) if item),
|
|
2337
|
+
}
|
|
2338
|
+
],
|
|
2339
|
+
"observations": [
|
|
2340
|
+
{
|
|
2341
|
+
"source": provider,
|
|
2342
|
+
"url": "googlePlaces://textSearch" if provider == "googlePlaces" else provider,
|
|
2343
|
+
"ok": ok,
|
|
2344
|
+
"adapter": provider,
|
|
2345
|
+
"statusCode": 200 if ok else 0,
|
|
2346
|
+
"title": f"{query.get('entity', 'places')} near {query.get('location', '')}".strip(),
|
|
2347
|
+
"matched": ok,
|
|
2348
|
+
"listingCount": len(places),
|
|
2349
|
+
"search": search,
|
|
2350
|
+
"error": "; ".join(str(item) for item in mapResult.get("errors", []) if item),
|
|
2351
|
+
}
|
|
2352
|
+
],
|
|
2353
|
+
"offers": offers,
|
|
2354
|
+
"errors": [] if ok else [{"source": provider, "ok": False, "error": "; ".join(str(item) for item in mapResult.get("errors", []) if item)}],
|
|
2355
|
+
"status": str(mapResult.get("status", "")),
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2358
|
+
@staticmethod
|
|
2359
|
+
def _float(value: Any) -> float:
|
|
2360
|
+
try:
|
|
2361
|
+
return float(value or 0.0)
|
|
2362
|
+
except Exception:
|
|
2363
|
+
return 0.0
|
|
2364
|
+
|
|
2365
|
+
def _huntPayload(self, request: str, payload: dict[str, Any], *, taskId: str, browser: dict[str, Any] | None = None, clientId: str = "orchestrator", channel: str = "api") -> dict[str, Any]:
|
|
2366
|
+
criteria = payload.get("criteria") if isinstance(payload.get("criteria"), dict) else {}
|
|
2367
|
+
budget = self._budget(request, payload)
|
|
2368
|
+
itemId = str(criteria.get("itemId", "") or self._itemId(request) or "requestedItem")
|
|
2369
|
+
maxNetUsd = float(criteria.get("maxNetUsd", budget) or budget or 0.0)
|
|
2370
|
+
rawOffers = payload.get("offers", payload.get("telemetry", []))
|
|
2371
|
+
offers = rawOffers if isinstance(rawOffers, list) else []
|
|
2372
|
+
browser = browser or {}
|
|
2373
|
+
browserOffers = browser.get("offers", []) if isinstance(browser.get("offers"), list) else []
|
|
2374
|
+
if not offers and browserOffers:
|
|
2375
|
+
offers = browserOffers
|
|
2376
|
+
return {
|
|
2377
|
+
"taskId": taskId,
|
|
2378
|
+
"criteria": {
|
|
2379
|
+
"itemId": itemId,
|
|
2380
|
+
"minDiscountPct": float(criteria.get("minDiscountPct")) if "minDiscountPct" in criteria else 0.0,
|
|
2381
|
+
"maxNetUsd": maxNetUsd,
|
|
2382
|
+
"minStock": int(criteria.get("minStock", 1) or 1),
|
|
2383
|
+
"region": str(criteria.get("region", "") or ""),
|
|
2384
|
+
},
|
|
2385
|
+
"telemetry": offers,
|
|
2386
|
+
"sourceTelemetry": self._sourceTelemetry(browser),
|
|
2387
|
+
"handoff": self._handoff(payload, clientId, channel),
|
|
2388
|
+
}
|
|
2389
|
+
|
|
2390
|
+
@staticmethod
|
|
2391
|
+
def _sourceTelemetry(browser: dict[str, Any]) -> dict[str, Any]:
|
|
2392
|
+
attempts = browser.get("marketplaceAttempts", []) if isinstance(browser.get("marketplaceAttempts"), list) else []
|
|
2393
|
+
targets = browser.get("searchTargets", []) if isinstance(browser.get("searchTargets"), list) else []
|
|
2394
|
+
return {
|
|
2395
|
+
"adapter": browser.get("adapter", ""),
|
|
2396
|
+
"rendered": browser.get("rendered", {}),
|
|
2397
|
+
"marketplaceSet": browser.get("marketplaceSet", {}),
|
|
2398
|
+
"budget": browser.get("budget", {}),
|
|
2399
|
+
"fx": browser.get("fx", {}),
|
|
2400
|
+
"networkAttempted": bool(browser.get("networkAttempted")),
|
|
2401
|
+
"targetSources": [item.get("source", "") for item in targets if isinstance(item, dict)],
|
|
2402
|
+
"attempts": attempts,
|
|
2403
|
+
"observations": PicuxMultiAgentOrchestrator._sourceObservationSummaries(browser),
|
|
2404
|
+
"offerCount": len(browser.get("offers", [])) if isinstance(browser.get("offers"), list) else 0,
|
|
2405
|
+
"observationCount": len(browser.get("observations", [])) if isinstance(browser.get("observations"), list) else 0,
|
|
2406
|
+
"status": browser.get("status", ""),
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
@staticmethod
|
|
2410
|
+
def _sourceObservationSummaries(browser: dict[str, Any]) -> list[dict[str, Any]]:
|
|
2411
|
+
observations = browser.get("observations", []) if isinstance(browser.get("observations"), list) else []
|
|
2412
|
+
summaries = []
|
|
2413
|
+
for observation in observations:
|
|
2414
|
+
if not isinstance(observation, dict):
|
|
2415
|
+
continue
|
|
2416
|
+
listings = observation.get("listings", []) if isinstance(observation.get("listings"), list) else []
|
|
2417
|
+
summaries.append(
|
|
2418
|
+
{
|
|
2419
|
+
"source": observation.get("source", ""),
|
|
2420
|
+
"url": observation.get("url", ""),
|
|
2421
|
+
"ok": bool(observation.get("ok")),
|
|
2422
|
+
"adapter": observation.get("adapter", ""),
|
|
2423
|
+
"statusCode": observation.get("statusCode", 0),
|
|
2424
|
+
"title": observation.get("title", ""),
|
|
2425
|
+
"matched": bool(observation.get("matched")),
|
|
2426
|
+
"listingCount": len(listings),
|
|
2427
|
+
"search": observation.get("search", {}) if isinstance(observation.get("search"), dict) else {},
|
|
2428
|
+
"error": observation.get("error", ""),
|
|
2429
|
+
}
|
|
2430
|
+
)
|
|
2431
|
+
return summaries
|
|
2432
|
+
|
|
2433
|
+
@staticmethod
|
|
2434
|
+
def _handoff(payload: dict[str, Any], clientId: str, channel: str) -> dict[str, str]:
|
|
2435
|
+
raw = payload.get("handoff", payload.get("handsoff", {}))
|
|
2436
|
+
raw = raw if isinstance(raw, dict) else {}
|
|
2437
|
+
explicitSource = raw.get("source") or payload.get("handoffSource") or payload.get("handsoffSource")
|
|
2438
|
+
source = str(explicitSource or clientId or "externalClient")
|
|
2439
|
+
explicitTarget = raw.get("target") or payload.get("handoffTarget") or payload.get("handsoffTarget")
|
|
2440
|
+
target = str(explicitTarget or (source if explicitSource else "orchestrator") or "orchestrator")
|
|
2441
|
+
if target in {"source", "handoffSource", "handsoffSource"}:
|
|
2442
|
+
target = source
|
|
2443
|
+
return {
|
|
2444
|
+
"source": source,
|
|
2445
|
+
"target": target,
|
|
2446
|
+
"channel": str(raw.get("channel", payload.get("handoffChannel", channel)) or channel),
|
|
2447
|
+
"mode": str(raw.get("mode", payload.get("handoffMode", "return")) or "return"),
|
|
2448
|
+
}
|
|
2449
|
+
|
|
2450
|
+
@staticmethod
|
|
2451
|
+
def _decisionTree(hunt: dict[str, Any]) -> dict[str, Any]:
|
|
2452
|
+
candidates = hunt.get("candidates", []) if isinstance(hunt.get("candidates"), list) else []
|
|
2453
|
+
selected = hunt.get("selected", {}) if isinstance(hunt.get("selected"), dict) else {}
|
|
2454
|
+
paths = []
|
|
2455
|
+
for item in candidates:
|
|
2456
|
+
if not isinstance(item, dict):
|
|
2457
|
+
continue
|
|
2458
|
+
sourceId = str(item.get("sourceId", "") or "")
|
|
2459
|
+
candidateId = str(item.get("candidateId", "") or sourceId)
|
|
2460
|
+
meta = item.get("meta", {}) if isinstance(item.get("meta"), dict) else {}
|
|
2461
|
+
price = meta.get("price", {}) if isinstance(meta.get("price"), dict) else {}
|
|
2462
|
+
paths.append(
|
|
2463
|
+
{
|
|
2464
|
+
"candidateId": candidateId,
|
|
2465
|
+
"vendorId": sourceId,
|
|
2466
|
+
"source": str(meta.get("source", "") or ""),
|
|
2467
|
+
"title": str(meta.get("listingTitle", item.get("itemId", "")) or ""),
|
|
2468
|
+
"url": str(meta.get("sourceUrl", "") or ""),
|
|
2469
|
+
"kind": str(meta.get("kind", meta.get("placeKind", "")) or ""),
|
|
2470
|
+
"address": str(meta.get("address", "") or ""),
|
|
2471
|
+
"website": str(meta.get("website", "") or ""),
|
|
2472
|
+
"rating": meta.get("rating", 0),
|
|
2473
|
+
"distanceKm": meta.get("distanceKm", 0),
|
|
2474
|
+
"price": price,
|
|
2475
|
+
"status": "selected" if selected.get("sourceId") == sourceId else "pruned",
|
|
2476
|
+
"reasons": item.get("reasons", []) if isinstance(item.get("reasons"), list) else [],
|
|
2477
|
+
"netUsd": item.get("netUsd", 0),
|
|
2478
|
+
}
|
|
2479
|
+
)
|
|
2480
|
+
return {"root": "userRequest", "selected": str(selected.get("sourceId", "") or ""), "paths": paths}
|
|
2481
|
+
|
|
2482
|
+
@staticmethod
|
|
2483
|
+
def _itemId(request: str) -> str:
|
|
2484
|
+
match = re.search(r"\b(i\s*phone\s*\d+|iphone\s*\d+|iphone|smartphone|phone|tv|laptop|car|apartment|house|property|mechanic|lead|option)\b", request, re.IGNORECASE)
|
|
2485
|
+
if match:
|
|
2486
|
+
return re.sub(r"\s+", "", match.group(1).lower())
|
|
2487
|
+
subject = re.search(
|
|
2488
|
+
r"\b(?:buy|find|get|search(?: for)?|shop(?: for)?|source)\s+(?:a|an|the|my)?\s*(.+?)(?:\s+(?:under|less than|below|budget|for)\b|\s+(?:in|near|around)\s+[A-Z][A-Za-z]+|$)",
|
|
2489
|
+
request,
|
|
2490
|
+
re.IGNORECASE,
|
|
2491
|
+
)
|
|
2492
|
+
if subject:
|
|
2493
|
+
clean = re.sub(r"\b(?:used|best|cheap|affordable|new)\b", " ", subject.group(1), flags=re.IGNORECASE)
|
|
2494
|
+
clean = re.sub(r"[^A-Za-z0-9 ]+", " ", clean)
|
|
2495
|
+
clean = re.sub(r"\s+", " ", clean).strip().lower()
|
|
2496
|
+
if clean:
|
|
2497
|
+
return clean[:80]
|
|
2498
|
+
words = [word for word in re.findall(r"[A-Za-z0-9]+", request) if len(word) > 2]
|
|
2499
|
+
return words[0].lower() if words else "requestedItem"
|
|
2500
|
+
|
|
2501
|
+
@staticmethod
|
|
2502
|
+
def _conversationItemLabel(request: str) -> str:
|
|
2503
|
+
itemId = PicuxMultiAgentOrchestrator._itemId(request)
|
|
2504
|
+
if itemId and itemId != "requestedItem":
|
|
2505
|
+
return PicuxMultiAgentOrchestrator._displayItem(itemId)
|
|
2506
|
+
subject = re.search(
|
|
2507
|
+
r"\b(?:buy|find|get|search(?: for)?|shop(?: for)?|source)\s+(?:a|an|the|my)?\s*(.+?)(?:\s+(?:under|less than|below|budget|for)\b|\s+(?:in|near|around)\s+[A-Z][A-Za-z]+|$)",
|
|
2508
|
+
request,
|
|
2509
|
+
re.IGNORECASE,
|
|
2510
|
+
)
|
|
2511
|
+
if not subject:
|
|
2512
|
+
return "matching options"
|
|
2513
|
+
clean = re.sub(r"\b(?:used|best|cheap|affordable|new)\b", " ", subject.group(1), flags=re.IGNORECASE)
|
|
2514
|
+
clean = re.sub(r"[^A-Za-z0-9 ]+", " ", clean)
|
|
2515
|
+
clean = re.sub(r"\s+", " ", clean).strip()
|
|
2516
|
+
return clean[:80] or "matching options"
|
|
2517
|
+
|
|
2518
|
+
@classmethod
|
|
2519
|
+
def _huntConversationSummary(cls, request: str, hunt: dict[str, Any]) -> str:
|
|
2520
|
+
sourceResponse = hunt.get("sourceResponse", {}) if isinstance(hunt.get("sourceResponse"), dict) else {}
|
|
2521
|
+
aggregate = hunt.get("sourceAggregate", {}) if isinstance(hunt.get("sourceAggregate"), dict) else {}
|
|
2522
|
+
sources = aggregate.get("sources", []) if isinstance(aggregate.get("sources"), list) else []
|
|
2523
|
+
candidates = hunt.get("candidates", []) if isinstance(hunt.get("candidates"), list) else []
|
|
2524
|
+
offers = aggregate.get("offers", []) if isinstance(aggregate.get("offers"), list) else []
|
|
2525
|
+
label = cls._conversationItemLabel(request)
|
|
2526
|
+
marketplaceSet = sourceResponse.get("marketplaceSet", {}) if isinstance(sourceResponse.get("marketplaceSet"), dict) else {}
|
|
2527
|
+
localService = str(marketplaceSet.get("market", "") or "") == "localService"
|
|
2528
|
+
resultSources = cls._resultSources(candidates, offers, sources)
|
|
2529
|
+
if candidates:
|
|
2530
|
+
if localService:
|
|
2531
|
+
entity = str(marketplaceSet.get("entity", "") or label or "local service")
|
|
2532
|
+
location = str(marketplaceSet.get("location", "") or "").strip()
|
|
2533
|
+
noun = cls._pluralService(entity)
|
|
2534
|
+
line = f"Found {len(candidates)} {noun}"
|
|
2535
|
+
if location:
|
|
2536
|
+
line += f" in {location}"
|
|
2537
|
+
return line + "."
|
|
2538
|
+
top = cls._topListingSummaries(candidates)
|
|
2539
|
+
sourceText = cls._sourceList(resultSources)
|
|
2540
|
+
listingWord = ("place" if len(candidates) == 1 else "places") if localService else ("listing" if len(candidates) == 1 else "listings")
|
|
2541
|
+
line = f"Found {len(candidates)} source-bound {label} {listingWord}"
|
|
2542
|
+
if sourceText:
|
|
2543
|
+
marketWord = "map source" if localService and len(resultSources) == 1 else ("map sources" if localService else ("marketplace" if len(resultSources) == 1 else "marketplaces"))
|
|
2544
|
+
line += f" from {len(resultSources)} {marketWord}: {sourceText}"
|
|
2545
|
+
if top:
|
|
2546
|
+
line += f". Top matches: {'; '.join(top)}"
|
|
2547
|
+
return line + "."
|
|
2548
|
+
listingSources = [source for source in sources if int(source.get("listingCount", 0) or 0) > 0]
|
|
2549
|
+
if listingSources:
|
|
2550
|
+
listingCount = sum(int(source.get("listingCount", 0) or 0) for source in listingSources)
|
|
2551
|
+
sourceText = cls._sourceList([str(source.get("source", "") or "") for source in listingSources])
|
|
2552
|
+
return f"Found {listingCount} raw {label} listings on {sourceText}, but none passed the model, price, and evidence filters yet."
|
|
2553
|
+
attempted = sourceResponse.get("attemptedSources", []) if isinstance(sourceResponse.get("attemptedSources"), list) else []
|
|
2554
|
+
failed = sourceResponse.get("failedSources", []) if isinstance(sourceResponse.get("failedSources"), list) else []
|
|
2555
|
+
if attempted:
|
|
2556
|
+
sourceText = cls._sourceList([str(source) for source in attempted])
|
|
2557
|
+
if len(failed) == len(attempted):
|
|
2558
|
+
return f"Searched {sourceText}, but every source returned an I/O error or blocked page."
|
|
2559
|
+
if localService:
|
|
2560
|
+
return f"Searched {sourceText}, but no source returned a source-bound {label} place yet."
|
|
2561
|
+
return f"Searched {sourceText}, but no source returned a price-bound {label} listing yet."
|
|
2562
|
+
targets = sourceResponse.get("targetSources", []) if isinstance(sourceResponse.get("targetSources"), list) else []
|
|
2563
|
+
if targets:
|
|
2564
|
+
return f"Prepared {len(targets)} source targets for {label}: {cls._sourceList([str(source) for source in targets])}."
|
|
2565
|
+
return ""
|
|
2566
|
+
|
|
2567
|
+
@staticmethod
|
|
2568
|
+
def _pluralService(entity: str) -> str:
|
|
2569
|
+
clean = re.sub(r"\s+", " ", str(entity or "local service").strip().lower())
|
|
2570
|
+
if clean in {"mechanic", "car repair", "bilverkstad"}:
|
|
2571
|
+
return "mechanics"
|
|
2572
|
+
if clean.endswith("s"):
|
|
2573
|
+
return clean
|
|
2574
|
+
if clean.endswith("y"):
|
|
2575
|
+
return clean[:-1] + "ies"
|
|
2576
|
+
return clean + "s"
|
|
2577
|
+
|
|
2578
|
+
@staticmethod
|
|
2579
|
+
def _resultSources(candidates: list[Any], offers: list[Any], sources: list[Any]) -> list[str]:
|
|
2580
|
+
result: list[str] = []
|
|
2581
|
+
for item in [*candidates, *offers]:
|
|
2582
|
+
if not isinstance(item, dict):
|
|
2583
|
+
continue
|
|
2584
|
+
meta = item.get("meta", {}) if isinstance(item.get("meta"), dict) else {}
|
|
2585
|
+
source = str(meta.get("source", "") or "")
|
|
2586
|
+
if source:
|
|
2587
|
+
result.append(source)
|
|
2588
|
+
if result:
|
|
2589
|
+
return list(dict.fromkeys(result))
|
|
2590
|
+
return list(
|
|
2591
|
+
dict.fromkeys(
|
|
2592
|
+
str(source.get("source", "") or "")
|
|
2593
|
+
for source in sources
|
|
2594
|
+
if isinstance(source, dict) and int(source.get("listingCount", 0) or 0) > 0
|
|
2595
|
+
)
|
|
2596
|
+
)
|
|
2597
|
+
|
|
2598
|
+
@classmethod
|
|
2599
|
+
def _topListingSummaries(cls, candidates: list[Any]) -> list[str]:
|
|
2600
|
+
summaries = []
|
|
2601
|
+
ordered = [item for item in candidates if isinstance(item, dict)]
|
|
2602
|
+
ordered.sort(key=lambda item: (not bool(item.get("eligible")), float(item.get("netUsd", item.get("offerUsd", 0.0)) or 0.0)))
|
|
2603
|
+
for item in ordered[:3]:
|
|
2604
|
+
meta = item.get("meta", {}) if isinstance(item.get("meta"), dict) else {}
|
|
2605
|
+
title = str(meta.get("listingTitle", "") or item.get("itemId", "") or "listing").strip()
|
|
2606
|
+
source = cls._displaySource(str(meta.get("source", "") or ""))
|
|
2607
|
+
price = meta.get("price", {}) if isinstance(meta.get("price"), dict) else {}
|
|
2608
|
+
priceText = cls._formatPrice(price, float(item.get("netUsd", item.get("offerUsd", 0.0)) or 0.0))
|
|
2609
|
+
reasons = item.get("reasons", []) if isinstance(item.get("reasons"), list) else []
|
|
2610
|
+
suffix = ""
|
|
2611
|
+
if reasons:
|
|
2612
|
+
suffix = f" ({', '.join(str(reason) for reason in reasons[:2])})"
|
|
2613
|
+
summary = f"{title}"
|
|
2614
|
+
if priceText:
|
|
2615
|
+
summary += f" - {priceText}"
|
|
2616
|
+
if source:
|
|
2617
|
+
summary += f" on {source}"
|
|
2618
|
+
summaries.append(summary + suffix)
|
|
2619
|
+
return summaries
|
|
2620
|
+
|
|
2621
|
+
@staticmethod
|
|
2622
|
+
def _formatPrice(price: dict[str, Any], usd: float) -> str:
|
|
2623
|
+
amount = price.get("amount")
|
|
2624
|
+
currency = str(price.get("currency", "") or "").upper()
|
|
2625
|
+
if amount and currency:
|
|
2626
|
+
try:
|
|
2627
|
+
amountText = f"{float(amount):,.0f}" if float(amount).is_integer() else f"{float(amount):,.2f}"
|
|
2628
|
+
except Exception:
|
|
2629
|
+
amountText = str(amount)
|
|
2630
|
+
if currency == "USD":
|
|
2631
|
+
return f"${amountText}"
|
|
2632
|
+
return f"{amountText} {currency} (about ${usd:,.0f})"
|
|
2633
|
+
if usd > 0:
|
|
2634
|
+
return f"about ${usd:,.0f}"
|
|
2635
|
+
return ""
|
|
2636
|
+
|
|
2637
|
+
@staticmethod
|
|
2638
|
+
def _sourceList(sources: list[str]) -> str:
|
|
2639
|
+
names = [PicuxMultiAgentOrchestrator._displaySource(source) for source in sources if source]
|
|
2640
|
+
return ", ".join(list(dict.fromkeys(name for name in names if name))[:8])
|
|
2641
|
+
|
|
2642
|
+
@staticmethod
|
|
2643
|
+
def _displaySource(source: str) -> str:
|
|
2644
|
+
labels = {
|
|
2645
|
+
"blocket": "Blocket",
|
|
2646
|
+
"tradera": "Tradera",
|
|
2647
|
+
"facebook": "Facebook Marketplace",
|
|
2648
|
+
"ebay": "eBay",
|
|
2649
|
+
"alibaba": "Alibaba",
|
|
2650
|
+
"amazon": "Amazon",
|
|
2651
|
+
"shopify": "Shop",
|
|
2652
|
+
"jumia": "Jumia",
|
|
2653
|
+
"konga": "Konga",
|
|
2654
|
+
"jiji": "Jiji",
|
|
2655
|
+
"googleplaces": "Google Places",
|
|
2656
|
+
"openstreetmapnominatim": "OpenStreetMap",
|
|
2657
|
+
"clientplaces": "Client Places",
|
|
2658
|
+
}
|
|
2659
|
+
return labels.get(source.lower(), source)
|
|
2660
|
+
|
|
2661
|
+
@staticmethod
|
|
2662
|
+
def _displayItem(itemId: str) -> str:
|
|
2663
|
+
compact = re.sub(r"\s+", "", str(itemId or "")).lower()
|
|
2664
|
+
phone = re.match(r"iphone(\d+)$", compact)
|
|
2665
|
+
if phone:
|
|
2666
|
+
return f"iPhone {phone.group(1)}"
|
|
2667
|
+
if compact == "iphone":
|
|
2668
|
+
return "iPhone"
|
|
2669
|
+
if compact == "smartphone":
|
|
2670
|
+
return "smartphone"
|
|
2671
|
+
if compact == "tv":
|
|
2672
|
+
return "TV"
|
|
2673
|
+
return re.sub(r"\s+", " ", str(itemId or "")).strip()
|
|
2674
|
+
|
|
2675
|
+
@staticmethod
|
|
2676
|
+
def _budget(request: str, payload: dict[str, Any]) -> float:
|
|
2677
|
+
raw = payload.get("budget", payload.get("maxSpend", 0))
|
|
2678
|
+
try:
|
|
2679
|
+
if raw:
|
|
2680
|
+
return float(raw)
|
|
2681
|
+
except Exception:
|
|
2682
|
+
pass
|
|
2683
|
+
patterns = (
|
|
2684
|
+
r"(?:under|less than|below|budget|for)\s*\$?\s*([0-9]+(?:\.[0-9]{1,2})?)",
|
|
2685
|
+
r"\$\s*([0-9]+(?:\.[0-9]{1,2})?)",
|
|
2686
|
+
r"\b([0-9]+(?:\.[0-9]{1,2})?)\s*(?:dollar|usd)\b",
|
|
2687
|
+
)
|
|
2688
|
+
for pattern in patterns:
|
|
2689
|
+
match = re.search(pattern, request, re.IGNORECASE)
|
|
2690
|
+
if not match:
|
|
2691
|
+
continue
|
|
2692
|
+
try:
|
|
2693
|
+
return float(match.group(1))
|
|
2694
|
+
except Exception:
|
|
2695
|
+
return 0.0
|
|
2696
|
+
return 0.0
|
|
2697
|
+
|
|
2698
|
+
@classmethod
|
|
2699
|
+
def _startResolveRecovery(
|
|
2700
|
+
cls,
|
|
2701
|
+
host: Any,
|
|
2702
|
+
*,
|
|
2703
|
+
request: str,
|
|
2704
|
+
payload: dict[str, Any],
|
|
2705
|
+
target: dict[str, Any],
|
|
2706
|
+
documents: list[Any],
|
|
2707
|
+
taskId: str,
|
|
2708
|
+
conversationId: str,
|
|
2709
|
+
) -> dict[str, Any]:
|
|
2710
|
+
if not target or not hasattr(host, "resolveRecovery"):
|
|
2711
|
+
return {}
|
|
2712
|
+
if hasattr(host, "_ensureReadyConnectors"):
|
|
2713
|
+
try:
|
|
2714
|
+
host._ensureReadyConnectors()
|
|
2715
|
+
except Exception:
|
|
2716
|
+
pass
|
|
2717
|
+
recoveryPayload = {
|
|
2718
|
+
**payload,
|
|
2719
|
+
"claim": request,
|
|
2720
|
+
"text": request,
|
|
2721
|
+
"taskId": taskId,
|
|
2722
|
+
"conversationId": conversationId,
|
|
2723
|
+
"targetEntity": target,
|
|
2724
|
+
"attachments": documents,
|
|
2725
|
+
"request": {
|
|
2726
|
+
"issueType": cls._issueType(request),
|
|
2727
|
+
"attachments": documents,
|
|
2728
|
+
**(payload.get("request", {}) if isinstance(payload.get("request"), dict) else {}),
|
|
2729
|
+
},
|
|
2730
|
+
}
|
|
2731
|
+
try:
|
|
2732
|
+
result = host.resolveRecovery(recoveryPayload)
|
|
2733
|
+
except Exception as exc:
|
|
2734
|
+
return {"ok": False, "status": "blocked", "reason": type(exc).__name__}
|
|
2735
|
+
recovery = result.get("recovery", {}) if isinstance(result.get("recovery"), dict) else {}
|
|
2736
|
+
if not recovery:
|
|
2737
|
+
return {}
|
|
2738
|
+
return {
|
|
2739
|
+
**recovery,
|
|
2740
|
+
"taskId": str(result.get("taskId", taskId) or taskId),
|
|
2741
|
+
"case": result.get("case", {}) if isinstance(result.get("case"), dict) else {},
|
|
2742
|
+
"proofCard": result.get("proofCard", {}) if isinstance(result.get("proofCard"), dict) else {},
|
|
2743
|
+
}
|
|
2744
|
+
|
|
2745
|
+
@classmethod
|
|
2746
|
+
def _target(cls, request: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
2747
|
+
target = payload.get("target") if isinstance(payload.get("target"), dict) else payload.get("targetEntity", {})
|
|
2748
|
+
if isinstance(target, dict) and target:
|
|
2749
|
+
return target
|
|
2750
|
+
lowered = request.lower()
|
|
2751
|
+
if "bank" in lowered:
|
|
2752
|
+
return {"entityId": "bank", "name": "bank", "type": "bank"}
|
|
2753
|
+
domainMerchant = re.search(r"\b(?:to|from|with|at)\s*([A-Z]?[A-Za-z0-9-]+\.(?:se|com|net|org|io|co|ng|uk))\b", request, re.IGNORECASE)
|
|
2754
|
+
if domainMerchant:
|
|
2755
|
+
name = domainMerchant.group(1).strip()
|
|
2756
|
+
return {"entityId": cls._stableId("entity", {"name": name}), "name": name, "type": "merchant"}
|
|
2757
|
+
merchant = re.search(r"\bfrom\s+([A-Z][A-Za-z0-9.& -]{2,40})", request)
|
|
2758
|
+
if merchant:
|
|
2759
|
+
name = merchant.group(1).strip()
|
|
2760
|
+
return {"entityId": cls._stableId("entity", {"name": name}), "name": name, "type": "merchant"}
|
|
2761
|
+
if any(token in lowered for token in ("merchant", "shop", "store", "support")):
|
|
2762
|
+
return {"entityId": "merchant", "name": "merchant support", "type": "merchant"}
|
|
2763
|
+
return {}
|
|
2764
|
+
|
|
2765
|
+
@staticmethod
|
|
2766
|
+
def _preferredChannel(request: str) -> str:
|
|
2767
|
+
lowered = request.lower()
|
|
2768
|
+
if "email" in lowered:
|
|
2769
|
+
return "email"
|
|
2770
|
+
if "chat" in lowered or "whatsapp" in lowered:
|
|
2771
|
+
return "chat"
|
|
2772
|
+
if "phone" in lowered or "call" in lowered:
|
|
2773
|
+
return "phone"
|
|
2774
|
+
return ""
|
|
2775
|
+
|
|
2776
|
+
@staticmethod
|
|
2777
|
+
def _issueType(request: str) -> str:
|
|
2778
|
+
lowered = request.lower()
|
|
2779
|
+
if "broken" in lowered or "damaged" in lowered:
|
|
2780
|
+
return "damaged goods claim"
|
|
2781
|
+
if "fake" in lowered or "scam" in lowered:
|
|
2782
|
+
return "scam or fake offer claim"
|
|
2783
|
+
if "used" in lowered:
|
|
2784
|
+
return "used item verification"
|
|
2785
|
+
return "consumer claim"
|
|
2786
|
+
|
|
2787
|
+
@classmethod
|
|
2788
|
+
def _hasPhone(cls, request: str, payload: dict[str, Any]) -> bool:
|
|
2789
|
+
return bool(cls._phone(request, payload))
|
|
2790
|
+
|
|
2791
|
+
@staticmethod
|
|
2792
|
+
def _phone(request: str, payload: dict[str, Any]) -> str:
|
|
2793
|
+
raw = str(payload.get("phone", "") or "")
|
|
2794
|
+
if raw:
|
|
2795
|
+
return raw
|
|
2796
|
+
match = re.search(r"\+?\d[\d\s().-]{7,}\d", request)
|
|
2797
|
+
return match.group(0).strip() if match else ""
|
|
2798
|
+
|
|
2799
|
+
@staticmethod
|
|
2800
|
+
def _shouldCreateProxy(request: str, payload: dict[str, Any]) -> bool:
|
|
2801
|
+
if bool(payload.get("requiresProxy", payload.get("humanRequired", False))):
|
|
2802
|
+
return True
|
|
2803
|
+
lowered = request.lower()
|
|
2804
|
+
return any(
|
|
2805
|
+
token in lowered
|
|
2806
|
+
for token in (
|
|
2807
|
+
"place a call",
|
|
2808
|
+
"make a call",
|
|
2809
|
+
"call merchant",
|
|
2810
|
+
"call support",
|
|
2811
|
+
"human handoff",
|
|
2812
|
+
"manual review",
|
|
2813
|
+
"lawyer",
|
|
2814
|
+
"consultant",
|
|
2815
|
+
"customer representative",
|
|
2816
|
+
"customer represent",
|
|
2817
|
+
"representative",
|
|
2818
|
+
"followup",
|
|
2819
|
+
"follow up",
|
|
2820
|
+
"did not follow",
|
|
2821
|
+
"no response",
|
|
2822
|
+
"logistics",
|
|
2823
|
+
"pickup",
|
|
2824
|
+
"delivery",
|
|
2825
|
+
)
|
|
2826
|
+
)
|
|
2827
|
+
|
|
2828
|
+
@staticmethod
|
|
2829
|
+
def _isResolveCase(request: str) -> bool:
|
|
2830
|
+
lowered = str(request or "").lower()
|
|
2831
|
+
if any(token in lowered for token in ("dispute", "claim", "complaint", "broken", "damaged", "defective", "fake", "scam", "verify", "audit", "evidence", "receipt", "screenshot")):
|
|
2832
|
+
return True
|
|
2833
|
+
damagedDelivery = any(token in lowered for token in ("in-transit", "in transit", "transit", "delivery", "delivered")) and any(token in lowered for token in ("tv", "item", "goods", "package", "order"))
|
|
2834
|
+
merchantNoFollowup = any(token in lowered for token in ("followup", "follow up", "follow-up", "did not follow", "no response", "customer representative", "customer represent"))
|
|
2835
|
+
return damagedDelivery or merchantNoFollowup
|
|
2836
|
+
|
|
2837
|
+
@staticmethod
|
|
2838
|
+
def _stableId(prefix: str, payload: dict[str, Any]) -> str:
|
|
2839
|
+
digest = hashlib.sha256(json.dumps(payload, ensure_ascii=True, sort_keys=True).encode("utf-8")).hexdigest()
|
|
2840
|
+
return f"{prefix}_{digest[:24]}"
|