/fix v2 chat stream api update to analyses_messages

#7
by rhbt6767 - opened
src/agents/chat_handler.py CHANGED
@@ -215,13 +215,13 @@ class ChatHandler:
215
  from .gate import stub_analysis_state
216
 
217
  if not analysis_id:
218
- return stub_analysis_state(problem_validated=False)
219
  try:
220
  state = await self._get_state_store().get(analysis_id)
221
  except Exception as e:
222
  logger.warning("help state read failed — not-validated", error=str(e))
223
  state = None
224
- return state if state is not None else stub_analysis_state(problem_validated=False)
225
 
226
  # ------------------------------------------------------------------
227
  # Public entry
@@ -320,7 +320,7 @@ class ChatHandler:
320
  # intent,
321
  # analysis_state
322
  # if analysis_state is not None
323
- # else stub_analysis_state(problem_validated=False),
324
  # )
325
 
326
  # The `intent` event is consumed by the endpoint (it gates response caching
 
215
  from .gate import stub_analysis_state
216
 
217
  if not analysis_id:
218
+ return stub_analysis_state()
219
  try:
220
  state = await self._get_state_store().get(analysis_id)
221
  except Exception as e:
222
  logger.warning("help state read failed — not-validated", error=str(e))
223
  state = None
224
+ return state if state is not None else stub_analysis_state()
225
 
226
  # ------------------------------------------------------------------
227
  # Public entry
 
320
  # intent,
321
  # analysis_state
322
  # if analysis_state is not None
323
+ # else stub_analysis_state(),
324
  # )
325
 
326
  # The `intent` event is consumed by the endpoint (it gates response caching
src/agents/gate.py CHANGED
@@ -20,7 +20,7 @@ from __future__ import annotations
20
 
21
  from datetime import UTC, datetime
22
 
23
- from pydantic import BaseModel
24
 
25
  from src.agents.orchestration import Intent
26
  from src.middlewares.logging import get_logger
@@ -29,17 +29,18 @@ logger = get_logger("gate")
29
 
30
 
31
  class AnalysisState(BaseModel):
32
- """Per-analysis state the gate + Help skill read every turn (locked contract).
33
 
34
- `problem_validated` is the gate driver; `report_id` is null until a report
35
- exists. Field names mirror the `analysis_states` table so the DB read swaps in
36
- without touching readers.
 
37
  """
38
 
39
  id: str
40
  analysis_title: str
41
- problem_statement: str
42
- problem_validated: bool = False
43
  user_id: str
44
  report_id: str | None = None
45
  created_at: datetime
@@ -66,18 +67,18 @@ def gate(intent: Intent, state: AnalysisState) -> Intent:
66
  return intent
67
 
68
 
69
- def stub_analysis_state(*, problem_validated: bool = False) -> AnalysisState:
70
- """Hardcoded Analysis State for building/testing before the DB lands (#9).
71
 
72
- Shared fixture so the gate, the Help skill, and tests all exercise the same
73
- shape. `problem_validated=True` simulates a passed interview.
74
  """
75
  now = datetime.now(UTC)
76
  return AnalysisState(
77
  id="stub-analysis",
78
  analysis_title="Stub analysis",
79
- problem_statement="Stub problem statement" if problem_validated else "",
80
- problem_validated=problem_validated,
81
  user_id="stub-user",
82
  report_id=None,
83
  created_at=now,
@@ -104,8 +105,8 @@ async def get_analysis_state(analysis_id: str) -> AnalysisState:
104
  analysis_id=analysis_id,
105
  error=str(exc),
106
  )
107
- return stub_analysis_state(problem_validated=False)
108
  if state is None:
109
  logger.debug("analysis_state missing — default not-validated", analysis_id=analysis_id)
110
- return stub_analysis_state(problem_validated=False)
111
  return state
 
20
 
21
  from datetime import UTC, datetime
22
 
23
+ from pydantic import BaseModel, Field
24
 
25
  from src.agents.orchestration import Intent
26
  from src.middlewares.logging import get_logger
 
29
 
30
 
31
  class AnalysisState(BaseModel):
32
+ """Per-analysis state the Help skill + report layer read every turn.
33
 
34
+ Field names mirror the dedorch `analyses` table so the DB read swaps in without
35
+ touching readers. The goal is the user-entered `objective` + `business_questions`
36
+ (set at onboarding by Go); the old `problem_statement`/`problem_validated` gate fields
37
+ were dropped (dedorch #3 / KM-652). `report_id` is null until a report exists.
38
  """
39
 
40
  id: str
41
  analysis_title: str
42
+ objective: str = ""
43
+ business_questions: list[str] = Field(default_factory=list)
44
  user_id: str
45
  report_id: str | None = None
46
  created_at: datetime
 
67
  return intent
68
 
69
 
70
+ def stub_analysis_state() -> AnalysisState:
71
+ """Hardcoded Analysis State for never-throw fallbacks / tests.
72
 
73
+ Shared fixture so the gate seam, the Help skill, and tests all exercise the same
74
+ shape when a real row is missing or a read fails.
75
  """
76
  now = datetime.now(UTC)
77
  return AnalysisState(
78
  id="stub-analysis",
79
  analysis_title="Stub analysis",
80
+ objective="",
81
+ business_questions=[],
82
  user_id="stub-user",
83
  report_id=None,
84
  created_at=now,
 
105
  analysis_id=analysis_id,
106
  error=str(exc),
107
  )
108
+ return stub_analysis_state()
109
  if state is None:
110
  logger.debug("analysis_state missing — default not-validated", analysis_id=analysis_id)
111
+ return stub_analysis_state()
112
  return state
src/agents/state_store.py CHANGED
@@ -25,8 +25,8 @@ def _row_to_state(row: AnalysisStateRow) -> AnalysisState:
25
  return AnalysisState(
26
  id=row.id,
27
  analysis_title=row.analysis_title,
28
- problem_statement=row.problem_statement,
29
- problem_validated=row.problem_validated,
30
  user_id=row.user_id,
31
  report_id=row.report_id,
32
  created_at=row.created_at,
@@ -58,14 +58,17 @@ class AnalysisStateStore:
58
  no source bindings — binding scoping fail-opens to the whole catalog.
59
  """
60
  async with AsyncSessionLocal() as session:
 
 
 
61
  stmt = (
62
  insert(AnalysisStateRow)
63
  .values(
64
  id=analysis_id,
65
  user_id=user_id,
66
  analysis_title=analysis_title,
67
- problem_statement="",
68
- problem_validated=False,
69
  )
70
  .on_conflict_do_nothing(index_elements=[AnalysisStateRow.id])
71
  )
@@ -80,7 +83,8 @@ class AnalysisStateStore:
80
  analysis_id: str,
81
  user_id: str,
82
  analysis_title: str = "New analysis",
83
- problem_statement: str = "",
 
84
  ) -> AnalysisState:
85
  """Create the state row for a new analysis (id shared with its chat room)."""
86
  async with AsyncSessionLocal() as session:
@@ -88,8 +92,8 @@ class AnalysisStateStore:
88
  id=analysis_id,
89
  user_id=user_id,
90
  analysis_title=analysis_title,
91
- problem_statement=problem_statement,
92
- problem_validated=False,
93
  )
94
  session.add(row)
95
  await session.commit()
@@ -100,14 +104,13 @@ class AnalysisStateStore:
100
  self,
101
  analysis_id: str,
102
  *,
103
- problem_statement: str | None = None,
104
- problem_validated: bool | None = None,
105
  report_id: str | None = None,
106
  ) -> AnalysisState | None:
107
  """Patch the given fields (only non-None args are written). Returns the row.
108
 
109
- Used by the Problem Statement skill (`problem_validated`) and the report
110
- flow (`report_id`). Returns None if the analysis doesn't exist.
 
111
  """
112
  async with AsyncSessionLocal() as session:
113
  row = await session.get(AnalysisStateRow, analysis_id)
@@ -117,10 +120,6 @@ class AnalysisStateStore:
117
  analysis_id=analysis_id,
118
  )
119
  return None
120
- if problem_statement is not None:
121
- row.problem_statement = problem_statement
122
- if problem_validated is not None:
123
- row.problem_validated = problem_validated
124
  if report_id is not None:
125
  row.report_id = report_id
126
  await session.commit()
 
25
  return AnalysisState(
26
  id=row.id,
27
  analysis_title=row.analysis_title,
28
+ objective=row.objective or "",
29
+ business_questions=list(row.business_questions or []),
30
  user_id=row.user_id,
31
  report_id=row.report_id,
32
  created_at=row.created_at,
 
58
  no source bindings — binding scoping fail-opens to the whole catalog.
59
  """
60
  async with AsyncSessionLocal() as session:
61
+ # Bare get-or-create row. objective/business_questions are always supplied
62
+ # (empty) so the INSERT satisfies dedorch whether or not they are NOT NULL;
63
+ # the real goal is set at onboarding by Go and preserved by ON CONFLICT DO NOTHING.
64
  stmt = (
65
  insert(AnalysisStateRow)
66
  .values(
67
  id=analysis_id,
68
  user_id=user_id,
69
  analysis_title=analysis_title,
70
+ objective="",
71
+ business_questions=[],
72
  )
73
  .on_conflict_do_nothing(index_elements=[AnalysisStateRow.id])
74
  )
 
83
  analysis_id: str,
84
  user_id: str,
85
  analysis_title: str = "New analysis",
86
+ objective: str = "",
87
+ business_questions: list[str] | None = None,
88
  ) -> AnalysisState:
89
  """Create the state row for a new analysis (id shared with its chat room)."""
90
  async with AsyncSessionLocal() as session:
 
92
  id=analysis_id,
93
  user_id=user_id,
94
  analysis_title=analysis_title,
95
+ objective=objective,
96
+ business_questions=business_questions or [],
97
  )
98
  session.add(row)
99
  await session.commit()
 
104
  self,
105
  analysis_id: str,
106
  *,
 
 
107
  report_id: str | None = None,
108
  ) -> AnalysisState | None:
109
  """Patch the given fields (only non-None args are written). Returns the row.
110
 
111
+ Used by the report flow (`report_id` write-back). The analysis goal
112
+ (`objective`/`business_questions`) is set at onboarding by Go, not patched here.
113
+ Returns None if the analysis doesn't exist.
114
  """
115
  async with AsyncSessionLocal() as session:
116
  row = await session.get(AnalysisStateRow, analysis_id)
 
120
  analysis_id=analysis_id,
121
  )
122
  return None
 
 
 
 
123
  if report_id is not None:
124
  row.report_id = report_id
125
  await session.commit()
src/api/v1/chat.py CHANGED
@@ -14,7 +14,7 @@ from sse_starlette.sse import EventSourceResponse
14
  from src.agents.chat_handler import ChatHandler
15
  from src.config.settings import settings
16
  from src.db.postgres.connection import get_db
17
- from src.db.postgres.models import ChatMessage, MessageSource
18
  from src.db.redis.connection import get_redis
19
  from src.middlewares.logging import get_logger, log_execution
20
 
@@ -100,12 +100,16 @@ async def cache_response(redis, cache_key: str, response: str, sources: list):
100
  )
101
 
102
 
103
- async def load_history(db: AsyncSession, room_id: str, limit: int = 10) -> list:
104
- """Load recent chat messages for a room as LangChain message objects (oldest-first)."""
 
 
 
 
105
  result = await db.execute(
106
- select(ChatMessage)
107
- .where(ChatMessage.room_id == room_id)
108
- .order_by(ChatMessage.created_at.asc())
109
  .limit(limit)
110
  )
111
  rows = result.scalars().all()
@@ -117,24 +121,25 @@ async def load_history(db: AsyncSession, room_id: str, limit: int = 10) -> list:
117
 
118
  async def save_messages(
119
  db: AsyncSession,
120
- room_id: str,
 
121
  user_content: str,
122
  assistant_content: str,
123
- sources: Optional[List[Dict[str, Any]]] = None,
124
  ):
125
- """Persist user and assistant messages, and attach sources to the assistant message."""
126
- db.add(ChatMessage(id=str(uuid.uuid4()), room_id=room_id, role="user", content=user_content))
127
- assistant_id = str(uuid.uuid4())
128
- db.add(ChatMessage(id=assistant_id, room_id=room_id, role="assistant", content=assistant_content))
129
- for src in (sources or []):
130
- page = src.get("page_label")
131
- db.add(MessageSource(
132
- id=str(uuid.uuid4()),
133
- message_id=assistant_id,
134
- document_id=src.get("document_id"),
135
- filename=src.get("filename"),
136
- page_label=str(page) if page is not None else None,
137
- ))
 
138
  await db.commit()
139
 
140
 
@@ -187,7 +192,7 @@ async def chat_stream(request: ChatRequest, db: AsyncSession = Depends(get_db)):
187
  logger.info("Returning cached response")
188
  cached_text = cached["response"]
189
  cached_sources = cached["sources"]
190
- await save_messages(db, request.room_id, request.message, cached_text, sources=cached_sources)
191
 
192
  async def stream_cached():
193
  yield {"event": "sources", "data": json.dumps(cached_sources)}
@@ -202,7 +207,7 @@ async def chat_stream(request: ChatRequest, db: AsyncSession = Depends(get_db)):
202
  direct = _fast_intent(request.message)
203
  if direct:
204
  await cache_response(redis, cache_key, direct, sources=[])
205
- await save_messages(db, request.room_id, request.message, direct, sources=[])
206
 
207
  async def stream_direct():
208
  yield {"event": "sources", "data": json.dumps([])}
@@ -244,7 +249,7 @@ async def chat_stream(request: ChatRequest, db: AsyncSession = Depends(get_db)):
244
  await cache_response(redis, cache_key, full_response, sources=sources)
245
  logger.info("saving messages", sources_count=len(sources), sources=sources)
246
  try:
247
- await save_messages(db, request.room_id, request.message, full_response, sources=sources)
248
  except Exception as e:
249
  logger.error("save_messages failed", room_id=request.room_id, error=str(e))
250
  yield event
 
14
  from src.agents.chat_handler import ChatHandler
15
  from src.config.settings import settings
16
  from src.db.postgres.connection import get_db
17
+ from src.db.postgres.models import AnalysesMessageRow
18
  from src.db.redis.connection import get_redis
19
  from src.middlewares.logging import get_logger, log_execution
20
 
 
100
  )
101
 
102
 
103
+ async def load_history(db: AsyncSession, analysis_id: str, limit: int = 10) -> list:
104
+ """Load recent conversation messages for an analysis as LangChain messages (oldest-first).
105
+
106
+ Reads the dedorch `analyses_messages` table (`role ∈ user|ai`), which replaced the
107
+ deprecated `rooms`/`chat_messages`.
108
+ """
109
  result = await db.execute(
110
+ select(AnalysesMessageRow)
111
+ .where(AnalysesMessageRow.analysis_id == analysis_id)
112
+ .order_by(AnalysesMessageRow.created_at.asc())
113
  .limit(limit)
114
  )
115
  rows = result.scalars().all()
 
121
 
122
  async def save_messages(
123
  db: AsyncSession,
124
+ analysis_id: str,
125
+ user_id: str,
126
  user_content: str,
127
  assistant_content: str,
 
128
  ):
129
+ """Persist the user turn + AI answer to dedorch `analyses_messages` (`role` user|ai).
130
+
131
+ Python writes this Go-owned table as a consumer (it does not create it). RAG source
132
+ citations are streamed to the client but not persisted — the old `message_sources`
133
+ table is deprecated along with `chat_messages`.
134
+ """
135
+ db.add(AnalysesMessageRow(
136
+ id=str(uuid.uuid4()), analysis_id=analysis_id, user_id=user_id,
137
+ role="user", content=user_content,
138
+ ))
139
+ db.add(AnalysesMessageRow(
140
+ id=str(uuid.uuid4()), analysis_id=analysis_id, user_id=user_id,
141
+ role="ai", content=assistant_content,
142
+ ))
143
  await db.commit()
144
 
145
 
 
192
  logger.info("Returning cached response")
193
  cached_text = cached["response"]
194
  cached_sources = cached["sources"]
195
+ await save_messages(db, request.room_id, request.user_id, request.message, cached_text)
196
 
197
  async def stream_cached():
198
  yield {"event": "sources", "data": json.dumps(cached_sources)}
 
207
  direct = _fast_intent(request.message)
208
  if direct:
209
  await cache_response(redis, cache_key, direct, sources=[])
210
+ await save_messages(db, request.room_id, request.user_id, request.message, direct)
211
 
212
  async def stream_direct():
213
  yield {"event": "sources", "data": json.dumps([])}
 
249
  await cache_response(redis, cache_key, full_response, sources=sources)
250
  logger.info("saving messages", sources_count=len(sources), sources=sources)
251
  try:
252
+ await save_messages(db, request.room_id, request.user_id, request.message, full_response)
253
  except Exception as e:
254
  logger.error("save_messages failed", room_id=request.room_id, error=str(e))
255
  yield event
src/api/v1/report.py CHANGED
@@ -128,7 +128,7 @@ async def generate_report(
128
 
129
  state = await _load_state(analysis_id)
130
  floor_missing, _ = await report_floor(
131
- analysis_id, state or stub_analysis_state(problem_validated=False)
132
  )
133
  if floor_missing:
134
  raise HTTPException(
 
128
 
129
  state = await _load_state(analysis_id)
130
  floor_missing, _ = await report_floor(
131
+ analysis_id, state or stub_analysis_state()
132
  )
133
  if floor_missing:
134
  raise HTTPException(
src/api/v2/chat.py CHANGED
@@ -88,7 +88,7 @@ async def chat_stream(request: ChatRequest, db: AsyncSession = Depends(get_db)):
88
  logger.info("Returning cached response")
89
  cached_text = cached["response"]
90
  cached_sources = cached["sources"]
91
- await save_messages(db, analysis_id, request.message, cached_text, sources=cached_sources)
92
 
93
  async def stream_cached():
94
  yield {"event": "sources", "data": json.dumps(cached_sources)}
@@ -103,7 +103,7 @@ async def chat_stream(request: ChatRequest, db: AsyncSession = Depends(get_db)):
103
  direct = _fast_intent(request.message)
104
  if direct:
105
  await cache_response(redis, cache_key, direct, sources=[])
106
- await save_messages(db, analysis_id, request.message, direct, sources=[])
107
 
108
  async def stream_direct():
109
  yield {"event": "sources", "data": json.dumps([])}
@@ -144,7 +144,7 @@ async def chat_stream(request: ChatRequest, db: AsyncSession = Depends(get_db)):
144
  await cache_response(redis, cache_key, full_response, sources=sources)
145
  try:
146
  await save_messages(
147
- db, analysis_id, request.message, full_response, sources=sources
148
  )
149
  except Exception as e:
150
  logger.error("save_messages failed", analysis_id=analysis_id, error=str(e))
 
88
  logger.info("Returning cached response")
89
  cached_text = cached["response"]
90
  cached_sources = cached["sources"]
91
+ await save_messages(db, analysis_id, request.user_id, request.message, cached_text)
92
 
93
  async def stream_cached():
94
  yield {"event": "sources", "data": json.dumps(cached_sources)}
 
103
  direct = _fast_intent(request.message)
104
  if direct:
105
  await cache_response(redis, cache_key, direct, sources=[])
106
+ await save_messages(db, analysis_id, request.user_id, request.message, direct)
107
 
108
  async def stream_direct():
109
  yield {"event": "sources", "data": json.dumps([])}
 
144
  await cache_response(redis, cache_key, full_response, sources=sources)
145
  try:
146
  await save_messages(
147
+ db, analysis_id, request.user_id, request.message, full_response
148
  )
149
  except Exception as e:
150
  logger.error("save_messages failed", analysis_id=analysis_id, error=str(e))
src/db/postgres/models.py CHANGED
@@ -3,7 +3,6 @@
3
  from uuid import uuid4
4
 
5
  from sqlalchemy import (
6
- Boolean,
7
  Column,
8
  DateTime,
9
  ForeignKey,
@@ -186,27 +185,23 @@ class AnalysisStateRow(Base):
186
  One session = one analysis = one conversation; `id` is the shared session id
187
  (canonical UUID). Verified against the dedorch DB 2026-06-25.
188
 
189
- dedorch `analyses` ACTUAL columns: `id` (uuid), `analysis_title`, `user_id` (text),
190
- `report_id` (uuid), `created_at`, `updated_at`, `problem_statement`,
191
- `problem_validated`, `status` (text 'active'|'inactive' — soft-delete),
192
- `data_bind` (jsonb), `data_bind_version` (int), `report_collection` (jsonb).
193
 
194
- Reconciled to that shape (#4, 2026-06-26): `user_id` (was `owner_id`) + `status`/`data_bind`/
195
- `data_bind_version`/`report_collection` added. dedorch still carries `problem_statement`/
196
- `problem_validated` and does NOT yet have `objective`/`business_questions` Harry's #3 drops
197
- the former + adds the latter; the report layer reads the goal getattr-tolerantly so that swap
198
- stays non-breaking. The new FE/Go columns are stored to match dedorch but NOT surfaced in the
199
- `AnalysisState` pydantic contract (no Python reader needs them yet).
200
-
201
- `analysis` (singular) is the deprecated DUPLICATE table Harry will drop — never use it.
202
- Class name kept as `AnalysisStateRow`.
203
  """
204
  __tablename__ = "analyses"
205
 
206
  id = Column(UUID(as_uuid=False), primary_key=True) # shared session id (uuid)
207
  analysis_title = Column(String, nullable=False, default="New analysis")
208
- problem_statement = Column(Text, nullable=False, default="")
209
- problem_validated = Column(Boolean, nullable=False, default=False)
210
  user_id = Column(String, nullable=False, index=True) # was owner_id (dedorch uses user_id)
211
  report_id = Column(UUID(as_uuid=False), nullable=True)
212
  # dedorch `analyses` columns (FE/Go concerns; carried so create_all matches dedorch).
@@ -240,3 +235,23 @@ class AnalysisDataSourceRow(Base):
240
  bound_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
241
  source_metadata = Column("metadata", JSONB, nullable=True)
242
  created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  from uuid import uuid4
4
 
5
  from sqlalchemy import (
 
6
  Column,
7
  DateTime,
8
  ForeignKey,
 
185
  One session = one analysis = one conversation; `id` is the shared session id
186
  (canonical UUID). Verified against the dedorch DB 2026-06-25.
187
 
188
+ dedorch `analyses` columns (reconciled 2026-07-01 — Harry's #3 landed): `id` (uuid),
189
+ `analysis_title`, `objective` (text), `business_questions` (jsonb), `user_id` (text),
190
+ `report_id` (uuid), `status` (text 'active'|'inactive' — soft-delete), `data_bind` (jsonb),
191
+ `data_bind_version` (int), `report_collection` (jsonb), `created_at`, `updated_at`.
192
 
193
+ `problem_statement`/`problem_validated` were DROPPED in dedorch (#3) and removed here;
194
+ `objective` + `business_questions` (the user-entered goal, set at onboarding by Go) replace
195
+ them. The FE/Go columns (`status`/`data_bind*`/`report_collection`) are carried to match
196
+ dedorch but are NOT surfaced in the `AnalysisState` pydantic contract. `analysis` (singular)
197
+ is the deprecated DUPLICATE table Harry will drop never use it. Class name kept.
 
 
 
 
198
  """
199
  __tablename__ = "analyses"
200
 
201
  id = Column(UUID(as_uuid=False), primary_key=True) # shared session id (uuid)
202
  analysis_title = Column(String, nullable=False, default="New analysis")
203
+ objective = Column(Text, nullable=False, default="")
204
+ business_questions = Column(JSONB, nullable=False, default=list)
205
  user_id = Column(String, nullable=False, index=True) # was owner_id (dedorch uses user_id)
206
  report_id = Column(UUID(as_uuid=False), nullable=True)
207
  # dedorch `analyses` columns (FE/Go concerns; carried so create_all matches dedorch).
 
235
  bound_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
236
  source_metadata = Column("metadata", JSONB, nullable=True)
237
  created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
238
+
239
+
240
+ class AnalysesMessageRow(Base):
241
+ """One conversation message — dedorch `analyses_messages` (Go-owned table).
242
+
243
+ The analysis chat room (user question + AI answer), replacing the deprecated
244
+ `rooms`/`chat_messages`. Python is a **consumer/writer** here: it INSERTs and reads
245
+ rows but does NOT own the table (Go's migration creates it). Shape mirrors the Go
246
+ contract (`API_CONTRACT_BE_GOLANG.md` §Analysis Messages): `role ∈ user|ai`. RAG source
247
+ citations are NOT persisted here — the old `message_sources` table is deprecated along
248
+ with `chat_messages`.
249
+ """
250
+ __tablename__ = "analyses_messages"
251
+
252
+ id = Column(UUID(as_uuid=False), primary_key=True)
253
+ analysis_id = Column(UUID(as_uuid=False), nullable=False, index=True)
254
+ user_id = Column(String, nullable=False, index=True)
255
+ role = Column(String, nullable=False) # user | ai
256
+ content = Column(Text, nullable=False)
257
+ created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())