harryagasi commited on
Commit
98ca5fc
·
1 Parent(s): 6b435ec

feat: sort analyses by updated_at, render data_used in traceability

Browse files

- Sort analysis list by updated_at (desc) on load, select, create, and data-bind update
- Add data_used field to traceability response type and render it in a dedicated section
- Move tool calls behind a collapsible "Details" section instead of showing inline
- Update API contract docs for soft-delete analysis behavior and traceability data_used/tool_calls fields

docs/API_CONTRACT_BE_GOLANG.md CHANGED
@@ -1,6 +1,6 @@
1
  # Backend Go API Contract
2
 
3
- **Last updated**: 2026-07-09
4
 
5
  Dokumen ini merangkum endpoint Orchestration Agent Service yang dipakai oleh frontend. Fokus flow:
6
 
@@ -764,7 +764,7 @@ Errors: `400`, `401`, `404`.
764
 
765
  ### `DELETE /api/v1/analyses/{id}`
766
 
767
- Hapus analysis milik user.
768
 
769
  Success `204` tanpa response body.
770
 
 
1
  # Backend Go API Contract
2
 
3
+ **Last updated**: 2026-07-13
4
 
5
  Dokumen ini merangkum endpoint Orchestration Agent Service yang dipakai oleh frontend. Fokus flow:
6
 
 
764
 
765
  ### `DELETE /api/v1/analyses/{id}`
766
 
767
+ Soft-delete analysis milik user: mengubah `status` menjadi `inactive`, bukan menghapus row dari database. Record analysis dan seluruh data terkait (reports, messages, report_inputs, dll) tetap ada di database, hanya tidak muncul lagi di `GET /api/v1/analyses` dengan filter default (`status=active`).
768
 
769
  Success `204` tanpa response body.
770
 
docs/API_CONTRACT_BE_PYTHON.md CHANGED
@@ -1,5 +1,7 @@
1
  # Backend Agentic Service API Contract
2
 
 
 
3
  This document describes the Python agentic backend used by the frontend for AI chat, help/report tools, and traceability data shown alongside chat answers.
4
 
5
  Base path examples use relative URLs. Configure the frontend with the deployed Python service base URL.
@@ -422,6 +424,7 @@ Response `200` for `structured_flow`:
422
  "order": 1,
423
  "task_id": null,
424
  "name": "check_data",
 
425
  "input": { "source_hint": "structured" },
426
  "output": {
427
  "kind": "table",
@@ -436,6 +439,7 @@ Response `200` for `structured_flow`:
436
  "order": 2,
437
  "task_id": null,
438
  "name": "retrieve_data",
 
439
  "input": { "ir": { "source_id": "src_sales_db", "table_id": "orders", "select": ["region", "amount"], "group_by": ["region"] } },
440
  "output": {
441
  "kind": "table",
@@ -447,11 +451,34 @@ Response `200` for `structured_flow`:
447
  "error": null
448
  }
449
  ],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
450
  "sources": [
451
  {
452
  "type": "database",
453
  "source_id": "src_sales_db",
 
454
  "name": "orders",
 
455
  "query": "SELECT region, SUM(amount) AS total FROM orders GROUP BY region",
456
  "detail": {
457
  "table": "orders",
 
1
  # Backend Agentic Service API Contract
2
 
3
+ **Last updated**: 2026-07-13
4
+
5
  This document describes the Python agentic backend used by the frontend for AI chat, help/report tools, and traceability data shown alongside chat answers.
6
 
7
  Base path examples use relative URLs. Configure the frontend with the deployed Python service base URL.
 
424
  "order": 1,
425
  "task_id": null,
426
  "name": "check_data",
427
+ "summary": "Inspected your data source structure",
428
  "input": { "source_hint": "structured" },
429
  "output": {
430
  "kind": "table",
 
439
  "order": 2,
440
  "task_id": null,
441
  "name": "retrieve_data",
442
+ "summary": "Retrieved 4 rows across 2 columns from orders",
443
  "input": { "ir": { "source_id": "src_sales_db", "table_id": "orders", "select": ["region", "amount"], "group_by": ["region"] } },
444
  "output": {
445
  "kind": "table",
 
451
  "error": null
452
  }
453
  ],
454
+ "data_used": [
455
+ {
456
+ "source": { "id": "src_sales_db", "name": "sales db", "type": "schema" },
457
+ "tables": [ { "id": "orders", "name": "orders", "role": "base" } ],
458
+ "joins": [],
459
+ "columns_read": [
460
+ { "id": "c_region", "name": "region", "table": "orders", "data_type": "string", "pii": false, "roles": ["selected", "grouped"] },
461
+ { "id": "c_amount", "name": "amount", "table": "orders", "data_type": "decimal", "pii": false, "roles": ["aggregated"] }
462
+ ],
463
+ "output_columns": [
464
+ { "name": "region", "kind": "column", "from": "orders.region" },
465
+ { "name": "total", "kind": "computed", "from": "orders.amount", "formula": "SUM(orders.amount)" }
466
+ ],
467
+ "filters": [],
468
+ "group_by": ["orders.region"],
469
+ "order_by": [],
470
+ "limit": null,
471
+ "rows_returned": 4,
472
+ "query": "SELECT region, SUM(amount) AS total FROM orders GROUP BY region"
473
+ }
474
+ ],
475
  "sources": [
476
  {
477
  "type": "database",
478
  "source_id": "src_sales_db",
479
+ "source_name": "sales db",
480
  "name": "orders",
481
+ "tables": ["orders"],
482
  "query": "SELECT region, SUM(amount) AS total FROM orders GROUP BY region",
483
  "detail": {
484
  "table": "orders",
src/app/components/analysis/AnalysisShell.tsx CHANGED
@@ -47,6 +47,10 @@ function toUiMessage(message: AnalysisMessage): UiMessage {
47
  }
48
 
49
 
 
 
 
 
50
  function normalizeAssistantContent(content: string) {
51
  return content
52
  .replace(/([A-Za-z0-9])(?=\*\*)/g, "$1 ")
@@ -81,7 +85,7 @@ export function AnalysisShell() {
81
  try {
82
  const [detail, history] = await Promise.all([getAnalysis(analysis.id), getAnalysisMessages(analysis.id)]);
83
  setActiveAnalysis(detail);
84
- setAnalyses((prev) => prev.map((item) => (item.id === detail.id ? detail : item)));
85
  setMessages(history.map(toUiMessage));
86
  } catch (err) {
87
  toast.error(err instanceof Error ? err.message : "Failed to open analysis");
@@ -95,7 +99,7 @@ export function AnalysisShell() {
95
  setLoadingAnalyses(true);
96
  try {
97
  const result = await listAnalyses({ status: "active", page: 1, limit: 50 });
98
- setAnalyses(result.analyses);
99
  if (!activeAnalysis && result.analyses.length > 0) {
100
  await selectAnalysis(result.analyses[0]);
101
  }
@@ -288,7 +292,7 @@ export function AnalysisShell() {
288
  };
289
 
290
  const handleCreated = (analysis: Analysis) => {
291
- setAnalyses((prev) => [analysis, ...prev.filter((item) => item.id !== analysis.id)]);
292
  setActiveMenu("analysis-agent");
293
  selectAnalysis(analysis);
294
  };
@@ -314,7 +318,7 @@ export function AnalysisShell() {
314
  if (!activeAnalysis) return;
315
  const updated = await updateAnalysisDataBind(activeAnalysis.id, activeAnalysis.data_bind_version, items);
316
  setActiveAnalysis(updated);
317
- setAnalyses((prev) => prev.map((item) => (item.id === updated.id ? updated : item)));
318
  setStaleSources([]);
319
  };
320
 
 
47
  }
48
 
49
 
50
+ function sortByUpdatedAtDesc(items: Analysis[]) {
51
+ return [...items].sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime());
52
+ }
53
+
54
  function normalizeAssistantContent(content: string) {
55
  return content
56
  .replace(/([A-Za-z0-9])(?=\*\*)/g, "$1 ")
 
85
  try {
86
  const [detail, history] = await Promise.all([getAnalysis(analysis.id), getAnalysisMessages(analysis.id)]);
87
  setActiveAnalysis(detail);
88
+ setAnalyses((prev) => sortByUpdatedAtDesc(prev.map((item) => (item.id === detail.id ? detail : item))));
89
  setMessages(history.map(toUiMessage));
90
  } catch (err) {
91
  toast.error(err instanceof Error ? err.message : "Failed to open analysis");
 
99
  setLoadingAnalyses(true);
100
  try {
101
  const result = await listAnalyses({ status: "active", page: 1, limit: 50 });
102
+ setAnalyses(sortByUpdatedAtDesc(result.analyses));
103
  if (!activeAnalysis && result.analyses.length > 0) {
104
  await selectAnalysis(result.analyses[0]);
105
  }
 
292
  };
293
 
294
  const handleCreated = (analysis: Analysis) => {
295
+ setAnalyses((prev) => sortByUpdatedAtDesc([analysis, ...prev.filter((item) => item.id !== analysis.id)]));
296
  setActiveMenu("analysis-agent");
297
  selectAnalysis(analysis);
298
  };
 
318
  if (!activeAnalysis) return;
319
  const updated = await updateAnalysisDataBind(activeAnalysis.id, activeAnalysis.data_bind_version, items);
320
  setActiveAnalysis(updated);
321
+ setAnalyses((prev) => sortByUpdatedAtDesc(prev.map((item) => (item.id === updated.id ? updated : item))));
322
  setStaleSources([]);
323
  };
324
 
src/app/components/analysis/MessageTraceability.tsx CHANGED
@@ -1,6 +1,6 @@
1
  import { useState } from "react";
2
- import { ChevronDown, ChevronRight, Loader2, Route, Wrench } from "lucide-react";
3
- import type { Observability } from "@/services/agenticApi";
4
  import { formatDateTime } from "./utils";
5
 
6
  interface MessageTraceabilityProps {
@@ -28,14 +28,55 @@ function sourceLabel(source: unknown, index: number) {
28
  return String(source ?? `Source ${index + 1}`);
29
  }
30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  export function MessageTraceability({ observability, loading, error, sources = [] }: MessageTraceabilityProps) {
32
  const [open, setOpen] = useState(false);
 
33
  const traceSources = observability?.sources?.length ? observability.sources : sources;
34
  const hasTrace = Boolean(observability || loading || error || traceSources.length > 0);
35
 
36
  if (!hasTrace) return null;
37
 
38
  const toolCalls = observability?.tool_calls ?? [];
 
39
 
40
  return (
41
  <div className="mt-3 border-t border-slate-200 pt-3">
@@ -95,31 +136,59 @@ export function MessageTraceability({ observability, loading, error, sources = [
95
 
96
  <div>
97
  <div className="flex items-center gap-2 font-semibold text-slate-900">
98
- <Wrench className="h-3.5 w-3.5" />
99
- Tool calls
100
  </div>
101
- {toolCalls.length === 0 ? (
102
- <p className="mt-1 text-slate-500">No tool call recorded for this answer.</p>
103
  ) : (
104
  <div className="mt-2 space-y-2">
105
- {toolCalls.map((tool, index) => (
106
- <div key={`${tool.name}-${index}`} className="rounded-md border border-slate-200 bg-white p-2">
107
- <div className="flex items-center justify-between gap-2">
108
- <p className="font-medium text-slate-900">{tool.name}</p>
109
- {tool.status && <span className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] uppercase text-slate-500">{tool.status}</span>}
110
- </div>
111
- {tool.input !== undefined && (
112
- <pre className="mt-2 max-h-40 overflow-auto rounded bg-slate-950 p-2 text-[11px] leading-4 text-slate-100">{renderUnknown(tool.input)}</pre>
113
- )}
114
- {tool.output !== undefined && (
115
- <pre className="mt-2 max-h-40 overflow-auto rounded bg-white p-2 text-[11px] leading-4 text-slate-600 ring-1 ring-slate-200">{renderUnknown(tool.output)}</pre>
116
- )}
117
- </div>
118
  ))}
119
  </div>
120
  )}
121
  </div>
122
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  {traceSources.length > 0 && (
124
  <div>
125
  <p className="font-semibold text-slate-900">Citations and sources</p>
 
1
  import { useState } from "react";
2
+ import { ChevronDown, ChevronRight, Database, Loader2, Route, Wrench } from "lucide-react";
3
+ import type { Observability, ObservabilityDataUsed } from "@/services/agenticApi";
4
  import { formatDateTime } from "./utils";
5
 
6
  interface MessageTraceabilityProps {
 
28
  return String(source ?? `Source ${index + 1}`);
29
  }
30
 
31
+ function DataUsedCard({ item, index }: { item: ObservabilityDataUsed; index: number }) {
32
+ return (
33
+ <div className="rounded-md border border-slate-200 bg-white p-2">
34
+ <div className="flex items-center justify-between gap-2">
35
+ <p className="font-medium text-slate-900">{item.source?.name ?? `Data source ${index + 1}`}</p>
36
+ {item.source?.type && <span className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] uppercase text-slate-500">{item.source.type}</span>}
37
+ </div>
38
+
39
+ {item.tables && item.tables.length > 0 && (
40
+ <p className="mt-1.5 text-slate-600">
41
+ Tables: {item.tables.map((table) => table.name).join(", ")}
42
+ </p>
43
+ )}
44
+
45
+ {item.columns_read && item.columns_read.length > 0 && (
46
+ <div className="mt-1.5 flex flex-wrap gap-1">
47
+ {item.columns_read.map((column) => (
48
+ <span key={column.id} className="rounded border border-slate-200 bg-slate-50 px-1.5 py-0.5 text-[11px] text-slate-600">
49
+ {column.table ? `${column.table}.${column.name}` : column.name}
50
+ </span>
51
+ ))}
52
+ </div>
53
+ )}
54
+
55
+ {item.group_by && item.group_by.length > 0 && (
56
+ <p className="mt-1.5 text-slate-600">Grouped by: {item.group_by.join(", ")}</p>
57
+ )}
58
+
59
+ {typeof item.rows_returned === "number" && (
60
+ <p className="mt-1.5 text-slate-600">Rows returned: {item.rows_returned}</p>
61
+ )}
62
+
63
+ {item.query && (
64
+ <pre className="mt-2 max-h-40 overflow-auto rounded bg-slate-950 p-2 text-[11px] leading-4 text-slate-100">{item.query}</pre>
65
+ )}
66
+ </div>
67
+ );
68
+ }
69
+
70
  export function MessageTraceability({ observability, loading, error, sources = [] }: MessageTraceabilityProps) {
71
  const [open, setOpen] = useState(false);
72
+ const [detailsOpen, setDetailsOpen] = useState(false);
73
  const traceSources = observability?.sources?.length ? observability.sources : sources;
74
  const hasTrace = Boolean(observability || loading || error || traceSources.length > 0);
75
 
76
  if (!hasTrace) return null;
77
 
78
  const toolCalls = observability?.tool_calls ?? [];
79
+ const dataUsed = observability?.data_used ?? [];
80
 
81
  return (
82
  <div className="mt-3 border-t border-slate-200 pt-3">
 
136
 
137
  <div>
138
  <div className="flex items-center gap-2 font-semibold text-slate-900">
139
+ <Database className="h-3.5 w-3.5" />
140
+ Data used
141
  </div>
142
+ {dataUsed.length === 0 ? (
143
+ <p className="mt-1 text-slate-500">No data usage recorded for this answer.</p>
144
  ) : (
145
  <div className="mt-2 space-y-2">
146
+ {dataUsed.map((item, index) => (
147
+ <DataUsedCard key={`${item.source?.id ?? "source"}-${index}`} item={item} index={index} />
 
 
 
 
 
 
 
 
 
 
 
148
  ))}
149
  </div>
150
  )}
151
  </div>
152
 
153
+ <div>
154
+ <button
155
+ type="button"
156
+ title="Show tool call details"
157
+ aria-expanded={detailsOpen}
158
+ onClick={() => setDetailsOpen((current) => !current)}
159
+ className="inline-flex items-center gap-2 rounded-md px-1.5 py-1 text-xs font-semibold text-slate-900 hover:bg-slate-100"
160
+ >
161
+ {detailsOpen ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
162
+ <Wrench className="h-3.5 w-3.5" />
163
+ Details
164
+ </button>
165
+
166
+ {detailsOpen && (
167
+ toolCalls.length === 0 ? (
168
+ <p className="mt-1 pl-1.5 text-slate-500">No tool call recorded for this answer.</p>
169
+ ) : (
170
+ <div className="mt-2 space-y-2 pl-1.5">
171
+ {toolCalls.map((tool, index) => (
172
+ <div key={`${tool.name}-${index}`} className="rounded-md border border-slate-200 bg-white p-2">
173
+ <div className="flex items-center justify-between gap-2">
174
+ <p className="font-medium text-slate-900">{tool.name}</p>
175
+ {tool.status && <span className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] uppercase text-slate-500">{tool.status}</span>}
176
+ </div>
177
+ {tool.summary && <p className="mt-1 text-slate-600">{tool.summary}</p>}
178
+ {tool.input !== undefined && (
179
+ <pre className="mt-2 max-h-40 overflow-auto rounded bg-slate-950 p-2 text-[11px] leading-4 text-slate-100">{renderUnknown(tool.input)}</pre>
180
+ )}
181
+ {tool.output !== undefined && (
182
+ <pre className="mt-2 max-h-40 overflow-auto rounded bg-white p-2 text-[11px] leading-4 text-slate-600 ring-1 ring-slate-200">{renderUnknown(tool.output)}</pre>
183
+ )}
184
+ {tool.error && <p className="mt-2 text-red-600">{tool.error}</p>}
185
+ </div>
186
+ ))}
187
+ </div>
188
+ )
189
+ )}
190
+ </div>
191
+
192
  {traceSources.length > 0 && (
193
  <div>
194
  <p className="font-semibold text-slate-900">Citations and sources</p>
src/services/agenticApi.ts CHANGED
@@ -48,10 +48,55 @@ export interface ObservabilityPlanning {
48
 
49
  export interface ObservabilityToolCall {
50
  order?: number;
 
51
  name: string;
 
52
  input?: unknown;
53
  output?: unknown;
54
  status?: string;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  }
56
 
57
  export interface Observability {
@@ -62,6 +107,7 @@ export interface Observability {
62
  planning: ObservabilityPlanning | null;
63
  thinking: string | null;
64
  tool_calls: ObservabilityToolCall[];
 
65
  sources: unknown[];
66
  }
67
 
 
48
 
49
  export interface ObservabilityToolCall {
50
  order?: number;
51
+ task_id?: string | null;
52
  name: string;
53
+ summary?: string;
54
  input?: unknown;
55
  output?: unknown;
56
  status?: string;
57
+ error?: string | null;
58
+ }
59
+
60
+ export interface ObservabilityDataUsedSource {
61
+ id: string;
62
+ name: string;
63
+ type: string;
64
+ }
65
+
66
+ export interface ObservabilityDataUsedTable {
67
+ id: string;
68
+ name: string;
69
+ role?: string;
70
+ }
71
+
72
+ export interface ObservabilityDataUsedColumn {
73
+ id: string;
74
+ name: string;
75
+ table?: string;
76
+ data_type?: string;
77
+ pii?: boolean;
78
+ roles?: string[];
79
+ }
80
+
81
+ export interface ObservabilityDataUsedOutputColumn {
82
+ name: string;
83
+ kind?: string;
84
+ from?: string;
85
+ formula?: string;
86
+ }
87
+
88
+ export interface ObservabilityDataUsed {
89
+ source: ObservabilityDataUsedSource;
90
+ tables?: ObservabilityDataUsedTable[];
91
+ joins?: unknown[];
92
+ columns_read?: ObservabilityDataUsedColumn[];
93
+ output_columns?: ObservabilityDataUsedOutputColumn[];
94
+ filters?: unknown[];
95
+ group_by?: string[];
96
+ order_by?: unknown[];
97
+ limit?: number | null;
98
+ rows_returned?: number;
99
+ query?: string;
100
  }
101
 
102
  export interface Observability {
 
107
  planning: ObservabilityPlanning | null;
108
  thinking: string | null;
109
  tool_calls: ObservabilityToolCall[];
110
+ data_used?: ObservabilityDataUsed[];
111
  sources: unknown[];
112
  }
113