agent0-sdk 0.31__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.
- agent0_sdk/__init__.py +52 -0
- agent0_sdk/core/agent.py +992 -0
- agent0_sdk/core/contracts.py +497 -0
- agent0_sdk/core/endpoint_crawler.py +330 -0
- agent0_sdk/core/feedback_manager.py +1023 -0
- agent0_sdk/core/indexer.py +1754 -0
- agent0_sdk/core/ipfs_client.py +355 -0
- agent0_sdk/core/models.py +313 -0
- agent0_sdk/core/oasf_validator.py +98 -0
- agent0_sdk/core/sdk.py +1045 -0
- agent0_sdk/core/subgraph_client.py +833 -0
- agent0_sdk/core/web3_client.py +192 -0
- agent0_sdk/taxonomies/all_domains.json +1565 -0
- agent0_sdk/taxonomies/all_skills.json +1030 -0
- agent0_sdk-0.31.dist-info/METADATA +367 -0
- agent0_sdk-0.31.dist-info/RECORD +33 -0
- agent0_sdk-0.31.dist-info/WHEEL +5 -0
- agent0_sdk-0.31.dist-info/licenses/LICENSE +22 -0
- agent0_sdk-0.31.dist-info/top_level.txt +2 -0
- tests/__init__.py +1 -0
- tests/config.py +46 -0
- tests/conftest.py +22 -0
- tests/discover_test_data.py +445 -0
- tests/test_feedback.py +417 -0
- tests/test_models.py +224 -0
- tests/test_multi_chain.py +588 -0
- tests/test_oasf_management.py +404 -0
- tests/test_real_public_servers.py +103 -0
- tests/test_registration.py +267 -0
- tests/test_registrationIpfs.py +227 -0
- tests/test_sdk.py +240 -0
- tests/test_search.py +415 -0
- tests/test_transfer.py +255 -0
|
@@ -0,0 +1,1023 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Feedback management system for Agent0 SDK.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import time
|
|
10
|
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
|
|
13
|
+
from .models import (
|
|
14
|
+
AgentId, Address, URI, Timestamp, IdemKey,
|
|
15
|
+
Feedback, TrustModel, SearchFeedbackParams
|
|
16
|
+
)
|
|
17
|
+
from .web3_client import Web3Client
|
|
18
|
+
from .ipfs_client import IPFSClient
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class FeedbackManager:
|
|
24
|
+
"""Manages feedback operations for the Agent0 SDK."""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
web3_client: Web3Client,
|
|
29
|
+
ipfs_client: Optional[IPFSClient] = None,
|
|
30
|
+
reputation_registry: Any = None,
|
|
31
|
+
identity_registry: Any = None,
|
|
32
|
+
subgraph_client: Optional[Any] = None,
|
|
33
|
+
indexer: Optional[Any] = None,
|
|
34
|
+
):
|
|
35
|
+
"""Initialize feedback manager."""
|
|
36
|
+
self.web3_client = web3_client
|
|
37
|
+
self.ipfs_client = ipfs_client
|
|
38
|
+
self.reputation_registry = reputation_registry
|
|
39
|
+
self.identity_registry = identity_registry
|
|
40
|
+
self.subgraph_client = subgraph_client
|
|
41
|
+
self.indexer = indexer
|
|
42
|
+
|
|
43
|
+
def signFeedbackAuth(
|
|
44
|
+
self,
|
|
45
|
+
agentId: AgentId,
|
|
46
|
+
clientAddress: Address,
|
|
47
|
+
indexLimit: Optional[int] = None,
|
|
48
|
+
expiryHours: int = 24,
|
|
49
|
+
) -> bytes:
|
|
50
|
+
"""Sign feedback authorization for a client."""
|
|
51
|
+
# Parse agent ID to get token ID
|
|
52
|
+
if ":" in agentId:
|
|
53
|
+
tokenId = int(agentId.split(":")[-1])
|
|
54
|
+
else:
|
|
55
|
+
tokenId = int(agentId)
|
|
56
|
+
|
|
57
|
+
# Get current feedback index if not provided
|
|
58
|
+
if indexLimit is None:
|
|
59
|
+
try:
|
|
60
|
+
lastIndex = self.web3_client.call_contract(
|
|
61
|
+
self.reputation_registry,
|
|
62
|
+
"getLastIndex",
|
|
63
|
+
tokenId,
|
|
64
|
+
clientAddress
|
|
65
|
+
)
|
|
66
|
+
indexLimit = lastIndex + 1
|
|
67
|
+
except Exception as e:
|
|
68
|
+
# If we can't get the index, default to 1 (for first feedback)
|
|
69
|
+
indexLimit = 1
|
|
70
|
+
|
|
71
|
+
# Calculate expiry timestamp
|
|
72
|
+
expiry = int(time.time()) + (expiryHours * 3600)
|
|
73
|
+
|
|
74
|
+
# Encode feedback auth data
|
|
75
|
+
authData = self.web3_client.encodeFeedbackAuth(
|
|
76
|
+
agentId=tokenId,
|
|
77
|
+
clientAddress=clientAddress,
|
|
78
|
+
indexLimit=indexLimit,
|
|
79
|
+
expiry=expiry,
|
|
80
|
+
chainId=self.web3_client.chain_id,
|
|
81
|
+
identityRegistry=self.identity_registry.address if self.identity_registry else "0x0",
|
|
82
|
+
signerAddress=self.web3_client.account.address
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# Hash the encoded data first (matching contract's keccak256(abi.encode(...)))
|
|
86
|
+
messageHash = self.web3_client.w3.keccak(authData)
|
|
87
|
+
|
|
88
|
+
# Sign the hash with Ethereum signed message prefix (matching contract's .toEthSignedMessageHash())
|
|
89
|
+
from eth_account.messages import encode_defunct
|
|
90
|
+
signableMessage = encode_defunct(primitive=messageHash)
|
|
91
|
+
signedMessage = self.web3_client.account.sign_message(signableMessage)
|
|
92
|
+
signature = signedMessage.signature
|
|
93
|
+
|
|
94
|
+
# Combine auth data and signature
|
|
95
|
+
return authData + signature
|
|
96
|
+
|
|
97
|
+
def prepareFeedback(
|
|
98
|
+
self,
|
|
99
|
+
agentId: AgentId,
|
|
100
|
+
score: Optional[int] = None, # 0-100
|
|
101
|
+
tags: List[str] = None,
|
|
102
|
+
text: Optional[str] = None,
|
|
103
|
+
capability: Optional[str] = None,
|
|
104
|
+
name: Optional[str] = None,
|
|
105
|
+
skill: Optional[str] = None,
|
|
106
|
+
task: Optional[str] = None,
|
|
107
|
+
context: Optional[Dict[str, Any]] = None,
|
|
108
|
+
proofOfPayment: Optional[Dict[str, Any]] = None,
|
|
109
|
+
extra: Optional[Dict[str, Any]] = None,
|
|
110
|
+
) -> Dict[str, Any]:
|
|
111
|
+
"""Prepare feedback file (local file/object) according to spec."""
|
|
112
|
+
if tags is None:
|
|
113
|
+
tags = []
|
|
114
|
+
|
|
115
|
+
# Parse agent ID to get token ID
|
|
116
|
+
if ":" in agentId:
|
|
117
|
+
tokenId = int(agentId.split(":")[-1])
|
|
118
|
+
else:
|
|
119
|
+
tokenId = int(agentId)
|
|
120
|
+
|
|
121
|
+
# Get current timestamp in ISO format
|
|
122
|
+
createdAt = datetime.fromtimestamp(time.time()).isoformat() + "Z"
|
|
123
|
+
|
|
124
|
+
# Build feedback data according to spec
|
|
125
|
+
feedbackData = {
|
|
126
|
+
# MUST FIELDS
|
|
127
|
+
"agentRegistry": f"eip155:{self.web3_client.chain_id}:{self.identity_registry.address if self.identity_registry else '0x0'}",
|
|
128
|
+
"agentId": tokenId,
|
|
129
|
+
"clientAddress": f"eip155:{self.web3_client.chain_id}:{self.web3_client.account.address}",
|
|
130
|
+
"createdAt": createdAt,
|
|
131
|
+
"feedbackAuth": "", # Will be filled when giving feedback
|
|
132
|
+
"score": int(score) if score else 0, # Score as integer (0-100)
|
|
133
|
+
|
|
134
|
+
# MAY FIELDS
|
|
135
|
+
"tag1": tags[0] if tags else None,
|
|
136
|
+
"tag2": tags[1] if len(tags) > 1 else None,
|
|
137
|
+
"skill": skill,
|
|
138
|
+
"context": context,
|
|
139
|
+
"task": task,
|
|
140
|
+
"capability": capability,
|
|
141
|
+
"name": name,
|
|
142
|
+
"proofOfPayment": proofOfPayment,
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
# Remove None values to keep the structure clean
|
|
146
|
+
feedbackData = {k: v for k, v in feedbackData.items() if v is not None}
|
|
147
|
+
|
|
148
|
+
if extra:
|
|
149
|
+
feedbackData.update(extra)
|
|
150
|
+
|
|
151
|
+
return feedbackData
|
|
152
|
+
|
|
153
|
+
def giveFeedback(
|
|
154
|
+
self,
|
|
155
|
+
agentId: AgentId,
|
|
156
|
+
feedbackFile: Dict[str, Any],
|
|
157
|
+
idem: Optional[IdemKey] = None,
|
|
158
|
+
feedbackAuth: Optional[bytes] = None,
|
|
159
|
+
) -> Feedback:
|
|
160
|
+
"""Give feedback (maps 8004 endpoint)."""
|
|
161
|
+
# Parse agent ID
|
|
162
|
+
if ":" in agentId:
|
|
163
|
+
tokenId = int(agentId.split(":")[-1])
|
|
164
|
+
else:
|
|
165
|
+
tokenId = int(agentId)
|
|
166
|
+
|
|
167
|
+
# Get client address (the one giving feedback)
|
|
168
|
+
# Keep in checksum format for blockchain calls (web3.py requirement)
|
|
169
|
+
clientAddress = self.web3_client.account.address
|
|
170
|
+
|
|
171
|
+
# Get current feedback index for this client-agent pair
|
|
172
|
+
try:
|
|
173
|
+
lastIndex = self.web3_client.call_contract(
|
|
174
|
+
self.reputation_registry,
|
|
175
|
+
"getLastIndex",
|
|
176
|
+
tokenId,
|
|
177
|
+
clientAddress
|
|
178
|
+
)
|
|
179
|
+
feedbackIndex = lastIndex + 1
|
|
180
|
+
except Exception as e:
|
|
181
|
+
raise ValueError(f"Failed to get feedback index: {e}")
|
|
182
|
+
|
|
183
|
+
# Prepare feedback auth (use provided auth or create new one)
|
|
184
|
+
if feedbackAuth is None:
|
|
185
|
+
feedbackAuth = self.sign_feedbackAuth(
|
|
186
|
+
agentId=agentId,
|
|
187
|
+
clientAddress=clientAddress,
|
|
188
|
+
indexLimit=feedbackIndex,
|
|
189
|
+
expiryHours=24
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
# Update feedback file with auth
|
|
193
|
+
feedbackFile["feedbackAuth"] = feedbackAuth.hex()
|
|
194
|
+
|
|
195
|
+
# Prepare on-chain data (only basic fields, no capability/endpoint)
|
|
196
|
+
score = feedbackFile.get("score", 0) # Already in 0-100 range
|
|
197
|
+
tag1 = self._stringToBytes32(feedbackFile.get("tag1", ""))
|
|
198
|
+
tag2 = self._stringToBytes32(feedbackFile.get("tag2", ""))
|
|
199
|
+
|
|
200
|
+
# Handle off-chain file storage
|
|
201
|
+
feedbackUri = ""
|
|
202
|
+
feedbackHash = b"\x00" * 32 # Default empty hash
|
|
203
|
+
|
|
204
|
+
if self.ipfs_client:
|
|
205
|
+
# Store feedback file on IPFS using Filecoin Pin
|
|
206
|
+
try:
|
|
207
|
+
logger.debug("Storing feedback file on IPFS")
|
|
208
|
+
cid = self.ipfs_client.add_json(feedbackFile)
|
|
209
|
+
feedbackUri = f"ipfs://{cid}"
|
|
210
|
+
feedbackHash = self.web3_client.keccak256(json.dumps(feedbackFile, sort_keys=True).encode())
|
|
211
|
+
logger.debug(f"Feedback file stored on IPFS: {cid}")
|
|
212
|
+
except Exception as e:
|
|
213
|
+
logger.warning(f"Failed to store feedback on IPFS: {e}")
|
|
214
|
+
# Continue without IPFS storage
|
|
215
|
+
elif feedbackFile.get("context") or feedbackFile.get("capability") or feedbackFile.get("name"):
|
|
216
|
+
# If we have rich data but no IPFS, we need to store it somewhere
|
|
217
|
+
raise ValueError("Rich feedback data requires IPFS client for storage")
|
|
218
|
+
|
|
219
|
+
# Submit to blockchain
|
|
220
|
+
try:
|
|
221
|
+
txHash = self.web3_client.transact_contract(
|
|
222
|
+
self.reputation_registry,
|
|
223
|
+
"giveFeedback",
|
|
224
|
+
tokenId,
|
|
225
|
+
score,
|
|
226
|
+
tag1,
|
|
227
|
+
tag2,
|
|
228
|
+
feedbackUri,
|
|
229
|
+
feedbackHash,
|
|
230
|
+
feedbackAuth
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
# Wait for transaction confirmation
|
|
234
|
+
receipt = self.web3_client.wait_for_transaction(txHash)
|
|
235
|
+
|
|
236
|
+
except Exception as e:
|
|
237
|
+
raise ValueError(f"Failed to submit feedback to blockchain: {e}")
|
|
238
|
+
|
|
239
|
+
# Create feedback object (address normalization happens in Feedback.create_id)
|
|
240
|
+
feedbackId = Feedback.create_id(agentId, clientAddress, feedbackIndex)
|
|
241
|
+
|
|
242
|
+
return Feedback(
|
|
243
|
+
id=feedbackId,
|
|
244
|
+
agentId=agentId,
|
|
245
|
+
reviewer=clientAddress, # create_id normalizes the ID; reviewer field can remain as-is
|
|
246
|
+
score=int(score) if score and score > 0 else None,
|
|
247
|
+
tags=[feedbackFile.get("tag1"), feedbackFile.get("tag2")] if feedbackFile.get("tag1") else [],
|
|
248
|
+
text=feedbackFile.get("text"),
|
|
249
|
+
context=feedbackFile.get("context"),
|
|
250
|
+
proofOfPayment=feedbackFile.get("proofOfPayment"),
|
|
251
|
+
fileURI=feedbackUri if feedbackUri else None,
|
|
252
|
+
createdAt=int(time.time()),
|
|
253
|
+
isRevoked=False,
|
|
254
|
+
# Off-chain only fields
|
|
255
|
+
capability=feedbackFile.get("capability"),
|
|
256
|
+
name=feedbackFile.get("name"),
|
|
257
|
+
skill=feedbackFile.get("skill"),
|
|
258
|
+
task=feedbackFile.get("task")
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
def getFeedback(
|
|
262
|
+
self,
|
|
263
|
+
agentId: AgentId,
|
|
264
|
+
clientAddress: Address,
|
|
265
|
+
feedbackIndex: int,
|
|
266
|
+
) -> Feedback:
|
|
267
|
+
"""Get single feedback with responses from subgraph or blockchain."""
|
|
268
|
+
# Use indexer for subgraph queries (unified search interface)
|
|
269
|
+
if self.indexer and self.subgraph_client:
|
|
270
|
+
# Indexer handles subgraph queries for unified search architecture
|
|
271
|
+
# This enables future semantic search capabilities
|
|
272
|
+
return self.indexer.get_feedback(agentId, clientAddress, feedbackIndex)
|
|
273
|
+
|
|
274
|
+
# Fallback: direct subgraph access (if indexer not available)
|
|
275
|
+
if self.subgraph_client:
|
|
276
|
+
return self._get_feedback_from_subgraph(agentId, clientAddress, feedbackIndex)
|
|
277
|
+
|
|
278
|
+
# Fallback to blockchain (direct contract query)
|
|
279
|
+
return self._get_feedback_from_blockchain(agentId, clientAddress, feedbackIndex)
|
|
280
|
+
|
|
281
|
+
def _get_feedback_from_subgraph(
|
|
282
|
+
self,
|
|
283
|
+
agentId: AgentId,
|
|
284
|
+
clientAddress: Address,
|
|
285
|
+
feedbackIndex: int,
|
|
286
|
+
) -> Feedback:
|
|
287
|
+
"""Get feedback from subgraph."""
|
|
288
|
+
# Normalize addresses to lowercase for consistent storage
|
|
289
|
+
normalized_client_address = self.web3_client.normalize_address(clientAddress)
|
|
290
|
+
|
|
291
|
+
# Build feedback ID in format: chainId:agentId:clientAddress:feedbackIndex
|
|
292
|
+
# If agentId already contains chainId (format: chainId:tokenId), use it as is
|
|
293
|
+
# Otherwise, prepend chainId from web3_client
|
|
294
|
+
if ":" in agentId:
|
|
295
|
+
# agentId already has chainId, so use it directly
|
|
296
|
+
feedback_id = f"{agentId}:{normalized_client_address}:{feedbackIndex}"
|
|
297
|
+
else:
|
|
298
|
+
# No chainId in agentId, prepend it
|
|
299
|
+
chain_id = str(self.web3_client.chain_id)
|
|
300
|
+
feedback_id = f"{chain_id}:{agentId}:{normalized_client_address}:{feedbackIndex}"
|
|
301
|
+
|
|
302
|
+
try:
|
|
303
|
+
feedback_data = self.subgraph_client.get_feedback_by_id(feedback_id)
|
|
304
|
+
|
|
305
|
+
if feedback_data is None:
|
|
306
|
+
raise ValueError(f"Feedback {feedback_id} not found in subgraph")
|
|
307
|
+
|
|
308
|
+
feedback_file = feedback_data.get('feedbackFile') or {}
|
|
309
|
+
if not isinstance(feedback_file, dict):
|
|
310
|
+
feedback_file = {}
|
|
311
|
+
|
|
312
|
+
# Map responses
|
|
313
|
+
responses_data = feedback_data.get('responses', [])
|
|
314
|
+
answers = []
|
|
315
|
+
for resp in responses_data:
|
|
316
|
+
answers.append({
|
|
317
|
+
'responder': resp.get('responder'),
|
|
318
|
+
'responseUri': resp.get('responseUri'),
|
|
319
|
+
'responseHash': resp.get('responseHash'),
|
|
320
|
+
'createdAt': resp.get('createdAt')
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
# Map tags - check if they're hex bytes32 or plain strings
|
|
324
|
+
tags = []
|
|
325
|
+
tag1 = feedback_data.get('tag1') or feedback_file.get('tag1')
|
|
326
|
+
tag2 = feedback_data.get('tag2') or feedback_file.get('tag2')
|
|
327
|
+
|
|
328
|
+
# Convert hex bytes32 to readable tags
|
|
329
|
+
if tag1 or tag2:
|
|
330
|
+
tags = self._hexBytes32ToTags(
|
|
331
|
+
tag1 if isinstance(tag1, str) else "",
|
|
332
|
+
tag2 if isinstance(tag2, str) else ""
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
# If conversion failed, try as plain strings
|
|
336
|
+
if not tags:
|
|
337
|
+
if tag1 and not tag1.startswith("0x"):
|
|
338
|
+
tags.append(tag1)
|
|
339
|
+
if tag2 and not tag2.startswith("0x"):
|
|
340
|
+
tags.append(tag2)
|
|
341
|
+
|
|
342
|
+
return Feedback(
|
|
343
|
+
id=Feedback.create_id(agentId, clientAddress, feedbackIndex), # create_id now normalizes
|
|
344
|
+
agentId=agentId,
|
|
345
|
+
reviewer=self.web3_client.normalize_address(clientAddress), # Also normalize reviewer field
|
|
346
|
+
score=feedback_data.get('score'),
|
|
347
|
+
tags=tags,
|
|
348
|
+
text=feedback_file.get('text'),
|
|
349
|
+
capability=feedback_file.get('capability'),
|
|
350
|
+
context=feedback_file.get('context'),
|
|
351
|
+
proofOfPayment={
|
|
352
|
+
'fromAddress': feedback_file.get('proofOfPaymentFromAddress'),
|
|
353
|
+
'toAddress': feedback_file.get('proofOfPaymentToAddress'),
|
|
354
|
+
'chainId': feedback_file.get('proofOfPaymentChainId'),
|
|
355
|
+
'txHash': feedback_file.get('proofOfPaymentTxHash'),
|
|
356
|
+
} if feedback_file.get('proofOfPaymentFromAddress') else None,
|
|
357
|
+
fileURI=feedback_data.get('feedbackUri'),
|
|
358
|
+
createdAt=feedback_data.get('createdAt', int(time.time())),
|
|
359
|
+
answers=answers,
|
|
360
|
+
isRevoked=feedback_data.get('isRevoked', False),
|
|
361
|
+
name=feedback_file.get('name'),
|
|
362
|
+
skill=feedback_file.get('skill'),
|
|
363
|
+
task=feedback_file.get('task'),
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
except Exception as e:
|
|
367
|
+
raise ValueError(f"Failed to get feedback from subgraph: {e}")
|
|
368
|
+
|
|
369
|
+
def _get_feedback_from_blockchain(
|
|
370
|
+
self,
|
|
371
|
+
agentId: AgentId,
|
|
372
|
+
clientAddress: Address,
|
|
373
|
+
feedbackIndex: int,
|
|
374
|
+
) -> Feedback:
|
|
375
|
+
"""Get feedback from blockchain (fallback)."""
|
|
376
|
+
# Parse agent ID
|
|
377
|
+
if ":" in agentId:
|
|
378
|
+
tokenId = int(agentId.split(":")[-1])
|
|
379
|
+
else:
|
|
380
|
+
tokenId = int(agentId)
|
|
381
|
+
|
|
382
|
+
try:
|
|
383
|
+
# Read from blockchain
|
|
384
|
+
result = self.web3_client.call_contract(
|
|
385
|
+
self.reputation_registry,
|
|
386
|
+
"readFeedback",
|
|
387
|
+
tokenId,
|
|
388
|
+
clientAddress,
|
|
389
|
+
feedbackIndex
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
score, tag1, tag2, is_revoked = result
|
|
393
|
+
|
|
394
|
+
# Create feedback object (normalize address for consistency)
|
|
395
|
+
normalized_address = self.web3_client.normalize_address(clientAddress)
|
|
396
|
+
feedbackId = Feedback.create_id(agentId, normalized_address, feedbackIndex)
|
|
397
|
+
|
|
398
|
+
return Feedback(
|
|
399
|
+
id=feedbackId,
|
|
400
|
+
agentId=agentId,
|
|
401
|
+
reviewer=normalized_address,
|
|
402
|
+
score=int(score) if score and score > 0 else None,
|
|
403
|
+
tags=self._bytes32ToTags(tag1, tag2),
|
|
404
|
+
text=None, # Not stored on-chain
|
|
405
|
+
capability=None, # Not stored on-chain
|
|
406
|
+
context=None, # Not stored on-chain
|
|
407
|
+
proofOfPayment=None, # Not stored on-chain
|
|
408
|
+
fileURI=None, # Would need to be retrieved separately
|
|
409
|
+
createdAt=int(time.time()), # Not stored on-chain
|
|
410
|
+
isRevoked=is_revoked
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
except Exception as e:
|
|
414
|
+
raise ValueError(f"Failed to get feedback: {e}")
|
|
415
|
+
|
|
416
|
+
def searchFeedback(
|
|
417
|
+
self,
|
|
418
|
+
agentId: AgentId,
|
|
419
|
+
clientAddresses: Optional[List[Address]] = None,
|
|
420
|
+
tags: Optional[List[str]] = None,
|
|
421
|
+
capabilities: Optional[List[str]] = None,
|
|
422
|
+
skills: Optional[List[str]] = None,
|
|
423
|
+
tasks: Optional[List[str]] = None,
|
|
424
|
+
names: Optional[List[str]] = None,
|
|
425
|
+
minScore: Optional[int] = None,
|
|
426
|
+
maxScore: Optional[int] = None,
|
|
427
|
+
include_revoked: bool = False,
|
|
428
|
+
first: int = 100,
|
|
429
|
+
skip: int = 0,
|
|
430
|
+
) -> List[Feedback]:
|
|
431
|
+
"""Search feedback for an agent - uses subgraph if available."""
|
|
432
|
+
# Use indexer for subgraph queries (unified search interface)
|
|
433
|
+
if self.indexer and self.subgraph_client:
|
|
434
|
+
# Indexer handles subgraph queries for unified search architecture
|
|
435
|
+
# This enables future semantic search capabilities
|
|
436
|
+
return self.indexer.search_feedback(
|
|
437
|
+
agentId, clientAddresses, tags, capabilities, skills, tasks, names,
|
|
438
|
+
minScore, maxScore, include_revoked, first, skip
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
# Fallback: direct subgraph access (if indexer not available)
|
|
442
|
+
if self.subgraph_client:
|
|
443
|
+
return self._search_feedback_subgraph(
|
|
444
|
+
agentId, clientAddresses, tags, capabilities, skills, tasks, names,
|
|
445
|
+
minScore, maxScore, include_revoked, first, skip
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
# Fallback to blockchain
|
|
449
|
+
# Parse agent ID
|
|
450
|
+
if ":" in agentId:
|
|
451
|
+
tokenId = int(agentId.split(":")[-1])
|
|
452
|
+
else:
|
|
453
|
+
tokenId = int(agentId)
|
|
454
|
+
|
|
455
|
+
try:
|
|
456
|
+
# Prepare filter parameters
|
|
457
|
+
client_list = clientAddresses if clientAddresses else []
|
|
458
|
+
tag1_filter = self._stringToBytes32(tags[0] if tags else "")
|
|
459
|
+
tag2_filter = self._stringToBytes32(tags[1] if tags and len(tags) > 1 else "")
|
|
460
|
+
|
|
461
|
+
# Read from blockchain
|
|
462
|
+
result = self.web3_client.call_contract(
|
|
463
|
+
self.reputation_registry,
|
|
464
|
+
"readAllFeedback",
|
|
465
|
+
tokenId,
|
|
466
|
+
client_list,
|
|
467
|
+
tag1_filter,
|
|
468
|
+
tag2_filter,
|
|
469
|
+
include_revoked
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
clients, scores, tag1s, tag2s, revoked_statuses = result
|
|
473
|
+
|
|
474
|
+
# Convert to Feedback objects
|
|
475
|
+
feedbacks = []
|
|
476
|
+
for i in range(len(clients)):
|
|
477
|
+
feedbackId = Feedback.create_id(agentId, clients[i], i + 1) # Assuming 1-indexed
|
|
478
|
+
|
|
479
|
+
feedback = Feedback(
|
|
480
|
+
id=feedbackId,
|
|
481
|
+
agentId=agentId,
|
|
482
|
+
reviewer=clients[i],
|
|
483
|
+
score=int(scores[i]) if scores[i] and scores[i] > 0 else None,
|
|
484
|
+
tags=self._bytes32ToTags(tag1s[i], tag2s[i]),
|
|
485
|
+
text=None,
|
|
486
|
+
capability=None,
|
|
487
|
+
endpoint=None,
|
|
488
|
+
context=None,
|
|
489
|
+
proofOfPayment=None,
|
|
490
|
+
fileURI=None,
|
|
491
|
+
createdAt=int(time.time()),
|
|
492
|
+
isRevoked=revoked_statuses[i]
|
|
493
|
+
)
|
|
494
|
+
feedbacks.append(feedback)
|
|
495
|
+
|
|
496
|
+
return feedbacks
|
|
497
|
+
|
|
498
|
+
except Exception as e:
|
|
499
|
+
raise ValueError(f"Failed to search feedback: {e}")
|
|
500
|
+
|
|
501
|
+
def _search_feedback_subgraph(
|
|
502
|
+
self,
|
|
503
|
+
agentId: AgentId,
|
|
504
|
+
clientAddresses: Optional[List[Address]],
|
|
505
|
+
tags: Optional[List[str]],
|
|
506
|
+
capabilities: Optional[List[str]],
|
|
507
|
+
skills: Optional[List[str]],
|
|
508
|
+
tasks: Optional[List[str]],
|
|
509
|
+
names: Optional[List[str]],
|
|
510
|
+
minScore: Optional[int],
|
|
511
|
+
maxScore: Optional[int],
|
|
512
|
+
include_revoked: bool,
|
|
513
|
+
first: int,
|
|
514
|
+
skip: int,
|
|
515
|
+
) -> List[Feedback]:
|
|
516
|
+
"""Search feedback using subgraph."""
|
|
517
|
+
# Create SearchFeedbackParams
|
|
518
|
+
params = SearchFeedbackParams(
|
|
519
|
+
agents=[agentId],
|
|
520
|
+
reviewers=clientAddresses,
|
|
521
|
+
tags=tags,
|
|
522
|
+
capabilities=capabilities,
|
|
523
|
+
skills=skills,
|
|
524
|
+
tasks=tasks,
|
|
525
|
+
names=names,
|
|
526
|
+
minScore=minScore,
|
|
527
|
+
maxScore=maxScore,
|
|
528
|
+
includeRevoked=include_revoked
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
# Query subgraph
|
|
532
|
+
feedbacks_data = self.subgraph_client.search_feedback(
|
|
533
|
+
params=params,
|
|
534
|
+
first=first,
|
|
535
|
+
skip=skip,
|
|
536
|
+
order_by="createdAt",
|
|
537
|
+
order_direction="desc"
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
# Map to Feedback objects
|
|
541
|
+
feedbacks = []
|
|
542
|
+
for fb_data in feedbacks_data:
|
|
543
|
+
feedback_file = fb_data.get('feedbackFile') or {}
|
|
544
|
+
if not isinstance(feedback_file, dict):
|
|
545
|
+
feedback_file = {}
|
|
546
|
+
|
|
547
|
+
# Map responses
|
|
548
|
+
responses_data = fb_data.get('responses', [])
|
|
549
|
+
answers = []
|
|
550
|
+
for resp in responses_data:
|
|
551
|
+
answers.append({
|
|
552
|
+
'responder': resp.get('responder'),
|
|
553
|
+
'responseUri': resp.get('responseUri'),
|
|
554
|
+
'responseHash': resp.get('responseHash'),
|
|
555
|
+
'createdAt': resp.get('createdAt')
|
|
556
|
+
})
|
|
557
|
+
|
|
558
|
+
# Map tags - check if they're hex bytes32 or plain strings
|
|
559
|
+
tags_list = []
|
|
560
|
+
tag1 = fb_data.get('tag1') or feedback_file.get('tag1')
|
|
561
|
+
tag2 = fb_data.get('tag2') or feedback_file.get('tag2')
|
|
562
|
+
|
|
563
|
+
# Convert hex bytes32 to readable tags
|
|
564
|
+
if tag1 or tag2:
|
|
565
|
+
tags_list = self._hexBytes32ToTags(
|
|
566
|
+
tag1 if isinstance(tag1, str) else "",
|
|
567
|
+
tag2 if isinstance(tag2, str) else ""
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
# If conversion failed, try as plain strings
|
|
571
|
+
if not tags_list:
|
|
572
|
+
if tag1 and not tag1.startswith("0x"):
|
|
573
|
+
tags_list.append(tag1)
|
|
574
|
+
if tag2 and not tag2.startswith("0x"):
|
|
575
|
+
tags_list.append(tag2)
|
|
576
|
+
|
|
577
|
+
# Parse agentId from feedback ID
|
|
578
|
+
feedback_id = fb_data['id']
|
|
579
|
+
parts = feedback_id.split(':')
|
|
580
|
+
if len(parts) >= 2:
|
|
581
|
+
agent_id_str = f"{parts[0]}:{parts[1]}"
|
|
582
|
+
client_addr = parts[2] if len(parts) > 2 else ""
|
|
583
|
+
feedback_idx = int(parts[3]) if len(parts) > 3 else 1
|
|
584
|
+
else:
|
|
585
|
+
agent_id_str = feedback_id
|
|
586
|
+
client_addr = ""
|
|
587
|
+
feedback_idx = 1
|
|
588
|
+
|
|
589
|
+
feedback = Feedback(
|
|
590
|
+
id=Feedback.create_id(agent_id_str, client_addr, feedback_idx),
|
|
591
|
+
agentId=agent_id_str,
|
|
592
|
+
reviewer=client_addr,
|
|
593
|
+
score=fb_data.get('score'),
|
|
594
|
+
tags=tags_list,
|
|
595
|
+
text=feedback_file.get('text'),
|
|
596
|
+
capability=feedback_file.get('capability'),
|
|
597
|
+
context=feedback_file.get('context'),
|
|
598
|
+
proofOfPayment={
|
|
599
|
+
'fromAddress': feedback_file.get('proofOfPaymentFromAddress'),
|
|
600
|
+
'toAddress': feedback_file.get('proofOfPaymentToAddress'),
|
|
601
|
+
'chainId': feedback_file.get('proofOfPaymentChainId'),
|
|
602
|
+
'txHash': feedback_file.get('proofOfPaymentTxHash'),
|
|
603
|
+
} if feedback_file.get('proofOfPaymentFromAddress') else None,
|
|
604
|
+
fileURI=fb_data.get('feedbackUri'),
|
|
605
|
+
createdAt=fb_data.get('createdAt', int(time.time())),
|
|
606
|
+
answers=answers,
|
|
607
|
+
isRevoked=fb_data.get('isRevoked', False),
|
|
608
|
+
name=feedback_file.get('name'),
|
|
609
|
+
skill=feedback_file.get('skill'),
|
|
610
|
+
task=feedback_file.get('task'),
|
|
611
|
+
)
|
|
612
|
+
feedbacks.append(feedback)
|
|
613
|
+
|
|
614
|
+
return feedbacks
|
|
615
|
+
|
|
616
|
+
def revokeFeedback(
|
|
617
|
+
self,
|
|
618
|
+
agentId: AgentId,
|
|
619
|
+
feedbackIndex: int,
|
|
620
|
+
) -> Dict[str, Any]:
|
|
621
|
+
"""Revoke feedback."""
|
|
622
|
+
# Parse agent ID
|
|
623
|
+
if ":" in agentId:
|
|
624
|
+
tokenId = int(agentId.split(":")[-1])
|
|
625
|
+
else:
|
|
626
|
+
tokenId = int(agentId)
|
|
627
|
+
|
|
628
|
+
clientAddress = self.web3_client.account.address
|
|
629
|
+
|
|
630
|
+
try:
|
|
631
|
+
txHash = self.web3_client.transact_contract(
|
|
632
|
+
self.reputation_registry,
|
|
633
|
+
"revokeFeedback",
|
|
634
|
+
tokenId,
|
|
635
|
+
feedbackIndex
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
receipt = self.web3_client.wait_for_transaction(txHash)
|
|
639
|
+
|
|
640
|
+
return {
|
|
641
|
+
"txHash": txHash,
|
|
642
|
+
"agentId": agentId,
|
|
643
|
+
"clientAddress": clientAddress,
|
|
644
|
+
"feedbackIndex": feedbackIndex,
|
|
645
|
+
"status": "revoked"
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
except Exception as e:
|
|
649
|
+
raise ValueError(f"Failed to revoke feedback: {e}")
|
|
650
|
+
|
|
651
|
+
def appendResponse(
|
|
652
|
+
self,
|
|
653
|
+
agentId: AgentId,
|
|
654
|
+
clientAddress: Address,
|
|
655
|
+
feedbackIndex: int,
|
|
656
|
+
response: Dict[str, Any],
|
|
657
|
+
) -> Feedback:
|
|
658
|
+
"""Append a response/follow-up to existing feedback."""
|
|
659
|
+
# Parse agent ID
|
|
660
|
+
if ":" in agentId:
|
|
661
|
+
tokenId = int(agentId.split(":")[-1])
|
|
662
|
+
else:
|
|
663
|
+
tokenId = int(agentId)
|
|
664
|
+
|
|
665
|
+
# Prepare response data
|
|
666
|
+
responseText = response.get("text", "")
|
|
667
|
+
responseUri = ""
|
|
668
|
+
responseHash = b"\x00" * 32
|
|
669
|
+
|
|
670
|
+
if self.ipfs_client and (response.get("text") or response.get("attachments")):
|
|
671
|
+
try:
|
|
672
|
+
cid = self.ipfs_client.add_json(response)
|
|
673
|
+
responseUri = f"ipfs://{cid}"
|
|
674
|
+
responseHash = self.web3_client.keccak256(json.dumps(response, sort_keys=True).encode())
|
|
675
|
+
except Exception as e:
|
|
676
|
+
logger.warning(f"Failed to store response on IPFS: {e}")
|
|
677
|
+
|
|
678
|
+
try:
|
|
679
|
+
txHash = self.web3_client.transact_contract(
|
|
680
|
+
self.reputation_registry,
|
|
681
|
+
"appendResponse",
|
|
682
|
+
tokenId,
|
|
683
|
+
clientAddress,
|
|
684
|
+
feedbackIndex,
|
|
685
|
+
responseUri,
|
|
686
|
+
responseHash
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
receipt = self.web3_client.wait_for_transaction(txHash)
|
|
690
|
+
|
|
691
|
+
# Read updated feedback
|
|
692
|
+
return self.getFeedback(agentId, clientAddress, feedbackIndex)
|
|
693
|
+
|
|
694
|
+
except Exception as e:
|
|
695
|
+
raise ValueError(f"Failed to append response: {e}")
|
|
696
|
+
|
|
697
|
+
def getReputationSummary(
|
|
698
|
+
self,
|
|
699
|
+
agentId: AgentId,
|
|
700
|
+
clientAddresses: Optional[List[Address]] = None,
|
|
701
|
+
tag1: Optional[str] = None,
|
|
702
|
+
tag2: Optional[str] = None,
|
|
703
|
+
groupBy: Optional[List[str]] = None,
|
|
704
|
+
) -> Dict[str, Any]:
|
|
705
|
+
"""Get reputation summary for an agent with optional grouping."""
|
|
706
|
+
# Parse chainId from agentId
|
|
707
|
+
chain_id = None
|
|
708
|
+
if ":" in agentId:
|
|
709
|
+
try:
|
|
710
|
+
chain_id = int(agentId.split(":", 1)[0])
|
|
711
|
+
except ValueError:
|
|
712
|
+
chain_id = None
|
|
713
|
+
|
|
714
|
+
# Try subgraph first (if available and indexer supports it)
|
|
715
|
+
if self.indexer and self.subgraph_client:
|
|
716
|
+
# Get correct subgraph client for the chain
|
|
717
|
+
subgraph_client = None
|
|
718
|
+
full_agent_id = agentId
|
|
719
|
+
|
|
720
|
+
if chain_id is not None:
|
|
721
|
+
subgraph_client = self.indexer._get_subgraph_client_for_chain(chain_id)
|
|
722
|
+
else:
|
|
723
|
+
# No chainId in agentId, use SDK's default
|
|
724
|
+
# Construct full agentId format for subgraph query
|
|
725
|
+
default_chain_id = self.web3_client.chain_id
|
|
726
|
+
token_id = agentId.split(":")[-1] if ":" in agentId else agentId
|
|
727
|
+
full_agent_id = f"{default_chain_id}:{token_id}"
|
|
728
|
+
subgraph_client = self.subgraph_client
|
|
729
|
+
|
|
730
|
+
if subgraph_client:
|
|
731
|
+
# Use subgraph to calculate reputation
|
|
732
|
+
return self._get_reputation_summary_from_subgraph(
|
|
733
|
+
full_agent_id, clientAddresses, tag1, tag2, groupBy
|
|
734
|
+
)
|
|
735
|
+
|
|
736
|
+
# Fallback to blockchain (requires chain-specific web3 client)
|
|
737
|
+
# For now, only works if chain matches SDK's default
|
|
738
|
+
if chain_id is not None and chain_id != self.web3_client.chain_id:
|
|
739
|
+
raise ValueError(
|
|
740
|
+
f"Blockchain reputation summary not supported for chain {chain_id}. "
|
|
741
|
+
f"SDK is configured for chain {self.web3_client.chain_id}. "
|
|
742
|
+
f"Use subgraph-based summary instead."
|
|
743
|
+
)
|
|
744
|
+
|
|
745
|
+
# Parse agent ID for blockchain call
|
|
746
|
+
if ":" in agentId:
|
|
747
|
+
tokenId = int(agentId.split(":")[-1])
|
|
748
|
+
else:
|
|
749
|
+
tokenId = int(agentId)
|
|
750
|
+
|
|
751
|
+
try:
|
|
752
|
+
client_list = clientAddresses if clientAddresses else []
|
|
753
|
+
tag1_bytes = self._stringToBytes32(tag1) if tag1 else b"\x00" * 32
|
|
754
|
+
tag2_bytes = self._stringToBytes32(tag2) if tag2 else b"\x00" * 32
|
|
755
|
+
|
|
756
|
+
result = self.web3_client.call_contract(
|
|
757
|
+
self.reputation_registry,
|
|
758
|
+
"getSummary",
|
|
759
|
+
tokenId,
|
|
760
|
+
client_list,
|
|
761
|
+
tag1_bytes,
|
|
762
|
+
tag2_bytes
|
|
763
|
+
)
|
|
764
|
+
|
|
765
|
+
count, average_score = result
|
|
766
|
+
|
|
767
|
+
# If no grouping requested, return simple summary
|
|
768
|
+
if not groupBy:
|
|
769
|
+
return {
|
|
770
|
+
"agentId": agentId,
|
|
771
|
+
"count": count,
|
|
772
|
+
"averageScore": float(average_score) / 100.0 if average_score > 0 else 0.0,
|
|
773
|
+
"filters": {
|
|
774
|
+
"clientAddresses": clientAddresses,
|
|
775
|
+
"tag1": tag1,
|
|
776
|
+
"tag2": tag2
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
# Get detailed feedback data for grouping
|
|
781
|
+
all_feedback = self.read_all_feedback(
|
|
782
|
+
agentId=agentId,
|
|
783
|
+
clientAddresses=clientAddresses,
|
|
784
|
+
tags=[tag1, tag2] if tag1 or tag2 else None,
|
|
785
|
+
include_revoked=False
|
|
786
|
+
)
|
|
787
|
+
|
|
788
|
+
# Group feedback by requested dimensions
|
|
789
|
+
grouped_data = self._groupFeedback(all_feedback, groupBy)
|
|
790
|
+
|
|
791
|
+
return {
|
|
792
|
+
"agentId": agentId,
|
|
793
|
+
"totalCount": count,
|
|
794
|
+
"totalAverageScore": float(average_score) / 100.0 if average_score > 0 else 0.0,
|
|
795
|
+
"groupedData": grouped_data,
|
|
796
|
+
"filters": {
|
|
797
|
+
"clientAddresses": clientAddresses,
|
|
798
|
+
"tag1": tag1,
|
|
799
|
+
"tag2": tag2
|
|
800
|
+
},
|
|
801
|
+
"groupBy": groupBy
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
except Exception as e:
|
|
805
|
+
raise ValueError(f"Failed to get reputation summary: {e}")
|
|
806
|
+
|
|
807
|
+
def _get_reputation_summary_from_subgraph(
|
|
808
|
+
self,
|
|
809
|
+
agentId: AgentId,
|
|
810
|
+
clientAddresses: Optional[List[Address]] = None,
|
|
811
|
+
tag1: Optional[str] = None,
|
|
812
|
+
tag2: Optional[str] = None,
|
|
813
|
+
groupBy: Optional[List[str]] = None,
|
|
814
|
+
) -> Dict[str, Any]:
|
|
815
|
+
"""Get reputation summary from subgraph."""
|
|
816
|
+
# Build tags list
|
|
817
|
+
tags = []
|
|
818
|
+
if tag1:
|
|
819
|
+
tags.append(tag1)
|
|
820
|
+
if tag2:
|
|
821
|
+
tags.append(tag2)
|
|
822
|
+
|
|
823
|
+
# Get all feedback for the agent using indexer (which handles multi-chain)
|
|
824
|
+
# Use searchFeedback with a large limit to get all feedback
|
|
825
|
+
all_feedback = self.searchFeedback(
|
|
826
|
+
agentId=agentId,
|
|
827
|
+
clientAddresses=clientAddresses,
|
|
828
|
+
tags=tags if tags else None,
|
|
829
|
+
include_revoked=False,
|
|
830
|
+
first=1000, # Large limit to get all feedback
|
|
831
|
+
skip=0
|
|
832
|
+
)
|
|
833
|
+
|
|
834
|
+
# Calculate summary statistics
|
|
835
|
+
count = len(all_feedback)
|
|
836
|
+
scores = [fb.score for fb in all_feedback if fb.score is not None]
|
|
837
|
+
average_score = sum(scores) / len(scores) if scores else 0.0
|
|
838
|
+
|
|
839
|
+
# If no grouping requested, return simple summary
|
|
840
|
+
if not groupBy:
|
|
841
|
+
return {
|
|
842
|
+
"agentId": agentId,
|
|
843
|
+
"count": count,
|
|
844
|
+
"averageScore": average_score,
|
|
845
|
+
"filters": {
|
|
846
|
+
"clientAddresses": clientAddresses,
|
|
847
|
+
"tag1": tag1,
|
|
848
|
+
"tag2": tag2
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
# Group feedback by requested dimensions
|
|
853
|
+
grouped_data = self._groupFeedback(all_feedback, groupBy)
|
|
854
|
+
|
|
855
|
+
return {
|
|
856
|
+
"agentId": agentId,
|
|
857
|
+
"totalCount": count,
|
|
858
|
+
"totalAverageScore": average_score,
|
|
859
|
+
"groupedData": grouped_data,
|
|
860
|
+
"filters": {
|
|
861
|
+
"clientAddresses": clientAddresses,
|
|
862
|
+
"tag1": tag1,
|
|
863
|
+
"tag2": tag2
|
|
864
|
+
},
|
|
865
|
+
"groupBy": groupBy
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
def _groupFeedback(self, feedbackList: List[Feedback], groupBy: List[str]) -> Dict[str, Any]:
|
|
869
|
+
"""Group feedback by specified dimensions."""
|
|
870
|
+
grouped = {}
|
|
871
|
+
|
|
872
|
+
for feedback in feedbackList:
|
|
873
|
+
# Create group key based on requested dimensions
|
|
874
|
+
group_key = self._createGroupKey(feedback, groupBy)
|
|
875
|
+
|
|
876
|
+
if group_key not in grouped:
|
|
877
|
+
grouped[group_key] = {
|
|
878
|
+
"count": 0,
|
|
879
|
+
"totalScore": 0.0,
|
|
880
|
+
"averageScore": 0.0,
|
|
881
|
+
"scores": [],
|
|
882
|
+
"feedback": []
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
# Add feedback to group
|
|
886
|
+
grouped[group_key]["count"] += 1
|
|
887
|
+
if feedback.score is not None:
|
|
888
|
+
grouped[group_key]["totalScore"] += feedback.score
|
|
889
|
+
grouped[group_key]["scores"].append(feedback.score)
|
|
890
|
+
grouped[group_key]["feedback"].append(feedback)
|
|
891
|
+
|
|
892
|
+
# Calculate averages for each group
|
|
893
|
+
for group_data in grouped.values():
|
|
894
|
+
if group_data["count"] > 0:
|
|
895
|
+
group_data["averageScore"] = group_data["totalScore"] / group_data["count"]
|
|
896
|
+
|
|
897
|
+
return grouped
|
|
898
|
+
|
|
899
|
+
def _createGroupKey(self, feedback: Feedback, groupBy: List[str]) -> str:
|
|
900
|
+
"""Create a group key for feedback based on grouping dimensions."""
|
|
901
|
+
key_parts = []
|
|
902
|
+
|
|
903
|
+
for dimension in groupBy:
|
|
904
|
+
if dimension == "tag":
|
|
905
|
+
# Group by tags
|
|
906
|
+
if feedback.tags:
|
|
907
|
+
key_parts.append(f"tags:{','.join(feedback.tags)}")
|
|
908
|
+
else:
|
|
909
|
+
key_parts.append("tags:none")
|
|
910
|
+
elif dimension == "capability":
|
|
911
|
+
# Group by MCP capability
|
|
912
|
+
if feedback.capability:
|
|
913
|
+
key_parts.append(f"capability:{feedback.capability}")
|
|
914
|
+
else:
|
|
915
|
+
key_parts.append("capability:none")
|
|
916
|
+
elif dimension == "skill":
|
|
917
|
+
# Group by A2A skill
|
|
918
|
+
if feedback.skill:
|
|
919
|
+
key_parts.append(f"skill:{feedback.skill}")
|
|
920
|
+
else:
|
|
921
|
+
key_parts.append("skill:none")
|
|
922
|
+
elif dimension == "task":
|
|
923
|
+
# Group by A2A task
|
|
924
|
+
if feedback.task:
|
|
925
|
+
key_parts.append(f"task:{feedback.task}")
|
|
926
|
+
else:
|
|
927
|
+
key_parts.append("task:none")
|
|
928
|
+
elif dimension == "endpoint":
|
|
929
|
+
# Group by endpoint (from context or capability)
|
|
930
|
+
endpoint = None
|
|
931
|
+
if feedback.context and "endpoint" in feedback.context:
|
|
932
|
+
endpoint = feedback.context["endpoint"]
|
|
933
|
+
elif feedback.capability:
|
|
934
|
+
endpoint = f"mcp:{feedback.capability}"
|
|
935
|
+
|
|
936
|
+
if endpoint:
|
|
937
|
+
key_parts.append(f"endpoint:{endpoint}")
|
|
938
|
+
else:
|
|
939
|
+
key_parts.append("endpoint:none")
|
|
940
|
+
elif dimension == "time":
|
|
941
|
+
# Group by time periods (daily, weekly, monthly)
|
|
942
|
+
from datetime import datetime
|
|
943
|
+
createdAt = datetime.fromtimestamp(feedback.createdAt)
|
|
944
|
+
key_parts.append(f"time:{createdAt.strftime('%Y-%m')}") # Monthly grouping
|
|
945
|
+
else:
|
|
946
|
+
# Unknown dimension, use as-is
|
|
947
|
+
key_parts.append(f"{dimension}:unknown")
|
|
948
|
+
|
|
949
|
+
return "|".join(key_parts)
|
|
950
|
+
|
|
951
|
+
def _stringToBytes32(self, text: str) -> bytes:
|
|
952
|
+
"""Convert string to bytes32 for blockchain storage."""
|
|
953
|
+
if not text:
|
|
954
|
+
return b"\x00" * 32
|
|
955
|
+
|
|
956
|
+
# Encode as UTF-8 and pad/truncate to 32 bytes
|
|
957
|
+
encoded = text.encode('utf-8')
|
|
958
|
+
if len(encoded) > 32:
|
|
959
|
+
encoded = encoded[:32]
|
|
960
|
+
else:
|
|
961
|
+
encoded = encoded.ljust(32, b'\x00')
|
|
962
|
+
|
|
963
|
+
return encoded
|
|
964
|
+
|
|
965
|
+
def _bytes32ToTags(self, tag1: bytes, tag2: bytes) -> List[str]:
|
|
966
|
+
"""Convert bytes32 tags back to strings."""
|
|
967
|
+
tags = []
|
|
968
|
+
|
|
969
|
+
if tag1 and tag1 != b"\x00" * 32:
|
|
970
|
+
tag1_str = tag1.rstrip(b'\x00').decode('utf-8', errors='ignore')
|
|
971
|
+
if tag1_str:
|
|
972
|
+
tags.append(tag1_str)
|
|
973
|
+
|
|
974
|
+
if tag2 and tag2 != b"\x00" * 32:
|
|
975
|
+
tag2_str = tag2.rstrip(b'\x00').decode('utf-8', errors='ignore')
|
|
976
|
+
if tag2_str:
|
|
977
|
+
tags.append(tag2_str)
|
|
978
|
+
|
|
979
|
+
return tags
|
|
980
|
+
|
|
981
|
+
def _hexBytes32ToTags(self, tag1: str, tag2: str) -> List[str]:
|
|
982
|
+
"""Convert hex bytes32 tags back to strings, or return plain strings as-is.
|
|
983
|
+
|
|
984
|
+
The subgraph now stores tags as human-readable strings (not hex),
|
|
985
|
+
so this method handles both formats for backwards compatibility.
|
|
986
|
+
"""
|
|
987
|
+
tags = []
|
|
988
|
+
|
|
989
|
+
if tag1 and tag1 != "0x" + "00" * 32:
|
|
990
|
+
# If it's already a plain string (from subgraph), use it directly
|
|
991
|
+
if not tag1.startswith("0x"):
|
|
992
|
+
if tag1:
|
|
993
|
+
tags.append(tag1)
|
|
994
|
+
else:
|
|
995
|
+
# Try to convert from hex bytes32 (on-chain format)
|
|
996
|
+
try:
|
|
997
|
+
# Remove 0x prefix if present
|
|
998
|
+
hex_bytes = bytes.fromhex(tag1[2:])
|
|
999
|
+
tag1_str = hex_bytes.rstrip(b'\x00').decode('utf-8', errors='ignore')
|
|
1000
|
+
if tag1_str:
|
|
1001
|
+
tags.append(tag1_str)
|
|
1002
|
+
except Exception as e:
|
|
1003
|
+
pass # Ignore invalid hex strings
|
|
1004
|
+
|
|
1005
|
+
if tag2 and tag2 != "0x" + "00" * 32:
|
|
1006
|
+
# If it's already a plain string (from subgraph), use it directly
|
|
1007
|
+
if not tag2.startswith("0x"):
|
|
1008
|
+
if tag2:
|
|
1009
|
+
tags.append(tag2)
|
|
1010
|
+
else:
|
|
1011
|
+
# Try to convert from hex bytes32 (on-chain format)
|
|
1012
|
+
try:
|
|
1013
|
+
if tag2.startswith("0x"):
|
|
1014
|
+
hex_bytes = bytes.fromhex(tag2[2:])
|
|
1015
|
+
else:
|
|
1016
|
+
hex_bytes = bytes.fromhex(tag2)
|
|
1017
|
+
tag2_str = hex_bytes.rstrip(b'\x00').decode('utf-8', errors='ignore')
|
|
1018
|
+
if tag2_str:
|
|
1019
|
+
tags.append(tag2_str)
|
|
1020
|
+
except Exception as e:
|
|
1021
|
+
pass # Ignore invalid hex strings
|
|
1022
|
+
|
|
1023
|
+
return tags
|