Files
agent-desktop/plugins/bundle/cloudpaw/tools/proposal_choice.py
T

373 lines
12 KiB
Python

# -*- coding: utf-8 -*-
"""Proposal choice tool for interactive resource selection.
Presents resource proposals to users in table format and waits for
their selection. Supports up to 5 proposals with resource details,
including multi-strategy proposals from iac-code.
Auto-corrects common LLM formatting mistakes:
- Strips header rows (e.g. ["资源类型", "资源用途", ...])
- Splits flat 2D arrays into multiple proposals using "合计" rows
"""
import asyncio
import json
import logging
import re
from typing import Any, List, Optional, Union
from agentscope.message import TextBlock
from agentscope.message import ToolResultState
from agentscope.tool import ToolChunk
# pylint: disable=no-name-in-module
from pineagents.app.interaction import InteractionManager
from pineagents.app.agent_context import get_current_session_id
logger = logging.getLogger("qwenpaw").getChild(
__name__.replace("plugin_cloudpaw.", ""),
)
_INTERACTION_TIMEOUT = 3600 # 1 hour
# Fixed table headers for resource proposals
_TABLE_HEADERS = [
"资源类型",
"资源用途",
"规格",
"地域",
"数量",
"计费方式",
"时长",
"原价",
"优惠",
"预估算费用",
]
# Fixed proposal names (extended for multi-strategy support)
_PROPOSAL_NAMES = ["方案一", "方案二", "方案三", "方案四", "方案五"]
def _validate_cell(cell: Any) -> bool:
"""Validate a single cell value.
Cell can be:
- A string
- An object with "text" (required) and optional "url" fields
"""
if isinstance(cell, str):
return True
if isinstance(cell, dict):
return "text" in cell and isinstance(cell["text"], str)
return False
def _is_row(item: Any) -> bool:
"""Check if item is a valid row (list of 10 cells)."""
return isinstance(item, list) and len(item) == 10
_HEADER_KEYWORDS = frozenset(h.lower() for h in _TABLE_HEADERS)
def _is_header_row(row: List[Any]) -> bool:
"""Detect if a row is a table header (e.g. ["资源类型", "资源用途", ...])."""
if not _is_row(row):
return False
first = str(row[0]).strip().lower() if row[0] else ""
return first in _HEADER_KEYWORDS
def _is_summary_row(row: List[Any]) -> bool:
"""Detect summary/total rows (first cell matches 合计/总计/total)."""
if not _is_row(row):
return False
first = str(row[0]).strip() if row[0] else ""
return bool(first) and bool(
re.match(r"^(合计|总计|total)", first, re.IGNORECASE),
)
def _split_flat_rows_into_proposals(
rows: List[List[Any]],
) -> List[List[List[Any]]]:
"""Split a flat 2D array into proposals.
Uses summary rows as delimiters.
When the LLM puts two proposals' rows into a single 2D array, we can
recover the intended structure by splitting on "合计" rows: each "合计"
row marks the end of a proposal.
"""
proposals: List[List[List[Any]]] = []
current: List[List[Any]] = []
for row in rows:
current.append(row)
if _is_summary_row(row):
proposals.append(current)
current = []
if current:
if proposals:
proposals[-1].extend(current)
else:
proposals.append(current)
return proposals if proposals else [rows]
def _normalize_proposals( # pylint: disable=too-many-return-statements
data: Any,
expected_count: int = 0,
) -> Union[List[List[List[Any]]], str]:
"""Normalize proposals data to 3D array format.
Accepts multiple formats and auto-corrects common LLM mistakes:
- 2D array (single proposal): [[row1], [row2], ...].
Each row has 10 columns.
- 3D array (multiple proposals): [[[row1], [row2]], [[row3]]]
- 2D array with mixed proposals: auto-splits on "合计" rows
Also auto-strips header rows (e.g. ["资源类型", "资源用途", ...]) that the
LLM sometimes inserts.
Args:
data: Parsed JSON data
expected_count: Expected number of proposals (from strategy_names).
Length used as a hint to split flat arrays when summary rows
are missing.
"""
if not isinstance(data, list) or len(data) == 0:
return "Error: data must be a non-empty JSON array."
# Check if it's a 2D array (all items are 10-column rows)
if all(_is_row(item) for item in data):
cleaned = [r for r in data if not _is_header_row(r)]
if not cleaned:
return (
"Error: data contains only header rows, "
"no actual resource data."
)
summary_count = sum(1 for r in cleaned if _is_summary_row(r))
if summary_count >= 2:
return _split_flat_rows_into_proposals(cleaned)
if expected_count >= 2 and len(cleaned) >= expected_count * 2:
import math
chunk = math.ceil(len(cleaned) / expected_count)
return [
cleaned[i : i + chunk] for i in range(0, len(cleaned), chunk)
]
return [cleaned]
# Check if it's a 3D array (multiple proposals)
if all(isinstance(item, list) for item in data):
result = []
for i, proposal in enumerate(data):
if not isinstance(proposal, list) or len(proposal) == 0:
return (
f"Error: proposal {i + 1} must be a non-empty array "
f"of rows. Each row should have 10 columns: "
f"{', '.join(_TABLE_HEADERS)}"
)
cleaned = [
r for r in proposal if _is_row(r) and not _is_header_row(r)
]
if not cleaned:
return (
f"Error: proposal {i + 1} has no valid data rows after "
f"filtering headers."
)
for j, row in enumerate(cleaned):
if not _is_row(row):
return (
f"Error: row {j + 1} in proposal {i + 1} has "
f"{len(row)} columns, expected 10. "
f"Columns: {', '.join(_TABLE_HEADERS)}"
)
result.append(cleaned)
return result
return (
"Error: data format not recognized. Expected either:\n"
"- 2D array for single proposal: [[row1], [row2], ...] "
"where each row has 10 columns\n"
"- 3D array for multiple proposals: [[[row1], [row2]], [[row3]]] "
"where each proposal contains rows"
)
def _validate_proposals(proposals: List[List[List[Any]]]) -> Union[str, None]:
"""Validate normalized proposals data structure."""
if len(proposals) < 1 or len(proposals) > 5:
return (
f"Error: must have 1 to 5 proposals (found {len(proposals)}). "
f"Each proposal can contain multiple resource rows."
)
for i, proposal in enumerate(proposals):
for j, row in enumerate(proposal):
for k, cell in enumerate(row):
if not _validate_cell(cell):
return (
f"Error: cell at proposal {i + 1}, row {j + 1}, "
f"column '{_TABLE_HEADERS[k]}' is invalid. "
f"Cell must be a string or an object with "
f"'text' field."
)
return None
async def proposal_choice(
data: str,
strategy_names: Optional[str] = None,
) -> ToolChunk:
"""Display a resource proposal to user and wait for confirmation.
Presents a resource proposal in table format with fixed headers and
waits for user to confirm deployment or request adjustments.
Args:
data (`str`):
A JSON-encoded 2D array of resource rows (single proposal) or
3D array (multiple proposals). Each row must have exactly 10
columns matching the fixed headers:
资源类型, 资源用途, 规格, 地域, 数量, 计费方式, 时长, 原价, 优惠, 预估算费用
Example (single proposal):
```json
[
["ECS", "Web服务器", "2核4G", "华东1", "1", "包年包月", "1年",
"¥1200", "8折", "¥960"],
["OSS", "存储", "标准", "华东1", "1", "按量付费", "-",
"¥100", "-", "¥100"],
["合计", "", "", "", "", "", "", "", "", "≈¥1060/月"]
]
```
The last row of each proposal should be a summary row with
"合计" as the first column and the total cost in the last
column. The frontend
renders this row separately and does NOT compute totals itself.
Cell value format:
- String: "value"
- Object with URL: {"text": "value", "url": "https://..."}
strategy_names (`str`, optional):
JSON-encoded list of strategy display names for each proposal.
If provided, these names replace the default proposal names
("方案一/方案二/方案三").
Example: '["最低价方案", "中等配置", "高性能方案"]'
Returns:
`ToolChunk`:
When user confirms deployment: "用户确认部署"
When user requests adjustment: "用户要求调整资源:{user input text}"
When timeout (1 hour): "用户确认部署"
"""
# Parse JSON data
try:
raw_data: Any = json.loads(data)
except (json.JSONDecodeError, TypeError):
return ToolChunk(
state=ToolResultState.SUCCESS,
content=[
TextBlock(
type="text",
text=(
"Error: 'data' parameter must be a valid JSON "
"string. Expected a 2D array (single proposal) "
"or 3D array (multiple proposals). Each row "
"must have 10 columns."
),
),
],
)
# Parse strategy names if provided
custom_names: list[str] | None = None
if strategy_names:
try:
parsed_names = json.loads(strategy_names)
if isinstance(parsed_names, list) and all(
isinstance(n, str) for n in parsed_names
):
custom_names = parsed_names
except (json.JSONDecodeError, TypeError):
pass
# Normalize to 3D array format
expected_count = len(custom_names) if custom_names else 0
normalized = _normalize_proposals(raw_data, expected_count=expected_count)
if isinstance(normalized, str):
return ToolChunk(
state=ToolResultState.SUCCESS,
content=[TextBlock(type="text", text=normalized)],
)
proposals: List[List[List[Any]]] = normalized
# Validate proposals structure
error = _validate_proposals(proposals)
if error:
return ToolChunk(
state=ToolResultState.SUCCESS,
content=[
TextBlock(type="text", text=error),
],
)
# Determine proposal names
proposal_names: list[str]
if custom_names and len(custom_names) >= len(proposals):
proposal_names = custom_names[: len(proposals)]
elif len(proposals) == 1:
proposal_names = ["方案"]
else:
proposal_names = _PROPOSAL_NAMES[: len(proposals)]
# Get session context
session_id = get_current_session_id()
logger.info("[proposal_choice] session_id from context: %s", session_id)
if session_id is None:
payload = json.dumps(
{
"headers": _TABLE_HEADERS,
"proposals": proposals,
"proposal_names": proposal_names,
},
ensure_ascii=False,
)
return ToolChunk(
state=ToolResultState.SUCCESS,
content=[TextBlock(type="text", text=payload)],
)
logger.info(
"[proposal_choice] Creating interaction for session: %s",
session_id,
)
interaction = InteractionManager.create(session_id)
try:
await asyncio.wait_for(
interaction.event.wait(),
timeout=_INTERACTION_TIMEOUT,
)
except asyncio.TimeoutError:
return ToolChunk(
state=ToolResultState.SUCCESS,
content=[
TextBlock(
type="text",
text="用户确认部署",
),
],
)
finally:
InteractionManager.cleanup(session_id)
result = interaction.result or "用户未做出选择"
return ToolChunk(
state=ToolResultState.SUCCESS,
content=[TextBlock(type="text", text=result)],
)