harryagasi commited on
Commit
587748b
·
1 Parent(s): e2da2f6

fix: handle failed AI message persistence per updated Go contract

Browse files

Backend now returns 201 with status:"failed" and a reserved all-zero
message_id when an AI message is saved without a valid message_id,
instead of a request error. Surface this as an error bubble and skip
calling traceability with the reserved id.

docs/API_CONTRACT_BE_GOLANG.md CHANGED
@@ -1,4 +1,6 @@
1
- # Frontend API Contract
 
 
2
 
3
  Dokumen ini merangkum endpoint Orchestration Agent Service yang dipakai oleh frontend. Fokus flow:
4
 
@@ -802,11 +804,14 @@ Errors:
802
 
803
  ```json
804
  {
805
- "id": "message-id",
 
806
  "analysis_id": "analysis-id",
807
  "user_id": "user-id",
808
  "role": "user",
809
  "content": "Apa penyebab revenue turun di Q3?",
 
 
810
  "created_at": "2026-06-30T08:00:00Z"
811
  }
812
  ```
@@ -816,11 +821,18 @@ Errors:
816
  - `user`: pertanyaan atau instruksi dari user
817
  - `ai`: jawaban dari AI Agent Service
818
 
 
 
 
 
 
 
 
819
  ### `POST /api/v1/analyses/{id}/messages`
820
 
821
  Merekam tepat satu pesan conversation ke `analyses_messages`.
822
 
823
- Request untuk pertanyaan user:
824
 
825
  ```json
826
  {
@@ -829,16 +841,17 @@ Request untuk pertanyaan user:
829
  }
830
  ```
831
 
832
- Request untuk jawaban AI:
833
 
834
  ```json
835
  {
836
  "role": "ai",
837
- "content": "Revenue turun karena penurunan transaksi enterprise dan kenaikan churn di wilayah barat."
 
838
  }
839
  ```
840
 
841
- Success `201`:
842
 
843
  ```json
844
  {
@@ -852,13 +865,38 @@ Success `201`:
852
  "user_id": "user-id",
853
  "role": "user",
854
  "content": "Apa penyebab revenue turun di Q3?",
 
855
  "created_at": "2026-06-30T08:00:00Z"
856
  }
857
  }
858
  }
859
  ```
860
 
861
- > `message_id` bernilai sama dengan `id` — disertakan agar frontend dapat langsung menggunakannya untuk memanggil endpoint observability (`GET /api/v1/observability?analysis_id=...&message_id=...`) tanpa mapping tambahan.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
862
 
863
  Errors:
864
 
@@ -891,14 +929,26 @@ Success `200`:
891
  "user_id": "user-id",
892
  "role": "ai",
893
  "content": "Revenue turun karena penurunan transaksi enterprise.",
 
894
  "created_at": "2026-06-30T08:01:00Z"
 
 
 
 
 
 
 
 
 
 
 
895
  }
896
  ]
897
  }
898
  }
899
  ```
900
 
901
- > Setiap item menyertakan `message_id` (sama dengan `id`) untuk digunakan frontend pada pemanggilan `GET /api/v1/observability?analysis_id=...&message_id=...` setelah page refresh.
902
 
903
  Errors: `400`, `401`, `404`.
904
 
@@ -942,7 +992,7 @@ GET /api/v1/data-catalog/{user_id}
942
  POST /api/v1/analyses with business_questions
943
  call AI Agent Service outside this service
944
  POST /api/v1/analyses/{analysis_id}/messages with role=user and content=user_question
945
- POST /api/v1/analyses/{analysis_id}/messages with role=ai and content=agent_answer
946
  GET /api/v1/analyses/{analysis_id}/messages
947
  ```
948
 
@@ -954,4 +1004,13 @@ GET /api/v1/analyses/{analysis_id}/messages
954
  - Perubahan `data_bind` akan rebuild analysis-level catalog. Frontend juga dapat memanggil endpoint rebuild analysis catalog secara eksplisit jika diperlukan.
955
  - Setelah refresh token sukses, selalu replace refresh token lama dengan refresh token baru.
956
  - Untuk endpoint yang membutuhkan `user_id`, gunakan `data.user.id` dari login dan pastikan sama dengan token aktif.
957
- - Untuk binding analysis, pakai source yang sudah berhasil diupload/diproses atau database client yang sudah diingest.
 
 
 
 
 
 
 
 
 
 
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
 
 
804
 
805
  ```json
806
  {
807
+ "id": "uuid-dari-db",
808
+ "message_id": "uuid-dari-db",
809
  "analysis_id": "analysis-id",
810
  "user_id": "user-id",
811
  "role": "user",
812
  "content": "Apa penyebab revenue turun di Q3?",
813
+ "status": "success",
814
+ "note": "",
815
  "created_at": "2026-06-30T08:00:00Z"
816
  }
817
  ```
 
821
  - `user`: pertanyaan atau instruksi dari user
822
  - `ai`: jawaban dari AI Agent Service
823
 
824
+ `status` hanya:
825
+
826
+ - `success`: pesan tersimpan sesuai konten yang dikirim. Untuk `role=user`, `message_id` selalu system-generated (default), berapa pun nilai `message_id` yang dikirim frontend (jika ada) — nilai kiriman itu diabaikan. Untuk `role=ai`, `success` berarti `message_id` yang dikirim valid dan dipakai apa adanya.
827
+ - `failed`: khusus `role=ai` yang dikirim tanpa `message_id` valid (lihat catatan di bawah). `content` diganti pesan fallback hardcoded dan `message_id` diganti default reserved value.
828
+
829
+ `note` hanya terisi (non-empty) ketika `status=failed`, berisi penjelasan singkat penyebab kegagalan. Saat `status=success`, field ini kosong/absent.
830
+
831
  ### `POST /api/v1/analyses/{id}/messages`
832
 
833
  Merekam tepat satu pesan conversation ke `analyses_messages`.
834
 
835
+ Request untuk pertanyaan user — **jangan kirim `message_id`**; jika dikirim tetap akan diabaikan sepenuhnya dan diganti dengan nilai default (system-generated):
836
 
837
  ```json
838
  {
 
841
  }
842
  ```
843
 
844
+ Request untuk jawaban AI — **`message_id` wajib diisi untuk `role=ai`**:
845
 
846
  ```json
847
  {
848
  "role": "ai",
849
+ "content": "Revenue turun karena penurunan transaksi enterprise dan kenaikan churn di wilayah barat.",
850
+ "message_id": "uuid-yang-dibuat-frontend-atau-agent-service"
851
  }
852
  ```
853
 
854
+ Success `201` (role=user, atau role=ai dengan `message_id` valid):
855
 
856
  ```json
857
  {
 
865
  "user_id": "user-id",
866
  "role": "user",
867
  "content": "Apa penyebab revenue turun di Q3?",
868
+ "status": "success",
869
  "created_at": "2026-06-30T08:00:00Z"
870
  }
871
  }
872
  }
873
  ```
874
 
875
+ > `message_id` bernilai sama dengan `id` untuk pesan sukses (baik `role=user` maupun `role=ai` dengan `message_id` valid) — disertakan agar frontend dapat langsung menggunakannya untuk memanggil endpoint observability (`GET /api/v1/observability?analysis_id=...&message_id=...`) tanpa mapping tambahan.
876
+
877
+ **Kasus `role=ai` tanpa `message_id`** (missing atau blank): endpoint **tetap** mengembalikan `201` (bukan error) karena kegagalan ini dianggap outcome data-level, bukan request error. Response menunjukkan `status: "failed"`:
878
+
879
+ ```json
880
+ {
881
+ "status": "success",
882
+ "message": "Message created",
883
+ "data": {
884
+ "message": {
885
+ "id": "uuid-dari-db",
886
+ "message_id": "00000000-0000-0000-0000-000000000000",
887
+ "analysis_id": "analysis-id",
888
+ "user_id": "user-id",
889
+ "role": "ai",
890
+ "content": "Maaf, terjadi kesalahan saat memproses respons AI. Silakan coba lagi.",
891
+ "status": "failed",
892
+ "note": "message_id is required for AI-role messages but was not provided",
893
+ "created_at": "2026-06-30T08:01:00Z"
894
+ }
895
+ }
896
+ }
897
+ ```
898
+
899
+ `message_id: "00000000-0000-0000-0000-000000000000"` adalah nilai reserved yang dipakai berulang untuk setiap pesan `ai` yang gagal (bukan di-generate baru setiap kali) — jangan gunakan nilai ini untuk memanggil endpoint observability, karena tidak menunjuk ke jawaban AI yang valid. Chat tetap bisa dilanjutkan setelah kegagalan ini; tidak ada retry otomatis dari backend.
900
 
901
  Errors:
902
 
 
929
  "user_id": "user-id",
930
  "role": "ai",
931
  "content": "Revenue turun karena penurunan transaksi enterprise.",
932
+ "status": "success",
933
  "created_at": "2026-06-30T08:01:00Z"
934
+ },
935
+ {
936
+ "id": "uuid-dari-db-2",
937
+ "message_id": "00000000-0000-0000-0000-000000000000",
938
+ "analysis_id": "analysis-id",
939
+ "user_id": "user-id",
940
+ "role": "ai",
941
+ "content": "Maaf, terjadi kesalahan saat memproses respons AI. Silakan coba lagi.",
942
+ "status": "failed",
943
+ "note": "message_id is required for AI-role messages but was not provided",
944
+ "created_at": "2026-06-30T08:02:00Z"
945
  }
946
  ]
947
  }
948
  }
949
  ```
950
 
951
+ > Setiap item menyertakan `message_id`, `status`, dan (jika `failed`) `note`. Untuk pesan `status=success`, `message_id` sama dengan `id` dan dapat dipakai memanggil `GET /api/v1/observability?analysis_id=...&message_id=...` setelah page refresh. Untuk pesan `status=failed`, gunakan field ini hanya untuk ditampilkan sebagai error bubble di UI — jangan dipakai memanggil observability endpoint.
952
 
953
  Errors: `400`, `401`, `404`.
954
 
 
992
  POST /api/v1/analyses with business_questions
993
  call AI Agent Service outside this service
994
  POST /api/v1/analyses/{analysis_id}/messages with role=user and content=user_question
995
+ POST /api/v1/analyses/{analysis_id}/messages with role=ai, content=agent_answer, message_id=agent_message_id
996
  GET /api/v1/analyses/{analysis_id}/messages
997
  ```
998
 
 
1004
  - Perubahan `data_bind` akan rebuild analysis-level catalog. Frontend juga dapat memanggil endpoint rebuild analysis catalog secara eksplisit jika diperlukan.
1005
  - Setelah refresh token sukses, selalu replace refresh token lama dengan refresh token baru.
1006
  - Untuk endpoint yang membutuhkan `user_id`, gunakan `data.user.id` dari login dan pastikan sama dengan token aktif.
1007
+ - Untuk binding analysis, pakai source yang sudah berhasil diupload/diproses atau database client yang sudah diingest.
1008
+ - Saat merekam jawaban AI (`role=ai`), selalu sertakan `message_id` dari AI Agent Service. Jika `message_id` tidak dikirim atau kosong, endpoint tetap sukses (`201`) tetapi menyimpan pesan dengan `status: "failed"`, `content` fallback hardcoded, dan `message_id` reserved (`00000000-0000-0000-0000-000000000000`) — frontend harus memeriksa `status` pada response, bukan hanya HTTP status code, untuk mendeteksi kegagalan ini.
1009
+ - Saat merekam pertanyaan user (`role=user`), **jangan** mengirim `message_id` sama sekali — backend selalu mengabaikannya dan memakai nilai default (system-generated) untuk `role=user`, tidak peduli apa yang dikirim frontend.
1010
+
1011
+
1012
+
1013
+
1014
+
1015
+
1016
+
src/app/components/KnowledgeManagement.tsx CHANGED
@@ -305,8 +305,10 @@ export default function KnowledgeManagement({
305
  if (!userId) return;
306
  setIngesting(clientId);
307
  try {
308
- const res = await ingestDatabaseClient(clientId, userId);
309
- const tableCount = res.tables?.length ?? 0;
 
 
310
  toast.success(`Ingested ${tableCount} table${tableCount !== 1 ? "s" : ""} successfully`);
311
  const now = new Date().toISOString();
312
  setIngestTimestamps((prev) => {
 
305
  if (!userId) return;
306
  setIngesting(clientId);
307
  try {
308
+ await ingestDatabaseClient(clientId, userId);
309
+ const catalog = await getDataCatalog(userId);
310
+ const source = catalog.sources.find((s) => s.source_id === clientId);
311
+ const tableCount = source?.table_count ?? 0;
312
  toast.success(`Ingested ${tableCount} table${tableCount !== 1 ? "s" : ""} successfully`);
313
  const now = new Date().toISOString();
314
  setIngestTimestamps((prev) => {
src/app/components/analysis/AnalysisShell.tsx CHANGED
@@ -12,6 +12,7 @@ import {
12
  getDocuments,
13
  listAnalyses,
14
  updateAnalysisDataBind,
 
15
  type Analysis,
16
  type AnalysisMessage,
17
  type DataBindItem,
@@ -29,13 +30,17 @@ import type { StreamState, UiMessage } from "./types";
29
  import { cx, makeLocalId } from "./utils";
30
 
31
  function toUiMessage(message: AnalysisMessage): UiMessage {
 
32
  return {
33
  id: message.id,
34
  role: message.role,
35
  content: message.content,
36
  created_at: message.created_at,
37
- status: "complete",
 
38
  messageId: message.message_id ?? undefined,
 
 
39
  };
40
  }
41
 
@@ -153,6 +158,7 @@ export function AnalysisShell() {
153
  message.role === "ai" &&
154
  message.status === "complete" &&
155
  Boolean(message.messageId) &&
 
156
  !message.traceability &&
157
  !message.traceabilityLoading &&
158
  !message.traceabilityError
@@ -229,14 +235,19 @@ export function AnalysisShell() {
229
  const saved = await createAnalysisMessage(activeAnalysis.id, { role: "ai", content: completedContent, message_id: doneMessageId });
230
  traceTargetId = saved.id;
231
  const savedUi = toUiMessage(saved);
232
- resolvedMessageId = savedUi.messageId ?? doneMessageId;
 
233
  setMessages((prev) =>
234
  prev.map((msg) =>
235
  msg.id === aiId
236
- ? { ...savedUi, sources: msg.sources, traceabilityLoading: Boolean(savedUi.messageId) }
237
  : msg
238
  )
239
  );
 
 
 
 
240
  }
241
 
242
  await fetchTraceability(activeAnalysis.id, resolvedMessageId, traceTargetId);
 
12
  getDocuments,
13
  listAnalyses,
14
  updateAnalysisDataBind,
15
+ RESERVED_FAILED_MESSAGE_ID,
16
  type Analysis,
17
  type AnalysisMessage,
18
  type DataBindItem,
 
30
  import { cx, makeLocalId } from "./utils";
31
 
32
  function toUiMessage(message: AnalysisMessage): UiMessage {
33
+ const failed = message.status === "failed";
34
  return {
35
  id: message.id,
36
  role: message.role,
37
  content: message.content,
38
  created_at: message.created_at,
39
+ status: failed ? "error" : "complete",
40
+ statusText: failed ? message.note : undefined,
41
  messageId: message.message_id ?? undefined,
42
+ persistStatus: message.status,
43
+ persistNote: message.note,
44
  };
45
  }
46
 
 
158
  message.role === "ai" &&
159
  message.status === "complete" &&
160
  Boolean(message.messageId) &&
161
+ message.messageId !== RESERVED_FAILED_MESSAGE_ID &&
162
  !message.traceability &&
163
  !message.traceabilityLoading &&
164
  !message.traceabilityError
 
235
  const saved = await createAnalysisMessage(activeAnalysis.id, { role: "ai", content: completedContent, message_id: doneMessageId });
236
  traceTargetId = saved.id;
237
  const savedUi = toUiMessage(saved);
238
+ const persistFailed = saved.status === "failed";
239
+ resolvedMessageId = persistFailed ? undefined : savedUi.messageId ?? doneMessageId;
240
  setMessages((prev) =>
241
  prev.map((msg) =>
242
  msg.id === aiId
243
+ ? { ...savedUi, sources: msg.sources, traceabilityLoading: Boolean(resolvedMessageId) }
244
  : msg
245
  )
246
  );
247
+ if (persistFailed) {
248
+ toast.error(saved.note || "Failed to save AI response");
249
+ return;
250
+ }
251
  }
252
 
253
  await fetchTraceability(activeAnalysis.id, resolvedMessageId, traceTargetId);
src/app/components/analysis/MarkdownContent.tsx CHANGED
@@ -26,8 +26,8 @@ function normalizeReadableMarkdown(content: string) {
26
  return repairMarkdownMarkers(line)
27
  .replace(/([.!?])\s*-\s*(?=[A-Z0-9*])/g, "$1\n\n- ")
28
  .replace(/(^|\n)-(?=\*\*|[A-Z0-9])/g, "$1- ")
29
- .replace(/([,;:])(?=\S)/g, "$1 ")
30
- .replace(/([.!?])(?=[A-Z0-9])/g, "$1 ")
31
  .replace(/[ \t]{2,}/g, " ");
32
  });
33
 
@@ -79,7 +79,7 @@ export function MarkdownContent({ content }: { content: string }) {
79
  const readableContent = normalizeReadableMarkdown(content);
80
 
81
  return (
82
- <div className="agent-markdown max-w-none break-words text-sm leading-7 text-slate-800 [overflow-wrap:anywhere]">
83
  <ReactMarkdown remarkPlugins={[remarkGfm, remarkMath]} rehypePlugins={[rehypeKatex]} components={markdownComponents}>
84
  {readableContent}
85
  </ReactMarkdown>
 
26
  return repairMarkdownMarkers(line)
27
  .replace(/([.!?])\s*-\s*(?=[A-Z0-9*])/g, "$1\n\n- ")
28
  .replace(/(^|\n)-(?=\*\*|[A-Z0-9])/g, "$1- ")
29
+ .replace(/(?<!\d)([,;:])(?=\S)/g, "$1 ")
30
+ .replace(/(?<!\d)([.!?])(?=[A-Z])/g, "$1 ")
31
  .replace(/[ \t]{2,}/g, " ");
32
  });
33
 
 
79
  const readableContent = normalizeReadableMarkdown(content);
80
 
81
  return (
82
+ <div className="agent-markdown max-w-none text-sm leading-7 text-slate-800 [overflow-wrap:anywhere]">
83
  <ReactMarkdown remarkPlugins={[remarkGfm, remarkMath]} rehypePlugins={[rehypeKatex]} components={markdownComponents}>
84
  {readableContent}
85
  </ReactMarkdown>
src/app/components/analysis/ReportSidebar.tsx CHANGED
@@ -72,7 +72,7 @@ export function ReportSidebar({ analysis, userId, onCollapse }: ReportSidebarPro
72
  }
73
  };
74
  loadDetail();
75
- }, [analysis?.id, selectedVersion]);
76
 
77
  const handleGenerate = async () => {
78
  if (!analysis?.id || !userId) return;
 
72
  }
73
  };
74
  loadDetail();
75
+ }, [selectedVersion]);
76
 
77
  const handleGenerate = async () => {
78
  if (!analysis?.id || !userId) return;
src/app/components/analysis/types.ts CHANGED
@@ -12,6 +12,8 @@ export interface UiMessage {
12
  statusText?: string;
13
  sources?: unknown[];
14
  messageId?: string;
 
 
15
  traceability?: Observability | null;
16
  traceabilityLoading?: boolean;
17
  traceabilityError?: string | null;
 
12
  statusText?: string;
13
  sources?: unknown[];
14
  messageId?: string;
15
+ persistStatus?: "success" | "failed";
16
+ persistNote?: string;
17
  traceability?: Observability | null;
18
  traceabilityLoading?: boolean;
19
  traceabilityError?: string | null;
src/services/orchestrationApi.ts CHANGED
@@ -185,9 +185,13 @@ export interface AnalysisMessage {
185
  role: "user" | "ai";
186
  content: string;
187
  message_id?: string | null;
 
 
188
  created_at: string;
189
  }
190
 
 
 
191
  export interface CreateAnalysisPayload {
192
  analysis_title: string;
193
  objective: string;
@@ -250,25 +254,35 @@ async function parseError(res: Response): Promise<ApiError> {
250
  return new ApiError(message, res.status, body);
251
  }
252
 
 
 
253
  async function refreshWithStoredSession(): Promise<UserSession | null> {
254
- const current = readStoredSession();
255
- if (!current?.refresh_token) return null;
256
 
257
- const res = await fetch(`${ORCHESTRATION_BASE_URL}/api/refresh`, {
258
- method: "POST",
259
- headers: { "Content-Type": "application/json" },
260
- body: JSON.stringify({ refresh_token: current.refresh_token }),
261
- });
262
 
263
- if (!res.ok) {
264
- clearStoredSession();
265
- return null;
266
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
267
 
268
- const envelope = (await res.json()) as RefreshResponse;
269
- const next = sessionFromAuthData(envelope.data, current);
270
- writeStoredSession(next);
271
- return next;
272
  }
273
 
274
  async function orchestrationRequest<T>(path: string, options: RequestInit = {}, retry = true): Promise<T> {
 
185
  role: "user" | "ai";
186
  content: string;
187
  message_id?: string | null;
188
+ status?: "success" | "failed";
189
+ note?: string;
190
  created_at: string;
191
  }
192
 
193
+ export const RESERVED_FAILED_MESSAGE_ID = "00000000-0000-0000-0000-000000000000";
194
+
195
  export interface CreateAnalysisPayload {
196
  analysis_title: string;
197
  objective: string;
 
254
  return new ApiError(message, res.status, body);
255
  }
256
 
257
+ let _refreshPromise: Promise<UserSession | null> | null = null;
258
+
259
  async function refreshWithStoredSession(): Promise<UserSession | null> {
260
+ if (_refreshPromise) return _refreshPromise;
 
261
 
262
+ _refreshPromise = (async () => {
263
+ const current = readStoredSession();
264
+ if (!current?.refresh_token) return null;
 
 
265
 
266
+ const res = await fetch(`${ORCHESTRATION_BASE_URL}/api/refresh`, {
267
+ method: "POST",
268
+ headers: { "Content-Type": "application/json" },
269
+ body: JSON.stringify({ refresh_token: current.refresh_token }),
270
+ });
271
+
272
+ if (!res.ok) {
273
+ clearStoredSession();
274
+ return null;
275
+ }
276
+
277
+ const envelope = (await res.json()) as RefreshResponse;
278
+ const next = sessionFromAuthData(envelope.data, current);
279
+ writeStoredSession(next);
280
+ return next;
281
+ })().finally(() => {
282
+ _refreshPromise = null;
283
+ });
284
 
285
+ return _refreshPromise;
 
 
 
286
  }
287
 
288
  async function orchestrationRequest<T>(path: string, options: RequestInit = {}, retry = true): Promise<T> {