Skip to content

Commit 922e7eb

Browse files
authored
feat: Kaapi Agentic Onboarding Chatbot Flow (#1300)
Co-authored-by: Ayush <80516839+Ayush8923@users.noreply.github.com>
1 parent baf0ff2 commit 922e7eb

67 files changed

Lines changed: 8658 additions & 24 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,315 @@
1+
"""add chatbot, flow version, session and message tables + CHATBOT feature flag key
2+
3+
Revision ID: 088
4+
Revises: 087
5+
Create Date: 2026-10-20 00:00:00.000000
6+
"""
7+
8+
import sqlalchemy as sa
9+
import sqlmodel.sql.sqltypes
10+
from alembic import op
11+
from sqlalchemy.dialects import postgresql
12+
13+
revision = "088"
14+
down_revision = "087"
15+
branch_labels = None
16+
depends_on = None
17+
18+
19+
def upgrade():
20+
op.create_table(
21+
"chatbot",
22+
sa.Column(
23+
"id",
24+
sa.Uuid(),
25+
nullable=False,
26+
comment="Chatbot identifier used in API paths",
27+
),
28+
sa.Column(
29+
"name",
30+
sqlmodel.sql.sqltypes.AutoString(length=255),
31+
nullable=False,
32+
comment="Display name of the chatbot shown in the UI",
33+
),
34+
sa.Column(
35+
"organization_id",
36+
sa.Integer(),
37+
nullable=False,
38+
comment="Organization that owns the chatbot",
39+
),
40+
sa.Column(
41+
"project_id",
42+
sa.Integer(),
43+
nullable=False,
44+
comment="Project that owns the chatbot",
45+
),
46+
sa.Column(
47+
"inserted_at",
48+
sa.DateTime(),
49+
nullable=False,
50+
comment="Timestamp when the chatbot was created",
51+
),
52+
sa.Column(
53+
"updated_at",
54+
sa.DateTime(),
55+
nullable=False,
56+
comment="Timestamp when the chatbot was last updated",
57+
),
58+
sa.Column(
59+
"deleted_at",
60+
sa.DateTime(),
61+
nullable=True,
62+
comment="Soft-delete timestamp; the row is kept because sessions reference it",
63+
),
64+
sa.ForeignKeyConstraint(
65+
["organization_id"], ["organization.id"], ondelete="CASCADE"
66+
),
67+
sa.ForeignKeyConstraint(["project_id"], ["project.id"], ondelete="CASCADE"),
68+
sa.PrimaryKeyConstraint("id"),
69+
)
70+
op.create_index(
71+
op.f("ix_chatbot_organization_id"),
72+
"chatbot",
73+
["organization_id"],
74+
unique=False,
75+
)
76+
op.create_index(
77+
op.f("ix_chatbot_project_id"), "chatbot", ["project_id"], unique=False
78+
)
79+
80+
op.create_table(
81+
"chatbot_flow_version",
82+
sa.Column(
83+
"id",
84+
sa.Uuid(),
85+
nullable=False,
86+
comment="Flow version identifier pinned by sessions",
87+
),
88+
sa.Column(
89+
"chatbot_id",
90+
sa.Uuid(),
91+
nullable=False,
92+
comment="Chatbot this flow version belongs to",
93+
),
94+
sa.Column(
95+
"version",
96+
sa.Integer(),
97+
nullable=False,
98+
comment="Sequential version number per chatbot, starting at 1",
99+
),
100+
sa.Column(
101+
"nodes",
102+
postgresql.JSONB(astext_type=sa.Text()),
103+
nullable=False,
104+
comment="React Flow nodes as saved by the UI: [{id, type (input/output/coordinator/onboarding/rag), position {x, y}, data}]; agent node data carries config_id, config_version, prompt",
105+
),
106+
sa.Column(
107+
"edges",
108+
postgresql.JSONB(astext_type=sa.Text()),
109+
nullable=False,
110+
server_default=sa.text("'[]'::jsonb"),
111+
comment="React Flow edges as saved by the UI: [{id, source, target, sourceHandle, targetHandle, data}]",
112+
),
113+
sa.Column(
114+
"is_live",
115+
sa.Boolean(),
116+
nullable=False,
117+
server_default=sa.text("false"),
118+
comment="True for the single published version new sessions run on; live versions are immutable, the highest non-live version is the draft",
119+
),
120+
sa.Column(
121+
"inserted_at",
122+
sa.DateTime(),
123+
nullable=False,
124+
comment="Timestamp when the version was created",
125+
),
126+
sa.Column(
127+
"updated_at",
128+
sa.DateTime(),
129+
nullable=False,
130+
comment="Timestamp when the draft was last saved or the version was published/unpublished",
131+
),
132+
sa.ForeignKeyConstraint(["chatbot_id"], ["chatbot.id"], ondelete="CASCADE"),
133+
sa.PrimaryKeyConstraint("id"),
134+
sa.UniqueConstraint(
135+
"chatbot_id",
136+
"version",
137+
name="uq_chatbot_flow_version_chatbot_version",
138+
),
139+
)
140+
op.create_index(
141+
op.f("ix_chatbot_flow_version_chatbot_id"),
142+
"chatbot_flow_version",
143+
["chatbot_id"],
144+
unique=False,
145+
)
146+
op.create_index(
147+
"uq_chatbot_flow_version_live_chatbot",
148+
"chatbot_flow_version",
149+
["chatbot_id"],
150+
unique=True,
151+
postgresql_where=sa.text("is_live"),
152+
)
153+
154+
op.create_table(
155+
"chatbot_session",
156+
sa.Column(
157+
"id",
158+
sa.Uuid(),
159+
nullable=False,
160+
comment="Session identifier returned to the caller",
161+
),
162+
sa.Column(
163+
"chatbot_id",
164+
sa.Uuid(),
165+
nullable=False,
166+
comment="Chatbot this session runs; tenant scope comes from the chatbot",
167+
),
168+
sa.Column(
169+
"flow_version_id",
170+
sa.Uuid(),
171+
nullable=False,
172+
comment="Live flow version pinned at creation, so publishing never changes a running conversation",
173+
),
174+
sa.Column(
175+
"contact_ref",
176+
sqlmodel.sql.sqltypes.AutoString(length=500),
177+
nullable=False,
178+
comment="Caller-side identifier of the end user (e.g. a Glific contact id); opaque to Kaapi",
179+
),
180+
sa.Column(
181+
"active_node_id",
182+
sqlmodel.sql.sqltypes.AutoString(length=255),
183+
nullable=False,
184+
comment="React Flow node id inside the pinned flow version that handles the next turn",
185+
),
186+
sa.Column(
187+
"status",
188+
sa.Enum(
189+
"active",
190+
"inactive",
191+
name="chatbotsessionstatusenum",
192+
),
193+
nullable=False,
194+
comment="Session in use: active, or inactive once superseded by a newer session for the same chatbot + contact_ref or the chatbot was deleted",
195+
),
196+
sa.Column(
197+
"completion_status",
198+
sa.Enum(
199+
"in_progress",
200+
"complete",
201+
"abandoned",
202+
name="chatbotcompletionstatusenum",
203+
),
204+
nullable=False,
205+
server_default="in_progress",
206+
comment="Field collection progress: in_progress, complete once every required field is gathered, or abandoned when deactivated while in_progress",
207+
),
208+
sa.Column(
209+
"collected_fields",
210+
postgresql.JSON(astext_type=sa.Text()),
211+
nullable=False,
212+
comment="Flat map of field name -> value gathered from the user so far",
213+
),
214+
sa.Column(
215+
"inserted_at",
216+
sa.DateTime(),
217+
nullable=False,
218+
comment="Timestamp when the session was created",
219+
),
220+
sa.Column(
221+
"updated_at",
222+
sa.DateTime(),
223+
nullable=False,
224+
comment="Timestamp when the session was last updated",
225+
),
226+
sa.ForeignKeyConstraint(["chatbot_id"], ["chatbot.id"], ondelete="RESTRICT"),
227+
sa.ForeignKeyConstraint(
228+
["flow_version_id"], ["chatbot_flow_version.id"], ondelete="RESTRICT"
229+
),
230+
sa.PrimaryKeyConstraint("id"),
231+
)
232+
op.create_index(
233+
op.f("ix_chatbot_session_flow_version_id"),
234+
"chatbot_session",
235+
["flow_version_id"],
236+
unique=False,
237+
)
238+
op.create_index(
239+
op.f("ix_chatbot_session_contact_ref"),
240+
"chatbot_session",
241+
["contact_ref"],
242+
unique=False,
243+
)
244+
op.create_index(
245+
"ix_chatbot_session_chatbot_contact",
246+
"chatbot_session",
247+
["chatbot_id", "contact_ref"],
248+
unique=False,
249+
)
250+
op.create_index(
251+
"uq_chatbot_session_active_chatbot_contact",
252+
"chatbot_session",
253+
["chatbot_id", "contact_ref"],
254+
unique=True,
255+
postgresql_where=sa.text("status = 'active'"),
256+
)
257+
258+
op.create_table(
259+
"chatbot_session_message",
260+
sa.Column("id", sa.Uuid(), nullable=False, comment="Unique identifier"),
261+
sa.Column(
262+
"session_id",
263+
sa.Uuid(),
264+
nullable=False,
265+
comment="Reference to the owning chatbot session",
266+
),
267+
sa.Column(
268+
"role",
269+
sa.Enum("system", "user", "assistant", name="chatbotmessageroleenum"),
270+
nullable=False,
271+
comment="Speaker: user or assistant (system is reserved; the prompt lives on the flow node)",
272+
),
273+
sa.Column(
274+
"content",
275+
sqlmodel.sql.sqltypes.AutoString(),
276+
nullable=False,
277+
comment="Message text",
278+
),
279+
sa.Column(
280+
"node_id",
281+
sqlmodel.sql.sqltypes.AutoString(length=255),
282+
nullable=True,
283+
comment="React Flow node id of the pinned flow that was active for this turn",
284+
),
285+
sa.Column(
286+
"inserted_at",
287+
sa.DateTime(),
288+
nullable=False,
289+
comment="Timestamp when the message was stored; defines transcript order",
290+
),
291+
sa.ForeignKeyConstraint(
292+
["session_id"], ["chatbot_session.id"], ondelete="CASCADE"
293+
),
294+
sa.PrimaryKeyConstraint("id"),
295+
)
296+
op.create_index(
297+
op.f("ix_chatbot_session_message_session_id"),
298+
"chatbot_session_message",
299+
["session_id"],
300+
unique=False,
301+
)
302+
303+
# ADD VALUE cannot run inside the migration's transaction on all PG versions.
304+
with op.get_context().autocommit_block():
305+
op.execute("ALTER TYPE featureflagkey ADD VALUE IF NOT EXISTS 'CHATBOT'")
306+
307+
308+
def downgrade():
309+
op.drop_table("chatbot_session_message")
310+
op.drop_table("chatbot_session")
311+
op.drop_table("chatbot_flow_version")
312+
op.drop_table("chatbot")
313+
sa.Enum(name="chatbotmessageroleenum").drop(op.get_bind())
314+
sa.Enum(name="chatbotcompletionstatusenum").drop(op.get_bind())
315+
sa.Enum(name="chatbotsessionstatusenum").drop(op.get_bind())
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Create a chatbot in the caller's project and return it (`201`).
2+
3+
Only `name` is required (1–255 characters; display only). A new chatbot has no flow: its first `PUT /chatbots/{chatbot_id}/flow` goes live at once, after which sessions can start. Later saves are drafts that go live with `POST /chatbots/{chatbot_id}/flow/publish`.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Start a conversation between a chatbot and an end user and return `{session_id, chatbot_id, flow_version, active_node_id}` (`201`).
2+
3+
The session is pinned to the chatbot's live flow version and starts on its entry node (the agent node the `input` node leads to, or the flow's only agent node). Every turn runs that pinned version, so publishing a newer flow applies to new sessions only. `contact_ref` is the caller's own identifier for the end user and is stored as-is.
4+
5+
A contact has at most one active session per chatbot. Creating a session marks the contact's previous `active` sessions of this chatbot as `inactive`, in the same transaction; those still `in_progress` become `abandoned`, while `complete` ones stay `complete`. Sessions of other chatbots or other contacts are untouched. Returns 404 if the chatbot doesn't exist in this project or was deleted, 409 if the chatbot has no published flow (publish the flow first) or another session for the same contact was created concurrently (retry).
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Soft-delete a chatbot; returns `{"message": "Chatbot deleted successfully"}`. It disappears from list/get and its flow can no longer be edited, published, or used for new sessions.
2+
3+
In the same transaction every `active` session of the chatbot becomes `inactive` (those still `in_progress` become `abandoned`; `complete` ones stay `complete`), so further messages to them return 409. Stored sessions and transcripts are kept. Returns 404 if the chatbot doesn't exist in this project or was already deleted.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Get one chatbot of the caller's project by `chatbot_id`.
2+
3+
Returns 404 if the chatbot doesn't exist in this project or was deleted. The chatbot's flow is fetched separately with `GET /chatbots/{chatbot_id}/flow`.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Get a version of the chatbot's React Flow graph: `{id, chatbot_id, version, is_live, nodes, edges, inserted_at, updated_at}`.
2+
3+
With `?version=N` that version is returned. Without it, the draft is returned if one exists (the highest version when it is not live), otherwise the live version. `nodes` and `edges` come back exactly as saved, with React Flow keys (`sourceHandle`, `targetHandle`, and UI-only keys such as `measured`). Agent nodes of a published version carry the pinned `data.config_version`.
4+
5+
Returns 404 if the chatbot doesn't exist in this project, has no flow yet, or has no such version.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Get the most recently created chatbot session for `contact_ref` in the caller's project, optionally narrowed to one chatbot with `chatbot_id`.
2+
3+
`contact_ref` is required and matched exactly. Omit `chatbot_id` to get the contact's newest session across the project's chatbots — the response's `chatbot_id` and `chatbot_name` say which one. Sessions of deleted chatbots are not returned.
4+
5+
The response carries the pinned `flow_version`, the `active_node_id` and that node's `prompt`, the session `status` (`active`, or `inactive` once a newer session for the same contact and chatbot was created or the chatbot was deleted), its `completion_status` (`in_progress` until every field the prompt asks for is collected, then `complete`; `abandoned` if it was deactivated while still `in_progress`), and the flat `collected_fields` map (`{field_name: value}`) gathered so far. Returns 404 when no matching session exists in this project.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
List the caller's project's chatbots, most recently updated first. Deleted chatbots are excluded.
2+
3+
Paginate with `skip` (default 0) and `limit` (1–100, default 100).
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Publish the chatbot's draft so new sessions run on it. Returns the now-live version.
2+
3+
The draft is validated strictly: node ids are unique; at most one `input` and one `output` node; every edge's `source` and `target` is a node in the flow; every agent node has `data.config_id` and a non-empty `data.prompt`, and its config resolves to an Anthropic text config in this project with a model set (and a valid thinking budget). The project must have Anthropic credentials configured, since agent nodes call Anthropic with the project's own key. Only `onboarding` agent nodes can run for now — `coordinator` and `rag` nodes are rejected as not supported yet. The flow needs an entry node: with no edges it must contain exactly one agent node; otherwise the `input` node must have exactly one outgoing edge, leading to an agent node. A node without `data.config_version` gets the config's latest version written into the stored node, so later config edits don't change the published flow.
4+
5+
The previous live version (if any) stops being live in the same transaction. Sessions already running stay pinned to the version they started on. Returns 404 for an unknown chatbot, 409 if there is no draft to publish (save the flow first) or on a concurrent change, and 422 listing every validation problem.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
Save the chatbot's flow: `{nodes, edges}` in React Flow shape. Returns the saved version.
2+
3+
The **first** save of a chatbot creates version 1 and makes it live immediately, so the chatbot can run without a separate publish. Because it goes live, it gets the same strict validation as publish (see the publish endpoint) and its missing `config_version`s are pinned to the latest; an invalid first flow returns 422 and nothing is saved.
4+
5+
Every later save is a draft that needs a publish to go live: if the highest version is not live it is overwritten, otherwise a new version `max + 1` is created with `is_live: false`. Live versions are never modified, so editing never affects running conversations.
6+
7+
Draft saves check only the shape, so half-built flows save: each node needs `id` (1–255 chars), `type` (`input`, `output`, `coordinator`, `onboarding`, `rag`), `position {x, y}` and an object `data`; agent nodes (`coordinator`, `onboarding`, `rag`) may carry `data.config_id` (UUID), `data.config_version` (≥ 1) and `data.prompt`. Each edge needs `id`, `source`, `target`; `sourceHandle`, `targetHandle` and `data` (e.g. `intent`, `description`) are optional. Extra keys are stored and returned as sent. Returns 404 for an unknown chatbot, 409 if the flow was changed concurrently (reload and retry), and 422 for an invalid first save.

0 commit comments

Comments
 (0)