|
39 | 39 | logger = logging.getLogger(__name__) |
40 | 40 |
|
41 | 41 |
|
| 42 | +# ============================================================================ |
| 43 | +# CALCULATIONS (pure — no side effects, no I/O) |
| 44 | +# ============================================================================ |
| 45 | + |
| 46 | +def _build_suggest_prompt( |
| 47 | + name: str, description: str, system_prompt: str, |
| 48 | + all_tools: list[dict[str, Any]], tool_names_list: list[str], |
| 49 | +) -> str: |
| 50 | + """Build the LLM prompt for schema + tool suggestion. Pure calculation.""" |
| 51 | + context_parts = [] |
| 52 | + if name: |
| 53 | + context_parts.append(f"Agent name: {name}") |
| 54 | + if description: |
| 55 | + context_parts.append(f"Description: {description}") |
| 56 | + if system_prompt: |
| 57 | + context_parts.append(f"System prompt: {system_prompt}") |
| 58 | + agent_context = "\n".join(context_parts) |
| 59 | + |
| 60 | + tool_descriptions = "\n".join( |
| 61 | + f" - {t['name']}: {t['description'][:150]}" for t in all_tools |
| 62 | + ) |
| 63 | + |
| 64 | + return ( |
| 65 | + "You are a JSON Schema designer AND tool selector for an AI agent.\n\n" |
| 66 | + "## Agent Definition\n" |
| 67 | + f"{agent_context}\n\n" |
| 68 | + "## Data Domain\n" |
| 69 | + "The agent works with IT support/helpdesk ticket data (BMC Remedy/ITSM export). " |
| 70 | + "Each ticket has fields: incident_id, summary, status, priority, assignee, " |
| 71 | + "assigned_group, requester_name, city, created_at, updated_at, notes, resolution, description.\n\n" |
| 72 | + "## Available Tools\n" |
| 73 | + f"{tool_descriptions}\n\n" |
| 74 | + "## UI Widget System\n" |
| 75 | + "Each property MUST have an 'x-ui' annotation with a 'widget' field:\n" |
| 76 | + " 'markdown' — GFM text. 'table' — array of objects ({\"columns\": [...]}).\n" |
| 77 | + " 'badge-list' — array of strings. 'stat-card' — single number ({\"label\": \"...\"}).\n" |
| 78 | + " 'bar-chart' — array of objects ({\"indexBy\": \"...\", \"keys\": [\"...\"]}).\n" |
| 79 | + " 'pie-chart' — object or [{\"id\":...,\"value\":...}]. 'json' — raw. 'hidden' — skip.\n\n" |
| 80 | + "## Your Task\n" |
| 81 | + "Return a JSON object with exactly two keys:\n" |
| 82 | + "1. \"schema\" — JSON Schema with 'type', 'properties', x-ui annotations.\n" |
| 83 | + "2. \"tool_names\" — array of tool names the agent needs.\n\n" |
| 84 | + "## Rules\n" |
| 85 | + "1. Always include 'message' (string, widget 'markdown').\n" |
| 86 | + "2. Always include 'referenced_tickets' (array of strings, widget 'badge-list').\n" |
| 87 | + "3. Match widget to data shape: numbers→stat-card, ticket lists→table, distributions→pie/bar-chart.\n" |
| 88 | + "4. For tool_names, select ONLY tools the agent actually needs.\n" |
| 89 | + f"5. Valid tool names: {tool_names_list}\n\n" |
| 90 | + "Respond with ONLY valid JSON (no markdown fences)." |
| 91 | + ) |
| 92 | + |
| 93 | + |
| 94 | +def _parse_suggest_response(raw: str, valid_tool_names: list[str]) -> dict[str, Any]: |
| 95 | + """Parse LLM response into {schema, tool_names}. Pure calculation.""" |
| 96 | + import json as json_mod |
| 97 | + import re |
| 98 | + |
| 99 | + json_match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", raw, re.DOTALL) |
| 100 | + json_str = json_match.group(1) if json_match else raw |
| 101 | + |
| 102 | + fallback = { |
| 103 | + "schema": {"type": "object", "properties": {"result": {"type": "string", "description": "Agent output"}}}, |
| 104 | + "tool_names": valid_tool_names, |
| 105 | + } |
| 106 | + |
| 107 | + try: |
| 108 | + result = json_mod.loads(json_str) |
| 109 | + if not isinstance(result, dict): |
| 110 | + return fallback |
| 111 | + |
| 112 | + if "schema" in result and "tool_names" in result: |
| 113 | + schema, suggested_tools = result["schema"], result["tool_names"] |
| 114 | + elif "properties" in result: |
| 115 | + schema, suggested_tools = result, valid_tool_names |
| 116 | + else: |
| 117 | + return fallback |
| 118 | + |
| 119 | + if not isinstance(schema, dict) or "properties" not in schema: |
| 120 | + return fallback |
| 121 | + |
| 122 | + return {"schema": schema, "tool_names": [t for t in suggested_tools if t in valid_tool_names]} |
| 123 | + except (json_mod.JSONDecodeError, ValueError): |
| 124 | + return fallback |
| 125 | + |
| 126 | + |
| 127 | +# ============================================================================ |
| 128 | +# SERVICE (actions — I/O, database, LLM calls) |
| 129 | +# ============================================================================ |
| 130 | + |
42 | 131 | class WorkbenchService: |
43 | 132 | """ |
44 | 133 | Manages the full lifecycle of agent definitions, runs, and evaluations. |
@@ -119,109 +208,21 @@ async def suggest_schema( |
119 | 208 | system_prompt: str, |
120 | 209 | ) -> dict[str, Any]: |
121 | 210 | """ |
122 | | - Ask the LLM to propose a JSON Schema for the agent's output. |
123 | | -
|
124 | | - Provides full context about available data, tools, and UI widgets |
125 | | - so the LLM can make informed schema suggestions. |
| 211 | + Ask the LLM to propose a JSON Schema AND recommended tools. |
| 212 | + |
| 213 | + Action: calls LLM. Delegates prompt building and response |
| 214 | + parsing to pure functions (_build_suggest_prompt, _parse_suggest_response). |
126 | 215 | """ |
127 | | - context_parts = [] |
128 | | - if name: |
129 | | - context_parts.append(f"Agent name: {name}") |
130 | | - if description: |
131 | | - context_parts.append(f"Description: {description}") |
132 | | - if system_prompt: |
133 | | - context_parts.append(f"System prompt: {system_prompt}") |
134 | | - agent_context = "\n".join(context_parts) |
135 | | - |
136 | | - # Gather available tool descriptions for context |
137 | | - tool_descriptions = "\n".join( |
138 | | - f" - {t['name']}: {t['description'][:150]}" |
139 | | - for t in self.list_tools() |
140 | | - ) |
| 216 | + all_tools = self.list_tools() |
| 217 | + tool_names_list = [t["name"] for t in all_tools] |
141 | 218 |
|
142 | | - suggest_prompt = ( |
143 | | - "You are a JSON Schema designer for an AI agent output format.\n\n" |
144 | | - "## Agent Definition\n" |
145 | | - f"{agent_context}\n\n" |
146 | | - "## Data Domain\n" |
147 | | - "The agent works with IT support/helpdesk ticket data (BMC Remedy/ITSM export). " |
148 | | - "Each ticket has these fields:\n" |
149 | | - " - incident_id (string, e.g. 'INC000016349327')\n" |
150 | | - " - summary (string, short description of the issue)\n" |
151 | | - " - status (enum: new, assigned, in_progress, pending, resolved, closed, cancelled)\n" |
152 | | - " - priority (enum: critical, high, medium, low)\n" |
153 | | - " - assignee (string or null, person assigned)\n" |
154 | | - " - assigned_group (string, e.g. 'WOS - Workplace & Software')\n" |
155 | | - " - requester_name (string, who reported it)\n" |
156 | | - " - city (string, e.g. 'Bern', 'Zollikofen', 'Ittigen')\n" |
157 | | - " - created_at / updated_at (datetime)\n" |
158 | | - " - notes, resolution, description (longer text fields)\n" |
159 | | - " - operational_category_1/2/3 (categorization tiers)\n\n" |
160 | | - "## Available Tools\n" |
161 | | - f"{tool_descriptions}\n\n" |
162 | | - "## UI Widget System\n" |
163 | | - "Each property MUST have an 'x-ui' annotation with a 'widget' field. " |
164 | | - "The frontend renders each property using the specified widget:\n\n" |
165 | | - " 'markdown' — Renders text as GitHub-flavored Markdown (headings, tables, bold, lists).\n" |
166 | | - " Use for: analysis text, recommendations, summaries, explanations.\n\n" |
167 | | - " 'table' — Renders array of objects as an HTML table.\n" |
168 | | - " Use for: ticket lists, comparison data, multi-row results.\n" |
169 | | - " Options: {\"columns\": [\"col1\", \"col2\"]} to control visible columns and order.\n" |
170 | | - " The array items must be objects with consistent keys.\n\n" |
171 | | - " 'badge-list' — Renders array of strings as monospace badge chips.\n" |
172 | | - " Use for: ticket IDs, tags, categories, short labels.\n\n" |
173 | | - " 'stat-card' — Renders a single number as a large prominent card.\n" |
174 | | - " Use for: totals, counts, percentages, KPIs.\n" |
175 | | - " Options: {\"label\": \"Total Tickets\"} for the display label.\n\n" |
176 | | - " 'bar-chart' — Renders array of objects as a Nivo bar chart.\n" |
177 | | - " Use for: counts by category (status, priority, city, group).\n" |
178 | | - " Options: {\"indexBy\": \"category_key\", \"keys\": [\"value_key\"]}.\n" |
179 | | - " Data must be array of objects like [{\"status\": \"open\", \"count\": 42}, ...].\n\n" |
180 | | - " 'pie-chart' — Renders object or array as a Nivo pie chart.\n" |
181 | | - " Use for: proportional breakdowns (status distribution, priority split).\n" |
182 | | - " Can accept {\"open\": 42, \"closed\": 18} or [{\"id\": \"open\", \"value\": 42}].\n\n" |
183 | | - " 'json' — Renders as formatted JSON in a code block.\n" |
184 | | - " Use for: raw data, debug output, complex nested structures.\n\n" |
185 | | - " 'hidden' — Not rendered in the UI.\n" |
186 | | - " Use for: internal metadata, IDs used for linking but not display.\n\n" |
187 | | - "## Rules\n" |
188 | | - "1. Always include a 'message' property (type string, widget 'markdown') for the main response.\n" |
189 | | - "2. Always include 'referenced_tickets' (type array of strings, widget 'badge-list') listing ticket IDs looked at.\n" |
190 | | - "3. Add additional properties based on what the agent would logically produce.\n" |
191 | | - "4. Use descriptive property names (snake_case) and include 'description' for each.\n" |
192 | | - "5. Match widget to data shape: numbers→stat-card, lists of tickets→table, distributions→pie/bar-chart.\n" |
193 | | - "6. For table widgets, define 'columns' matching the object keys in the array items.\n\n" |
194 | | - "Respond with ONLY a valid JSON object (no markdown fences, no explanation)." |
195 | | - ) |
| 219 | + prompt = _build_suggest_prompt(name, description, system_prompt, all_tools, tool_names_list) |
196 | 220 |
|
197 | 221 | from langchain_core.messages import HumanMessage |
198 | | - response = await self.llm.ainvoke([HumanMessage(content=suggest_prompt)]) |
| 222 | + response = await self.llm.ainvoke([HumanMessage(content=prompt)]) |
199 | 223 | raw = (response.content or "").strip() |
200 | 224 |
|
201 | | - # Extract JSON from response (strip markdown fences if present) |
202 | | - import re |
203 | | - json_match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", raw, re.DOTALL) |
204 | | - json_str = json_match.group(1) if json_match else raw |
205 | | - |
206 | | - import json as json_mod |
207 | | - try: |
208 | | - schema = json_mod.loads(json_str) |
209 | | - if not isinstance(schema, dict) or "properties" not in schema: |
210 | | - raise ValueError("Schema must have 'properties'") |
211 | | - return schema |
212 | | - except (json_mod.JSONDecodeError, ValueError): |
213 | | - return { |
214 | | - "type": "object", |
215 | | - "properties": { |
216 | | - "result": {"type": "string", "description": "Agent output"}, |
217 | | - }, |
218 | | - } |
219 | | - result.append({ |
220 | | - "name": t.name, |
221 | | - "description": (t.description or "")[:200], |
222 | | - "input_schema": input_schema, |
223 | | - }) |
224 | | - return result |
| 225 | + return _parse_suggest_response(raw, tool_names_list) |
225 | 226 |
|
226 | 227 | # ------------------------------------------------------------------ |
227 | 228 | # Validation helpers (calculations) |
|
0 commit comments