Skip to content

Commit 2d5b05a

Browse files
committed
Merge origin/main into kba-draft-ale-search-questions
2 parents df5fd5e + eb824e6 commit 2d5b05a

11 files changed

Lines changed: 221 additions & 96 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,4 @@ logs/
5252
*.db
5353
csv/*.csv
5454
screenshots/
55+
*.png

backend/agent_workbench/service.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@
99
The host project injects tools and LLM configuration at startup.
1010
"""
1111

12+
import logging
1213
import os
1314
from datetime import datetime
1415
from pathlib import Path
16+
from time import perf_counter
1517
from typing import Any, Optional
1618

1719
from sqlmodel import Session, select
@@ -33,6 +35,41 @@
3335
)
3436
from .tool_registry import ToolRegistry
3537

38+
logger = logging.getLogger(__name__)
39+
40+
41+
# ============================================================================
42+
# TOOL CALL LOGGING CALLBACK
43+
# ============================================================================
44+
45+
def _make_tool_logging_callback() -> Any:
46+
"""Create a callback handler that logs tool invocations with latency."""
47+
from langchain_core.callbacks import BaseCallbackHandler
48+
49+
class ToolCallLoggingCallback(BaseCallbackHandler):
50+
def __init__(self) -> None:
51+
super().__init__()
52+
self._start_times: dict[Any, float] = {}
53+
54+
def on_tool_start(self, serialized: dict[str, Any], input_str: str, *, run_id: Any, **kwargs: Any) -> None:
55+
self._start_times[run_id] = perf_counter()
56+
name = serialized.get("name", "?")
57+
preview = input_str[:200] if isinstance(input_str, str) else str(input_str)[:200]
58+
logger.info("🔧 Tool START name=%s run_id=%s input=%s", name, run_id, preview)
59+
60+
def on_tool_end(self, output: str, *, run_id: Any, **kwargs: Any) -> None:
61+
started = self._start_times.pop(run_id, None)
62+
ms = int((perf_counter() - started) * 1000) if started is not None else None
63+
preview = output[:300] if isinstance(output, str) else str(output)[:300]
64+
logger.info("✅ Tool END run_id=%s duration_ms=%s output=%s", run_id, ms, preview)
65+
66+
def on_tool_error(self, error: BaseException, *, run_id: Any, **kwargs: Any) -> None:
67+
started = self._start_times.pop(run_id, None)
68+
ms = int((perf_counter() - started) * 1000) if started is not None else None
69+
logger.error("❌ Tool ERROR run_id=%s duration_ms=%s error=%s", run_id, ms, error)
70+
71+
return ToolCallLoggingCallback()
72+
3673
# ============================================================================
3774
# LLM HELPER - isolated so it stays optional at import time
3875
# ============================================================================
@@ -381,11 +418,20 @@ async def run_agent(
381418
runtime_system_prompt = _append_markdown_output_instruction(agent_def.system_prompt)
382419
react = _build_react_agent(self.llm, tools, runtime_system_prompt)
383420

421+
logger.info("▶️ Agent run_id=%s agent=%s tools=%s prompt=%s",
422+
run_id, agent_id, validated_tool_names, user_message[:120])
423+
t0 = perf_counter()
424+
384425
result = await react.ainvoke(
385426
{"messages": [("user", user_message)]},
386-
config={"recursion_limit": self._recursion_limit},
427+
config={
428+
"recursion_limit": self._recursion_limit,
429+
"callbacks": [_make_tool_logging_callback()],
430+
},
387431
)
388432

433+
total_ms = int((perf_counter() - t0) * 1000)
434+
389435
final_msg = result["messages"][-1]
390436
output = final_msg.content if hasattr(final_msg, "content") else str(final_msg)
391437

@@ -398,6 +444,9 @@ async def run_agent(
398444
if name:
399445
tools_used.append(name)
400446

447+
logger.info("⏹️ Agent done run_id=%s total_ms=%s tools_used=%s messages=%d",
448+
run_id, total_ms, tools_used, len(result["messages"]))
449+
401450
# -- Persist completion --
402451
with Session(self._engine) as session:
403452
db_run = session.get(AgentRun, run_id)

backend/app.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -899,17 +899,21 @@ def get_sort_key(ticket: Ticket):
899899
@app.route("/api/csv-tickets/<ticket_id>", methods=["GET"])
900900
async def get_csv_ticket(ticket_id: str):
901901
"""
902-
Get one CSV ticket by ID.
902+
Get one CSV ticket by INC number (e.g. INC000016349327) or UUID.
903903
904904
Query params:
905905
- fields: optional comma-separated list of fields to include
906906
"""
907-
try:
908-
parsed_id = UUID(ticket_id)
909-
except ValueError:
910-
return jsonify({"error": "Invalid ticket ID"}), 400
907+
# Try INC number first (primary identifier)
908+
if ticket_id.upper().startswith("INC"):
909+
ticket = _csv_ticket_service.get_ticket_by_incident_id(ticket_id)
910+
else:
911+
try:
912+
parsed_id = UUID(ticket_id)
913+
except ValueError:
914+
return jsonify({"error": "Invalid ticket ID. Use an INC number (e.g. INC000016349327) or UUID."}), 400
915+
ticket = _csv_ticket_service.get_ticket(parsed_id)
911916

912-
ticket = _csv_ticket_service.get_ticket(parsed_id)
913917
if ticket is None:
914918
return jsonify({"error": "Ticket not found"}), 404
915919

backend/csv_data.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,7 @@ class CSVTicketService:
440440

441441
def __init__(self):
442442
self._tickets: dict[UUID, Ticket] = {}
443+
self._tickets_by_incident_id: dict[str, Ticket] = {}
443444
self._loaded_files: set[str] = set()
444445

445446
def load_csv(self, file_path: str | Path) -> int:
@@ -455,13 +456,19 @@ def load_csv(self, file_path: str | Path) -> int:
455456

456457
for ticket in tickets:
457458
self._tickets[ticket.id] = ticket
459+
if ticket.incident_id:
460+
self._tickets_by_incident_id[ticket.incident_id] = ticket
458461

459462
self._loaded_files.add(file_key)
460463
return len(tickets)
461464

462465
def get_ticket(self, ticket_id: UUID) -> Optional[Ticket]:
463-
"""Get ticket by ID."""
466+
"""Get ticket by UUID."""
464467
return self._tickets.get(ticket_id)
468+
469+
def get_ticket_by_incident_id(self, incident_id: str) -> Optional[Ticket]:
470+
"""Get ticket by INC number (e.g. INC000016349327)."""
471+
return self._tickets_by_incident_id.get(incident_id)
465472

466473
def get_ticket_by_incident_id(self, incident_id: str) -> Optional[Ticket]:
467474
"""

backend/operations.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -274,12 +274,16 @@ async def op_csv_list_tickets(
274274

275275
@operation(
276276
name="csv_get_ticket",
277-
description="Get a single CSV ticket by UUID",
277+
description="Get a single CSV ticket by INC number (e.g. INC000016349327) or UUID",
278278
http_method="GET",
279279
)
280280
async def op_csv_get_ticket(ticket_id: str) -> Ticket | None:
281-
"""Get one CSV ticket."""
281+
"""Get one CSV ticket by INC number or UUID."""
282282
_ensure_csv_loaded()
283+
# Try INC number first (primary identifier)
284+
if ticket_id.upper().startswith("INC"):
285+
return _csv_service.get_ticket_by_incident_id(ticket_id)
286+
# Fall back to UUID for internal use
283287
try:
284288
parsed_id = UUID(ticket_id)
285289
except ValueError:
@@ -289,7 +293,7 @@ async def op_csv_get_ticket(ticket_id: str) -> Ticket | None:
289293

290294
@operation(
291295
name="csv_search_tickets",
292-
description="Search CSV tickets by text across summary, description, notes, requester and location fields",
296+
description="Search CSV tickets by text across incident ID, summary, description, notes, requester and location fields",
293297
http_method="GET",
294298
)
295299
async def op_csv_search_tickets(query: str, limit: int = 50) -> list[Ticket]:
@@ -304,6 +308,7 @@ async def op_csv_search_tickets(query: str, limit: int = 50) -> list[Ticket]:
304308
for ticket in _csv_service.list_tickets():
305309
haystack = " ".join(
306310
[
311+
ticket.incident_id or "",
307312
ticket.summary or "",
308313
ticket.description or "",
309314
ticket.notes or "",

frontend/src/features/usecase-demo/UsecaseDemoPage.jsx

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ export default function UsecaseDemoPage({ definition }) {
204204
)
205205

206206
const selectedTicket = useMemo(
207-
() => matchingTickets.find((ticket) => ticket.id === selectedTicketId) || null,
207+
() => matchingTickets.find((ticket) => ticket.incident_id === selectedTicketId || ticket.id === selectedTicketId) || null,
208208
[matchingTickets, selectedTicketId]
209209
)
210210

@@ -254,10 +254,10 @@ export default function UsecaseDemoPage({ definition }) {
254254
const loadedTickets = tickets.filter((ticket) => !ticket._error)
255255
setMatchingTickets(loadedTickets)
256256
setSelectedTicketId((previousId) => {
257-
if (previousId && loadedTickets.some((ticket) => ticket.id === previousId)) {
257+
if (previousId && loadedTickets.some((ticket) => ticket.incident_id === previousId || ticket.id === previousId)) {
258258
return previousId
259259
}
260-
return loadedTickets[0]?.id || null
260+
return loadedTickets[0]?.incident_id || loadedTickets[0]?.id || null
261261
})
262262

263263
if (!loadedTickets.length) {
@@ -462,31 +462,33 @@ export default function UsecaseDemoPage({ definition }) {
462462
</tr>
463463
</thead>
464464
<tbody>
465-
{matchingTickets.map((ticket, index) => (
466-
<tr key={ticket.id} className={index % 2 ? styles.rowAlt : ''}>
467-
<td className={styles.td}>{ticket.id}</td>
465+
{matchingTickets.map((ticket, index) => {
466+
const ticketKey = ticket.incident_id || ticket.id
467+
return (
468+
<tr key={ticketKey} className={index % 2 ? styles.rowAlt : ''}>
469+
<td className={styles.td}>{ticketKey}</td>
468470
<td className={styles.td}>{ticket.summary || '—'}</td>
469471
<td className={styles.td}>{ticket.status || '—'}</td>
470472
<td className={styles.td}>
471473
<Button
472474
size="small"
473-
appearance={selectedTicketId === ticket.id ? 'primary' : 'secondary'}
474-
onClick={() => setSelectedTicketId(ticket.id)}
475-
data-testid={`${testIdPrefix}-ticket-open-${ticket.id}`}
475+
appearance={selectedTicketId === ticketKey ? 'primary' : 'secondary'}
476+
onClick={() => setSelectedTicketId(ticketKey)}
477+
data-testid={`${testIdPrefix}-ticket-open-${ticketKey}`}
476478
>
477479
Open
478480
</Button>
479481
</td>
480482
</tr>
481-
))}
483+
)})}
482484
</tbody>
483485
</table>
484486
</div>
485487

486488
{selectedTicket && (
487489
<div className={styles.ticketDetails} data-testid={`${testIdPrefix}-ticket-details`}>
488490
<Text weight="semibold">{selectedTicket.summary || 'Ticket Details'}</Text>
489-
<Text>ID: {selectedTicket.id}</Text>
491+
<Text>INC: {selectedTicket.incident_id || '—'}</Text>
490492
<Text>Status: {selectedTicket.status || '—'}</Text>
491493
<Text>Priority: {selectedTicket.priority || '—'}</Text>
492494
<Text>Assignee: {selectedTicket.assignee || '—'}</Text>

frontend/src/features/usecase-demo/demoDefinitions.js

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,19 @@
11
const VPN_DEFAULT_PROMPT = `Find VPN issues where you think it's more a skill issue than a technical issue.
22
33
For speed:
4-
- First call csv_search_tickets with fields="id,summary,status,priority,assignee,assigned_group,created_at" and limit=20.
5-
- Use csv_get_ticket only for the top 3-5 most relevant IDs when deeper context is required.
4+
- First call csv_search_tickets with fields="incident_id,summary,status,priority,assignee,assigned_group,created_at" and limit=20.
5+
- Use csv_get_ticket only for the top 3-5 most relevant INC numbers when deeper context is required.
66
- Stop after the first sufficient result set; avoid extra tool loops.
77
- Do not request notes or resolution by default; only request them when explicitly needed for evidence.`;
88

99
const OPS_DEFAULT_PROMPT = `Analysiere Tickets zu "Outlook" oder "E-Mail" und erstelle einen einzigen Operations-Usecase.
1010
Für schnelle Ausführung:
11-
- Nutze zuerst csv_search_tickets mit fields="id,summary,status,priority,assigned_group,created_at" und limit=20.
12-
- Nutze csv_get_ticket nur für wenige ausgewählte Ticket-IDs, wenn Details nötig sind.
11+
- Nutze zuerst csv_search_tickets mit fields="incident_id,summary,status,priority,assigned_group,created_at" und limit=20.
12+
- Nutze csv_get_ticket nur für wenige ausgewählte INC-Nummern, wenn Details nötig sind.
1313
- Beende nach dem ersten ausreichenden Datensatz und vermeide zusätzliche Tool-Schleifen.
1414
- Fordere notes/resolution nicht standardmäßig an, nur wenn sie für die Aussage zwingend notwendig sind.
1515
Liefere nur eine kurze, handlungsorientierte Zusammenfassung mit Prioritäten und nächstem Schritt.
16-
Nutze ausschließlich CSV-Daten und nenne die verwendeten Ticket-IDs in Fließtext.`;
16+
Nutze ausschließlich CSV-Daten und nenne die verwendeten INC-Nummern in Fließtext.`;
1717

1818
const SLA_BREACH_DEFAULT_PROMPT = `Call csv_sla_breach_tickets with default parameters (unassigned_only=true, include_ok=false).
1919
@@ -58,6 +58,7 @@ export const USECASE_DEMO_DEFINITIONS = [
5858
description:
5959
"Ticket IDs from the agent result are resolved against CSV data. Click a ticket to inspect details.",
6060
fields: [
61+
"incident_id",
6162
"id",
6263
"summary",
6364
"status",
Lines changed: 36 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,58 +1,69 @@
11
const UUID_PATTERN =
2-
/[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/gi
2+
/[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/gi;
3+
4+
const INC_PATTERN = /INC\d{12,15}/gi;
35

46
export function formatDateTime(value) {
5-
if (!value) return '—'
6-
const parsed = new Date(value)
7-
if (Number.isNaN(parsed.getTime())) return value
8-
return parsed.toLocaleString()
7+
if (!value) return "—";
8+
const parsed = new Date(value);
9+
if (Number.isNaN(parsed.getTime())) return value;
10+
return parsed.toLocaleString();
911
}
1012

1113
export function upsertRun(runs, updatedRun, maxSize = 25) {
12-
const index = runs.findIndex((item) => item.id === updatedRun.id)
14+
const index = runs.findIndex((item) => item.id === updatedRun.id);
1315
if (index === -1) {
14-
return [updatedRun, ...runs].slice(0, maxSize)
16+
return [updatedRun, ...runs].slice(0, maxSize);
1517
}
1618

17-
const next = [...runs]
18-
next[index] = updatedRun
19-
next.sort((a, b) => new Date(b.created_at) - new Date(a.created_at))
20-
return next
19+
const next = [...runs];
20+
next[index] = updatedRun;
21+
next.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
22+
return next;
2123
}
2224

2325
export function parseTicketIds(rawValue) {
24-
if (rawValue == null) return []
26+
if (rawValue == null) return [];
2527

2628
const asText = Array.isArray(rawValue)
27-
? rawValue.join(',')
28-
: String(rawValue)
29+
? rawValue.join(",")
30+
: String(rawValue);
31+
32+
// Try INC numbers first (primary identifier)
33+
const incMatches = asText.match(INC_PATTERN);
34+
if (incMatches?.length) {
35+
return incMatches.map((value) => value.toUpperCase());
36+
}
2937

30-
const uuidMatches = asText.match(UUID_PATTERN)
38+
const uuidMatches = asText.match(UUID_PATTERN);
3139
if (uuidMatches?.length) {
32-
return uuidMatches.map((value) => value.toLowerCase())
40+
return uuidMatches.map((value) => value.toLowerCase());
3341
}
3442

3543
return asText
3644
.split(/[\n,;\s]+/)
3745
.map((value) => value.trim())
38-
.filter(Boolean)
46+
.filter(Boolean);
3947
}
4048

41-
export function extractTicketIdsFromRows(rows, ticketIdFields = ['ticket_ids', 'ticket_id', 'ticketIds']) {
42-
const ids = new Set()
49+
export function extractTicketIdsFromRows(
50+
rows,
51+
ticketIdFields = ["ticket_ids", "ticket_id", "ticketIds"],
52+
) {
53+
const ids = new Set();
4354
for (const row of rows || []) {
44-
if (!row || typeof row !== 'object') continue
55+
if (!row || typeof row !== "object") continue;
4556
for (const field of ticketIdFields) {
46-
const fieldValue = row[field]
57+
const fieldValue = row[field];
4758
for (const parsedId of parseTicketIds(fieldValue)) {
48-
ids.add(parsedId)
59+
ids.add(parsedId);
4960
}
5061
}
5162
}
52-
return Array.from(ids)
63+
return Array.from(ids);
5364
}
5465

5566
export function sanitizeMarkdownForDisplay(markdown) {
56-
if (!markdown) return ''
57-
return markdown.replace(/```json[\s\S]*?```/gi, '').trim()
67+
if (!markdown) return "";
68+
return markdown.replace(/```json[\s\S]*?```/gi, "").trim();
5869
}

frontend/src/services/api.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -219,8 +219,8 @@ export async function getCSVTicketStats() {
219219
}
220220

221221
/**
222-
* Get one CSV ticket by ID.
223-
* @param {string} ticketId - Ticket UUID
222+
* Get one CSV ticket by INC number (e.g. INC000016349327) or UUID.
223+
* @param {string} ticketId - INC number or UUID
224224
* @param {string[]} fields - Optional field selection
225225
* @returns {Promise<Object>} Ticket details
226226
*/
@@ -337,7 +337,7 @@ export async function deleteWorkbenchAgent(agentId) {
337337

338338
export async function runWorkbenchAgent(
339339
agentId,
340-
{ inputPrompt = "", requiredInputValue = "" } = {}
340+
{ inputPrompt = "", requiredInputValue = "" } = {},
341341
) {
342342
return fetchJSON(`${API_BASE_URL}/workbench/agents/${agentId}/runs`, {
343343
method: "POST",

0 commit comments

Comments
 (0)