a2a-source-edit: write agent.py
This commit is contained in:
117
agent.py
117
agent.py
@@ -134,19 +134,8 @@ class QuoteJudgeStudioV1(A2AAgent[QuoteJudgeStudioV1Config, PlatformUserAuth]):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@a2a.tool(
|
@a2a.tool(description="Compare two or more vendor quotes, persist the result for this platform user, and return the recommendation.", timeout_seconds=30, idempotent=True, cost_class="deterministic")
|
||||||
description="Compare two or more vendor quotes, persist the result for this platform user, and return the recommendation.",
|
async def compare_quotes(self, ctx: RunContext[PlatformUserAuth], comparison_id: str, quotes: list[QuoteInput], weights: QuoteWeights) -> dict[str, Any]:
|
||||||
timeout_seconds=30,
|
|
||||||
idempotent=True,
|
|
||||||
cost_class="deterministic",
|
|
||||||
)
|
|
||||||
async def compare_quotes(
|
|
||||||
self,
|
|
||||||
ctx: RunContext[PlatformUserAuth],
|
|
||||||
comparison_id: str,
|
|
||||||
quotes: list[QuoteInput],
|
|
||||||
weights: QuoteWeights,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
validation = _validate_request(comparison_id, quotes, weights)
|
validation = _validate_request(comparison_id, quotes, weights)
|
||||||
if validation is not None:
|
if validation is not None:
|
||||||
await _persist_receipt_best_effort(ctx, "compare_quotes", comparison_id, "error", validation)
|
await _persist_receipt_best_effort(ctx, "compare_quotes", comparison_id, "error", validation)
|
||||||
@@ -158,22 +147,13 @@ class QuoteJudgeStudioV1(A2AAgent[QuoteJudgeStudioV1Config, PlatformUserAuth]):
|
|||||||
await _persist_receipt_best_effort(ctx, "compare_quotes", comparison_id, "ok", result)
|
await _persist_receipt_best_effort(ctx, "compare_quotes", comparison_id, "ok", result)
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
return _setup_required(comparison_id, str(exc))
|
return _setup_required(comparison_id, str(exc))
|
||||||
except Exception: # noqa: BLE001
|
except Exception:
|
||||||
return _temporary_db_error(comparison_id)
|
return _temporary_db_error(comparison_id)
|
||||||
await ctx.emit_progress(f"saved comparison {comparison_id}")
|
await ctx.emit_progress(f"saved comparison {comparison_id}")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@a2a.tool(
|
@a2a.tool(description="Reload a saved quote comparison for the current signed-in platform user.", timeout_seconds=20, idempotent=True, cost_class="deterministic")
|
||||||
description="Reload a saved quote comparison for the current signed-in platform user.",
|
async def get_comparison(self, ctx: RunContext[PlatformUserAuth], comparison_id: str) -> dict[str, Any]:
|
||||||
timeout_seconds=20,
|
|
||||||
idempotent=True,
|
|
||||||
cost_class="deterministic",
|
|
||||||
)
|
|
||||||
async def get_comparison(
|
|
||||||
self,
|
|
||||||
ctx: RunContext[PlatformUserAuth],
|
|
||||||
comparison_id: str,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
tenant = _tenant_key(ctx)
|
tenant = _tenant_key(ctx)
|
||||||
clean_id = _clean_comparison_id(comparison_id)
|
clean_id = _clean_comparison_id(comparison_id)
|
||||||
if clean_id is None:
|
if clean_id is None:
|
||||||
@@ -183,76 +163,42 @@ class QuoteJudgeStudioV1(A2AAgent[QuoteJudgeStudioV1Config, PlatformUserAuth]):
|
|||||||
await _persist_receipt_best_effort(ctx, "get_comparison", clean_id, "ok", record or {"ok": False})
|
await _persist_receipt_best_effort(ctx, "get_comparison", clean_id, "ok", record or {"ok": False})
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
return _setup_required(clean_id, str(exc))
|
return _setup_required(clean_id, str(exc))
|
||||||
except Exception: # noqa: BLE001
|
except Exception:
|
||||||
return _temporary_db_error(clean_id)
|
return _temporary_db_error(clean_id)
|
||||||
if record is None:
|
if record is None:
|
||||||
return {"ok": False, "code": "comparison_not_found", "comparison_id": clean_id, "message": "No saved comparison exists for this user and id."}
|
return {"ok": False, "code": "comparison_not_found", "comparison_id": clean_id, "message": "No saved comparison exists for this user and id."}
|
||||||
return record
|
return record
|
||||||
|
|
||||||
@a2a.tool(
|
@a2a.tool(description="Compare quotes from an external client FileUpload (CSV, JSON, or plain text) and persist the result.", timeout_seconds=30, idempotent=True, cost_class="deterministic")
|
||||||
description="Compare quotes from an external client FileUpload (CSV, JSON, or plain text) and persist the result.",
|
|
||||||
timeout_seconds=30,
|
|
||||||
idempotent=True,
|
|
||||||
cost_class="deterministic",
|
|
||||||
)
|
|
||||||
async def compare_quotes_file(
|
async def compare_quotes_file(
|
||||||
self,
|
self,
|
||||||
ctx: RunContext[PlatformUserAuth],
|
ctx: RunContext[PlatformUserAuth],
|
||||||
comparison_id: str,
|
comparison_id: str,
|
||||||
weights: QuoteWeights,
|
weights: QuoteWeights,
|
||||||
document: Annotated[
|
document: Annotated[UploadedFile, FileUpload(accept=ALLOWED_UPLOAD_MEDIA_TYPES, max_bytes=MAX_UPLOAD_BYTES, description="CSV/JSON/text quote file with vendor, unit_price, quantity, delivery_days, warranty_months.")],
|
||||||
UploadedFile,
|
|
||||||
FileUpload(
|
|
||||||
accept=ALLOWED_UPLOAD_MEDIA_TYPES,
|
|
||||||
max_bytes=MAX_UPLOAD_BYTES,
|
|
||||||
description="CSV/JSON/text quote file with vendor, unit_price, quantity, delivery_days, warranty_months.",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
payload = ctx.workspace.read_bytes(document.path) # type: ignore[attr-defined]
|
payload = ctx.workspace.read_bytes(document.path) # type: ignore[attr-defined]
|
||||||
except Exception: # noqa: BLE001
|
except Exception:
|
||||||
return {"ok": False, "code": "upload_read_failed", "comparison_id": comparison_id, "message": "Could not read the uploaded file from the invocation workspace."}
|
return {"ok": False, "code": "upload_read_failed", "comparison_id": comparison_id, "message": "Could not read the uploaded file from the invocation workspace."}
|
||||||
parsed = _parse_uploaded_quotes(document.filename, document.media_type, payload)
|
parsed = _parse_uploaded_quotes(document.filename, document.media_type, payload)
|
||||||
if not parsed["ok"]:
|
if not parsed["ok"]:
|
||||||
return {**parsed, "comparison_id": comparison_id}
|
return {**parsed, "comparison_id": comparison_id}
|
||||||
return await self.compare_quotes(ctx, comparison_id, parsed["quotes"], weights)
|
return await self.compare_quotes(ctx, comparison_id, parsed["quotes"], weights)
|
||||||
|
|
||||||
@a2a.tool(
|
@a2a.tool(description="Browser JSON bridge: accept one bounded base64 quote file, parse it, compare quotes, and persist the result.", timeout_seconds=30, idempotent=True, cost_class="deterministic")
|
||||||
description="Browser JSON bridge: accept one bounded base64 quote file, parse it, compare quotes, and persist the result.",
|
async def compare_quotes_upload(self, ctx: RunContext[PlatformUserAuth], comparison_id: str, upload: BrowserQuoteDocument, weights: QuoteWeights) -> dict[str, Any]:
|
||||||
timeout_seconds=30,
|
|
||||||
idempotent=True,
|
|
||||||
cost_class="deterministic",
|
|
||||||
)
|
|
||||||
async def compare_quotes_upload(
|
|
||||||
self,
|
|
||||||
ctx: RunContext[PlatformUserAuth],
|
|
||||||
comparison_id: str,
|
|
||||||
upload: BrowserQuoteDocument,
|
|
||||||
weights: QuoteWeights,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
return await self.compare_quotes_browser_upload(ctx, comparison_id, weights, [upload])
|
return await self.compare_quotes_browser_upload(ctx, comparison_id, weights, [upload])
|
||||||
|
|
||||||
@a2a.tool(
|
@a2a.tool(description="Browser JSON bridge: accept bounded base64 quote files, parse them, compare quotes, and persist the result.", timeout_seconds=30, idempotent=True, cost_class="deterministic")
|
||||||
description="Browser JSON bridge: accept bounded base64 quote files, parse them, compare quotes, and persist the result.",
|
async def compare_quotes_browser_upload(self, ctx: RunContext[PlatformUserAuth], comparison_id: str, weights: QuoteWeights, documents: list[BrowserQuoteDocument]) -> dict[str, Any]:
|
||||||
timeout_seconds=30,
|
|
||||||
idempotent=True,
|
|
||||||
cost_class="deterministic",
|
|
||||||
)
|
|
||||||
async def compare_quotes_browser_upload(
|
|
||||||
self,
|
|
||||||
ctx: RunContext[PlatformUserAuth],
|
|
||||||
comparison_id: str,
|
|
||||||
weights: QuoteWeights,
|
|
||||||
documents: list[BrowserQuoteDocument],
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
if not documents or len(documents) > MAX_BROWSER_UPLOADS:
|
if not documents or len(documents) > MAX_BROWSER_UPLOADS:
|
||||||
return {"ok": False, "code": "invalid_upload_count", "comparison_id": comparison_id, "message": f"Upload 1 to {MAX_BROWSER_UPLOADS} files."}
|
return {"ok": False, "code": "invalid_upload_count", "comparison_id": comparison_id, "message": f"Upload 1 to {MAX_BROWSER_UPLOADS} files."}
|
||||||
all_quotes: list[QuoteInput] = []
|
all_quotes: list[QuoteInput] = []
|
||||||
for document in documents:
|
for document in documents:
|
||||||
try:
|
try:
|
||||||
data = base64.b64decode(document.data_base64, validate=True)
|
data = base64.b64decode(document.data_base64, validate=True)
|
||||||
except Exception: # noqa: BLE001
|
except Exception:
|
||||||
return {"ok": False, "code": "invalid_base64_upload", "comparison_id": comparison_id, "message": f"{document.filename} is not valid base64."}
|
return {"ok": False, "code": "invalid_base64_upload", "comparison_id": comparison_id, "message": f"{document.filename} is not valid base64."}
|
||||||
if len(data) > MAX_UPLOAD_BYTES:
|
if len(data) > MAX_UPLOAD_BYTES:
|
||||||
return {"ok": False, "code": "upload_too_large", "comparison_id": comparison_id, "message": f"{document.filename} exceeds {MAX_UPLOAD_BYTES} bytes."}
|
return {"ok": False, "code": "upload_too_large", "comparison_id": comparison_id, "message": f"{document.filename} exceeds {MAX_UPLOAD_BYTES} bytes."}
|
||||||
@@ -308,7 +254,7 @@ def _score_quotes(comparison_id: str, quotes: list[QuoteInput], weights: QuoteWe
|
|||||||
"weighted_score": rounded_score,
|
"weighted_score": rounded_score,
|
||||||
"score_breakdown": {"price": _round(price_score * 100), "delivery": _round(delivery_score * 100), "warranty": _round(warranty_score * 100)},
|
"score_breakdown": {"price": _round(price_score * 100), "delivery": _round(delivery_score * 100), "warranty": _round(warranty_score * 100)},
|
||||||
})
|
})
|
||||||
ranked = sorted(scored, key=lambda row: (-row["score"], row["total_price"], row["delivery_days"], -row["warranty_months"], row["vendor"].lower()))
|
ranked = sorted(scored, key=_quote_sort_key)
|
||||||
winner = ranked[0]
|
winner = ranked[0]
|
||||||
result = {
|
result = {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
@@ -334,6 +280,10 @@ def _score_quotes(comparison_id: str, quotes: list[QuoteInput], weights: QuoteWe
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _quote_sort_key(row: dict[str, Any]) -> tuple[Any, ...]:
|
||||||
|
return (-row["score"], row["total_price"], row["delivery_days"], -row["warranty_months"], row["vendor"].lower())
|
||||||
|
|
||||||
|
|
||||||
def _money(value: Any) -> float:
|
def _money(value: Any) -> float:
|
||||||
return float(Decimal(str(value)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
|
return float(Decimal(str(value)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
|
||||||
|
|
||||||
@@ -364,7 +314,7 @@ def _parse_uploaded_quotes(filename: str, media_type: str, data: bytes) -> dict[
|
|||||||
quotes = [QuoteInput.model_validate(row) for row in rows]
|
quotes = [QuoteInput.model_validate(row) for row in rows]
|
||||||
else:
|
else:
|
||||||
quotes = _parse_csv_or_text(text)
|
quotes = _parse_csv_or_text(text)
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc:
|
||||||
return {"ok": False, "code": "quote_parse_failed", "message": f"Could not parse quote upload: {str(exc)[:160]}"}
|
return {"ok": False, "code": "quote_parse_failed", "message": f"Could not parse quote upload: {str(exc)[:160]}"}
|
||||||
return {"ok": True, "quotes": quotes}
|
return {"ok": True, "quotes": quotes}
|
||||||
|
|
||||||
@@ -376,7 +326,7 @@ def _parse_csv_or_text(text: str) -> list[QuoteInput]:
|
|||||||
reader = csv.DictReader(io.StringIO(sample))
|
reader = csv.DictReader(io.StringIO(sample))
|
||||||
required = {"vendor", "unit_price", "quantity", "delivery_days", "warranty_months"}
|
required = {"vendor", "unit_price", "quantity", "delivery_days", "warranty_months"}
|
||||||
if reader.fieldnames and required.issubset({name.strip() for name in reader.fieldnames}):
|
if reader.fieldnames and required.issubset({name.strip() for name in reader.fieldnames}):
|
||||||
return [QuoteInput.model_validate({k.strip(): v for k, v in row.items()}) for row in reader]
|
return [QuoteInput.model_validate({key.strip(): value for key, value in csv_row.items()}) for csv_row in reader]
|
||||||
raise ValueError("CSV must include vendor, unit_price, quantity, delivery_days, warranty_months columns")
|
raise ValueError("CSV must include vendor, unit_price, quantity, delivery_days, warranty_months columns")
|
||||||
|
|
||||||
|
|
||||||
@@ -399,12 +349,7 @@ def _connect():
|
|||||||
import psycopg
|
import psycopg
|
||||||
from psycopg.rows import dict_row
|
from psycopg.rows import dict_row
|
||||||
|
|
||||||
return psycopg.connect(
|
return psycopg.connect(_database_url(), connect_timeout=DB_CONNECT_TIMEOUT_SECONDS, row_factory=dict_row, options=f"-c statement_timeout={DB_STATEMENT_TIMEOUT_MS} -c lock_timeout={DB_LOCK_TIMEOUT_MS}")
|
||||||
_database_url(),
|
|
||||||
connect_timeout=DB_CONNECT_TIMEOUT_SECONDS,
|
|
||||||
row_factory=dict_row,
|
|
||||||
options=f"-c statement_timeout={DB_STATEMENT_TIMEOUT_MS} -c lock_timeout={DB_LOCK_TIMEOUT_MS}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _save_comparison(tenant_key: str, result: dict[str, Any]) -> None:
|
async def _save_comparison(tenant_key: str, result: dict[str, Any]) -> None:
|
||||||
@@ -440,19 +385,11 @@ async def _load_comparison(tenant_key: str, comparison_id: str) -> dict[str, Any
|
|||||||
def _load_comparison_sync(tenant_key: str, comparison_id: str) -> dict[str, Any] | None:
|
def _load_comparison_sync(tenant_key: str, comparison_id: str) -> dict[str, Any] | None:
|
||||||
with _connect() as conn:
|
with _connect() as conn:
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
cur.execute(
|
cur.execute("""SELECT result_json FROM quote_judge_comparisons WHERE tenant_key = %s AND comparison_id = %s LIMIT 1""", (tenant_key, comparison_id))
|
||||||
"""
|
db_row = cur.fetchone()
|
||||||
SELECT result_json
|
if not db_row:
|
||||||
FROM quote_judge_comparisons
|
|
||||||
WHERE tenant_key = %s AND comparison_id = %s
|
|
||||||
LIMIT 1
|
|
||||||
""",
|
|
||||||
(tenant_key, comparison_id),
|
|
||||||
)
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row:
|
|
||||||
return None
|
return None
|
||||||
payload = row["result_json"]
|
payload = db_row["result_json"]
|
||||||
return json.loads(payload) if isinstance(payload, str) else dict(payload)
|
return json.loads(payload) if isinstance(payload, str) else dict(payload)
|
||||||
|
|
||||||
|
|
||||||
@@ -462,7 +399,7 @@ async def _persist_receipt_best_effort(ctx: RunContext[PlatformUserAuth], skill_
|
|||||||
tenant = _tenant_key(ctx)
|
tenant = _tenant_key(ctx)
|
||||||
receipt = _receipt_payload(skill_name, comparison_id, payload)
|
receipt = _receipt_payload(skill_name, comparison_id, payload)
|
||||||
await asyncio.to_thread(_persist_receipt_sync, tenant, comparison_id, skill_name, status, getattr(ctx, "task_id", "") or "", receipt)
|
await asyncio.to_thread(_persist_receipt_sync, tenant, comparison_id, skill_name, status, getattr(ctx, "task_id", "") or "", receipt)
|
||||||
except Exception: # noqa: BLE001
|
except Exception:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user