Skip to content

Web App#2

Open
egrillot wants to merge 19 commits into
getorbital:mainfrom
egrillot:emilien
Open

Web App#2
egrillot wants to merge 19 commits into
getorbital:mainfrom
egrillot:emilien

Conversation

@egrillot

@egrillot egrillot commented Jun 17, 2026

Copy link
Copy Markdown

Le readme de cette branche décrit ce qui a été fait et comment run le projet localement avec et sans Docker.

Summary by CodeRabbit

  • New Features

    • Full-stack web application with real-time streaming chat interface
    • Conversation history with persistent storage
    • CSV dataset upload and management
    • Agent response streaming with structured process timeline (reasoning, tool calls, results)
    • Downloadable generated tables and interactive charts
    • Multi-turn conversation support
  • Infrastructure

    • Docker Compose setup with database and S3 storage services
    • Make commands for development, testing, and deployment
    • Database migrations and seeding scripts
  • Testing

    • Comprehensive backend API and chat tests
    • Frontend component and hook tests

Emilien Grillot and others added 19 commits June 16, 2026 08:52
- single page with VITE server
- conversations navigation and csv upload UI/UX (just front components)
- front and back tests
- SQLAlchemy + Alembic for ORM and connection to postgres db
- the upload endpoint allow user to upload a list of csv
we store the agent work like generated figure or csv files
persist user, send last 10, stream SSE, persist assistant + artifacts + CsvFile
Replace the stale case-study brief (it described the old CLI layout) with
setup instructions for the web app: docker compose up after setting
ANTHROPIC_API_KEY in backend/.env, plus a config reference, the no-Docker
make workflow, project layout and how a chat turn streams.

Also drop the dead ./agent:/app/agent mount from docker-compose — the agent
now lives under backend/app/services/agent/, so the root agent/ folder no
longer exists and the mount created an empty stray dir in the container.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 17, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

This PR replaces the previous CLI-oriented setup with a Dockerized backend and React frontend, adds database and S3-backed persistence, introduces SSE-based chat and artifact APIs, adds dataset upload and rendering flows, and includes seeding, tooling, and automated tests.

Changes

Full-stack web application

Layer / File(s) Summary
Development and runtime scaffolding
.env.example, .gitignore, .vscode/*, Makefile, docker-compose*.yml, backend/Dockerfile, frontend/Dockerfile, frontend/package.json, frontend/*config*, README.md, Dockerfile, main.py, requirements.txt
Replaces the root CLI/container setup with backend/frontend packaging, compose services, make targets, editor settings, environment templates, and updated project documentation.
Backend configuration and persistence
backend/app/core/config.py, backend/app/db/*, backend/app/models/*, backend/app/schemas/*, backend/app/services/storage.py, backend/alembic/*
Adds backend settings, async DB/session wiring, S3 helpers, ORM models, response schemas, and Alembic migrations for conversations, messages, CSV files, and message artifacts.
Backend APIs and chat runtime
backend/app/main.py, backend/app/api/*, backend/app/services/agent/*, backend/app/services/chat.py
Adds FastAPI endpoints for health, conversations, messages, CSV uploads, and artifact downloads, plus agent event streaming, dataset loading, title generation, and artifact persistence.
Seed data and backend validation
backend/scripts/seed.py, backend/tests/*
Adds a development seeding script for uploaded and generated artifacts and integration-style backend tests for health, pagination, uploads, streaming chat, and artifact download behavior.
Frontend contracts and state flow
frontend/src/types/index.ts, frontend/src/services/*, frontend/src/lib/format.ts, frontend/src/main.tsx, frontend/src/App.tsx, frontend/src/vite-env.d.ts
Adds typed frontend models, API clients, SSE parsing, chat/message/conversation/upload hooks, and the main application state flow for conversations, datasets, and streamed replies.
Frontend UI rendering and tests
frontend/src/components/*, frontend/src/index.css, frontend/src/App.test.tsx, frontend/src/services/*.test.ts, frontend/src/test/setup.ts
Adds the chat interface, sidebars, upload and empty states, markdown and Plotly rendering, streaming process timeline UI, and frontend tests for initial rendering and streamed event handling.

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant Frontend
  participant Backend
  participant Agent
  participant Storage

  User->>Frontend: Send message
  Frontend->>Backend: POST /api/conversations/{id}/messages
  Backend->>Agent: Run chat with history and datasets
  Agent-->>Backend: Stream reasoning, tool, artifact, text events
  Backend->>Storage: Persist figure/table payloads
  Backend-->>Frontend: SSE event stream
  Frontend->>Backend: Reload conversation/messages
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Poem

🐇 I traded a burrow CLI for rooms of glass,
with chats that stream as bright events pass.
I tucked CSVs in buckets, neat and deep,
while migrations and messages learned to keep.
Now frontend and backend hop in rhyme—
a full-stack meadow, built this time.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

Due to the large number of review comments, Critical severity comments were prioritized as inline comments.

🟠 Major comments (28)
frontend/src/services/useMessages.ts-34-47 (1)

34-47: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reset loading in non-fetch branches to prevent a stuck loading state.

When switching conversations during an in-flight fetch, the cancelled request skips the old finally path, and these early returns do not clear loading, so the UI can stay stuck.

🔧 Proposed fix
   useEffect(() => {
     if (conversationId === null) {
+      setLoading(false);
       setMessages([]);
       setTotal(0);
       setLoadedCount(0);
       return;
     }
@@
     if (conversationId === skipFetchFor) {
+      setLoading(false);
       setTotal(0);
       setLoadedCount(0);
       return;
     }

Also applies to: 68-74

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/services/useMessages.ts` around lines 34 - 47, The loading state
is not being reset in the early return branches of the useMessages hook, causing
the UI to remain stuck in a loading state when switching conversations.
Specifically, in both the branch where conversationId is null and the branch
where conversationId matches skipFetchFor, add a call to setLoading(false)
before the return statement to ensure the loading state is properly cleared
whenever the fetch flow is skipped.
frontend/src/services/api.ts-90-114 (1)

90-114: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Handle CRLF-delimited SSE frames in the stream parser.

The frame splitter only recognizes \n\n. With CRLF streams, frames never flush and onEvent is never called.

🔧 Proposed fix
   for (;;) {
     const { done, value } = await reader.read();
     if (done) break;
-    buffer += decoder.decode(value, { stream: true });
+    buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, "\n");
     flushFrames();
   }
-  buffer += decoder.decode();
+  buffer += decoder.decode().replace(/\r\n/g, "\n");
   flushFrames();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/services/api.ts` around lines 90 - 114, The flushFrames function
only checks for Unix-style line ending delimiters (\n\n) when parsing SSE
frames, causing frames delimited with CRLF (\r\n\r\n) to never flush and onEvent
to never be called. Modify the frame detection logic to handle both CRLF and LF
delimiters by either normalizing the buffer to remove carriage returns before
checking for \n\n, or updating the indexOf check to detect both \n\n and
\r\n\r\n patterns and extract frames accordingly.
frontend/src/services/useConversations.ts-17-25 (1)

17-25: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Catch refresh failures to avoid unhandled promise rejections.

refresh() can reject, and the current effect only uses .finally(...), so fetch failures leak as unhandled rejections.

🔧 Proposed fix
   const refresh = useCallback(async () => {
-    const page = await listConversations({ limit: PAGE_SIZE, offset: 0 });
-    setConversations(page.items);
-    setTotal(page.total);
+    try {
+      const page = await listConversations({ limit: PAGE_SIZE, offset: 0 });
+      setConversations(page.items);
+      setTotal(page.total);
+    } catch {
+      setConversations([]);
+      setTotal(0);
+    }
   }, []);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/services/useConversations.ts` around lines 17 - 25, The refresh
function in the useEffect hook can reject when listConversations fails, but the
current implementation only uses .finally() without catching the rejection,
leading to unhandled promise rejections. Add a .catch() handler before the
.finally() call in the useEffect hook that handles the error appropriately (such
as logging it or setting an error state), ensuring all rejection paths are
handled before the finally block sets setLoading to false.
frontend/src/services/useMessages.ts-80-95 (1)

80-95: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard loadMore against stale responses from a previously selected conversation.

If the user switches conversations while loadMore is in flight, the old response can still mutate messages, mixing threads across conversation boundaries.

🔧 Proposed fix
-import { useCallback, useEffect, useState } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
@@
   const [loadedCount, setLoadedCount] = useState(0);
+  const activeConversationRef = useRef<string | null>(conversationId);
@@
   useEffect(() => {
+    activeConversationRef.current = conversationId;
     if (conversationId === null) {
@@
   const loadMore = useCallback(async () => {
     if (conversationId === null) return;
+    const targetConversationId = conversationId;
     setLoadingMore(true);
     try {
-      const page = await listMessages(conversationId, {
+      const page = await listMessages(targetConversationId, {
         limit: PAGE_SIZE,
         offset: loadedCount,
       });
+      if (activeConversationRef.current !== targetConversationId) return;
       // Older messages go to the top, still chronological within the page.
       setMessages((prev) => [...[...page.items].reverse(), ...prev]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/services/useMessages.ts` around lines 80 - 95, The `loadMore`
function has a race condition where stale responses from a previously selected
conversation can mutate the messages state. To fix this, capture the
conversationId value at the start of the async operation (before calling
listMessages) and after receiving the response, verify that the captured
conversationId still matches the current conversationId before updating state
with setMessages, setTotal, and setLoadedCount. This ensures that if the user
switches conversations while the request is in flight, the stale response will
be discarded instead of mixing messages across different conversations.
frontend/src/components/ChatComposer.tsx-33-37 (1)

33-37: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Prevent Enter-to-send while IME composition is active.

On Line 34, submitting on raw Enter without checking composition state can send unfinished IME text.

Proposed fix
-        onKeyDown={(e) => {
-          if (e.key === "Enter" && !e.shiftKey) {
+        onKeyDown={(e) => {
+          if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
             e.preventDefault();
             submit();
           }
         }}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/components/ChatComposer.tsx` around lines 33 - 37, The onKeyDown
handler in the ChatComposer component submits when Enter is pressed without
checking if IME composition is currently active, which can send incomplete or
unfinished composed text. To fix this, before calling submit() in the Enter key
handler, add a check for the composition state by either checking the
e.isComposing property on the keyboard event, or by tracking composition state
separately using onCompositionStart and onCompositionEnd event handlers to set a
flag. Only proceed with the submit() call when composition is not active.
frontend/src/components/PlotlyFigure.tsx-20-44 (1)

20-44: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Validate parsed figure structure before rendering Plotly.

On Lines 22-44, parse success is treated as structural validity. If figureJson is valid JSON but lacks data/layout, rendering can fail at runtime.

Proposed fix
 export default function PlotlyFigure({ figureJson }: PlotlyFigureProps) {
   const fig = useMemo(() => {
     try {
-      return JSON.parse(figureJson) as { data: unknown[]; layout: object };
+      const parsed: unknown = JSON.parse(figureJson);
+      if (!parsed || typeof parsed !== "object") return null;
+      const maybe = parsed as { data?: unknown; layout?: unknown };
+      if (!Array.isArray(maybe.data)) return null;
+      if (
+        maybe.layout !== undefined &&
+        (typeof maybe.layout !== "object" || maybe.layout === null)
+      ) {
+        return null;
+      }
+      return {
+        data: maybe.data as Plotly.Data[],
+        layout: (maybe.layout ?? {}) as Partial<Plotly.Layout>,
+      };
     } catch {
       return null;
     }
   }, [figureJson]);
@@
       <Plot
-        data={fig.data as Plotly.Data[]}
+        data={fig.data}
         layout={{
-          ...(fig.layout as Partial<Plotly.Layout>),
+          ...fig.layout,
           autosize: true,
-          margin: { t: 40, r: 16, b: 40, l: 48, ...((fig.layout as { margin?: object })?.margin ?? {}) },
+          margin: { t: 40, r: 16, b: 40, l: 48, ...((fig.layout as { margin?: object })?.margin ?? {}) },
         }}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/components/PlotlyFigure.tsx` around lines 20 - 44, The useMemo
hook parsing figureJson only catches JSON parsing errors but does not validate
that the parsed object has the required structure with data and layout
properties. Add validation after successful JSON parsing to check that the
parsed object contains both a data property (that is an array) and a layout
property (that is an object). If either property is missing or has an invalid
type, return null from the useMemo hook, similar to how parse errors are
handled, to prevent runtime failures in the Plot component.
frontend/src/lib/format.ts-7-14 (1)

7-14: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use toLocaleString instead of toLocaleDateString for date+time formatting.

toLocaleDateString is designed for date-only formatting and does not support hour and minute options. According to the ECMAScript specification, these options are only valid for toLocaleString and toLocaleTimeString. In the current implementation, the time portion may be silently ignored or produce inconsistent results across browsers.

🕒 Proposed fix
 export function formatDate(iso: string): string {
-  return new Date(iso).toLocaleDateString(undefined, {
+  return new Date(iso).toLocaleString(undefined, {
     month: "short",
     day: "numeric",
     hour: "2-digit",
     minute: "2-digit",
   });
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/lib/format.ts` around lines 7 - 14, The formatDate function uses
toLocaleDateString with hour and minute options, but toLocaleDateString is
designed for date-only formatting and does not support time-related options
according to the ECMAScript specification. Replace the toLocaleDateString method
call with toLocaleString, which properly supports both date and time formatting
options and will ensure consistent behavior across all browsers.
frontend/src/App.tsx-16-19 (1)

16-19: ⚠️ Potential issue | 🟠 Major

Guard route decoding against malformed URL segments.

decodeURIComponent throws a URIError when it encounters invalid % encodings (e.g., percent sign not followed by two hex digits). This will crash component initialization or browser navigation if a user reaches a URL with malformed encoding like /c/%GG.

Proposed fix
 function conversationIdFromPath(): string | null {
   const match = window.location.pathname.match(/^\/c\/(.+)$/);
-  return match ? decodeURIComponent(match[1]) : null;
+  if (!match) return null;
+  try {
+    return decodeURIComponent(match[1]);
+  } catch {
+    return null;
+  }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/App.tsx` around lines 16 - 19, The conversationIdFromPath
function does not handle the URIError that decodeURIComponent throws when
encountering invalid percent encodings like %GG, which will crash component
initialization. Wrap the decodeURIComponent call in a try-catch block to catch
URIError exceptions and return null when the URL segment contains malformed
encoding, ensuring graceful degradation instead of application crashes.
frontend/src/App.tsx-66-71 (1)

66-71: ⚠️ Potential issue | 🟠 Major

Return the reconcile promise from onDone—don't discard it with void.

The send() function in useChatStream awaits the onDone callback to ensure persisted messages replace the live bubble before it clears. Using void reconcileMessages(id) discards the promise, so send() won't actually wait for reconciliation to complete, breaking the intended handoff ordering.

Proposed fix
   const { reply, running, send: streamSend } = useChatStream({
     onTitle: (id, title) => renameConversation(id, title),
     onDone: (id) => {
       // Replace optimistic messages + the live bubble with the persisted ones
       // (which carry artifacts). Pass the id explicitly: for a brand-new
       // conversation the hook's captured id may still be stale.
-      void reconcileMessages(id);
+      return reconcileMessages(id);
     },
   });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/App.tsx` around lines 66 - 71, The onDone callback in App.tsx is
discarding the promise returned by reconcileMessages(id) using the void
operator, which prevents the send() function from awaiting the reconciliation to
complete. Remove the void keyword from the reconcileMessages(id) call and return
the promise directly so that send() can properly wait for message reconciliation
to finish before clearing the live bubble, ensuring the correct handoff
ordering.
backend/alembic/env.py-22-22 (1)

22-22: ⚠️ Potential issue | 🟠 Major

Escape % in Alembic URL injection to prevent migration startup failures.

At Line 22, passing raw database_url into Alembic config fails when credentials contain URL-encoded characters (for example %40), causing ConfigParser interpolation errors before migrations run.

Proposed fix
- config.set_main_option("sqlalchemy.url", get_settings().database_url)
+ db_url = get_settings().database_url
+ config.set_main_option("sqlalchemy.url", db_url.replace("%", "%%"))

Note: An alternative approach is to bypass the Config object entirely by passing the URL directly to context.configure() in the run_migrations_online() function, which avoids ConfigParser processing altogether and is considered the recommended practice. However, the escaping solution above is a valid immediate fix.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/alembic/env.py` at line 22, The database_url containing URL-encoded
characters like %40 causes ConfigParser interpolation errors when passed to
config.set_main_option in the env.py file. Fix this by escaping all percent
signs in the database_url before passing it to set_main_option at line 22,
converting each % to %% so ConfigParser treats them as literal characters.
Alternatively, pass the raw database_url directly to context.configure() inside
the run_migrations_online() function instead of using set_main_option, which
bypasses ConfigParser processing entirely.
backend/app/core/config.py-32-34 (1)

32-34: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

URL-encode DB credentials in the constructed DSN.

database_url currently interpolates raw username/password. If either contains URI-reserved characters, SQLAlchemy gets an invalid URL and DB connection fails.

Suggested patch
+from urllib.parse import quote_plus
@@
     def database_url(self) -> str:
         if self.database_url_override:
             return self.database_url_override
+        user = quote_plus(self.postgres_user)
+        password = quote_plus(self.postgres_password)
         return (
-            f"postgresql+asyncpg://{self.postgres_user}:{self.postgres_password}"
+            f"postgresql+asyncpg://{user}:{password}"
             f"@{self.postgres_host}:{self.postgres_port}/{self.postgres_db}"
         )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/core/config.py` around lines 32 - 34, The database_url property
construction directly interpolates self.postgres_user and self.postgres_password
into the PostgreSQL connection string without URL-encoding them. If these
credentials contain URI-reserved characters (such as @ or :), the resulting URL
will be malformed and SQLAlchemy will fail to connect to the database. To fix
this, import the appropriate URL encoding utility (such as quote from
urllib.parse) and apply it to both self.postgres_user and self.postgres_password
before inserting them into the f-string that constructs the database URL.
backend/app/api/csv_files.py-101-103 (1)

101-103: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Delete ordering risks dangling DB references on commit failure.

S3 is deleted before the DB transaction commits. If the commit fails, the DB row still exists but points to a missing object.

Safer ordering
-    storage.delete_object(csv_file.s3_key)
     await db.delete(csv_file)
     await db.commit()
+    try:
+        storage.delete_object(csv_file.s3_key)
+    except Exception:
+        # log + schedule retry/outbox cleanup
+        pass
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/api/csv_files.py` around lines 101 - 103, The current deletion
order in the csv_file deletion logic creates a risk where the S3 object is
deleted before the database transaction is committed. If the commit fails, the
S3 object will already be gone while the database row still exists, creating a
dangling reference. Reorder the operations so that the database deletion and
commit happen first (the await db.delete(csv_file) and await db.commit() calls),
and only after the database transaction successfully commits should the S3
object be deleted via storage.delete_object(csv_file.s3_key). This ensures
database consistency is guaranteed before any irreversible S3 operations occur.
backend/Dockerfile-1-18 (1)

1-18: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Run the backend process as a non-root user.

The container currently runs the app as root, which is an avoidable privilege escalation risk if the service is compromised.

Suggested hardening diff
 FROM python:3.12-slim
+RUN groupadd --system app && useradd --system --gid app --create-home app
 
 # Install uv (fast Python package manager).
 COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
 
 WORKDIR /app
+RUN chown app:app /app
 
 # Install dependencies first (cached layer).
 COPY pyproject.toml ./
 RUN uv sync --no-install-project
 
 # Copy application code.
 COPY . .
+RUN chown -R app:app /app
+USER app
 
 EXPOSE 8000
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/Dockerfile` around lines 1 - 18, The Dockerfile currently runs the
application as the root user, which presents a security risk. Add a USER
directive to create and switch to a non-root user before the CMD instruction.
Create a new user with appropriate permissions that has access to the
application directory (/app), then use the USER directive to run all subsequent
commands and the application under this non-root user account instead of root.

Source: Linters/SAST tools

backend/app/api/csv_files.py-67-93 (1)

67-93: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Upload batch is not atomic across S3 and DB.

If any later upload or db.commit() fails, already-uploaded objects remain in S3 with no DB record. That violates the all-or-nothing expectation and creates orphaned artifacts.

Suggested compensation pattern
-    prepared: list[CsvFile] = []
-    for filename, body in bodies:
+    prepared: list[CsvFile] = []
+    uploaded_keys: list[str] = []
+    try:
+        for filename, body in bodies:
         # Best-effort row/column count (does not fail the upload if unparsable).
         row_count: int | None = None
         column_count: int | None = None
         try:
             df = pd.read_csv(io.BytesIO(body))
             row_count, column_count = int(df.shape[0]), int(df.shape[1])
         except Exception:
             pass
 
         s3_key = f"{uuid4()}/{filename}"
         storage.upload_csv(s3_key, body)
+        uploaded_keys.append(s3_key)
 
         csv_file = CsvFile(
             filename=filename,
             s3_key=s3_key,
             size_bytes=len(body),
             row_count=row_count,
             column_count=column_count,
         )
         db.add(csv_file)
         prepared.append(csv_file)
-
-    await db.commit()
+        await db.commit()
+    except Exception:
+        await db.rollback()
+        for key in uploaded_keys:
+            try:
+                storage.delete_object(key)
+            except Exception:
+                pass
+        raise
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/api/csv_files.py` around lines 67 - 93, The current
implementation uploads files to S3 before committing to the database, which
means if the database commit fails, orphaned files remain in S3. Refactor the
logic to defer all S3 uploads until after the database commit succeeds.
Restructure the loop to first collect all the CSV file metadata and add entries
to the database, commit the transaction, and only then iterate through the
prepared CSV files to upload their corresponding bodies to S3 using
storage.upload_csv. This ensures that if any operation fails during the database
phase, no files are uploaded to S3.
backend/app/services/storage.py-12-21 (1)

12-21: ⚠️ Potential issue | 🟠 Major

Synchronous boto3 calls block the event loop in async request paths.

The storage functions get_s3_client(), upload_object(), download_object(), and delete_object() use blocking boto3 methods directly. These are called from async API endpoints (e.g., upload_csv_files() in csv_files.py, _persist_assistant() and _load_dataset_context() in chat.py) without thread pool wrapping, causing blocking I/O that reduces throughput under load.

Wrap the storage calls using run_in_threadpool:

from starlette.concurrency import run_in_threadpool

async def upload_object(...):
    client = get_s3_client()
    await run_in_threadpool(
        client.put_object,
        Bucket=settings.s3_bucket,
        Key=key,
        Body=body,
        ContentType=content_type,
    )

Then update async callers to await storage.upload_object(...) (and similarly for download/delete/head/create).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/services/storage.py` around lines 12 - 21, The storage functions
`get_s3_client()`, `upload_object()`, `download_object()`, and `delete_object()`
perform blocking boto3 I/O operations that are called from async contexts
without thread pool wrapping, which blocks the event loop. Import
`run_in_threadpool` from `starlette.concurrency` and wrap all boto3 method calls
(such as `client.put_object()`, `client.get_object()`, `client.delete_object()`,
etc.) with `await run_in_threadpool(method, args...)` to offload blocking
operations to a thread pool. Convert the storage functions to async functions
where needed and update all async callers in csv_files.py and chat.py to
properly await these wrapped storage calls.
backend/app/services/storage.py-27-30 (1)

27-30: ⚠️ Potential issue | 🟠 Major

Narrow head_bucket error handling and handle regional bucket creation correctly.

head_bucket returns generic HTTP status codes (400/403/404) with no body, making it impossible to distinguish a missing bucket from permission errors. Catching all ClientError and always calling create_bucket will mask permission or endpoint failures. Additionally, create_bucket without CreateBucketConfiguration.LocationConstraint fails outside us-east-1—if the region is not us-east-1, the API returns an IllegalLocationConstraintException.

Check the error code to confirm the bucket is actually missing before attempting creation, and include the LocationConstraint when the configured region is not us-east-1.

Suggested fix
 def ensure_bucket() -> None:
     """Create the upload bucket if it does not exist (idempotent)."""
     client = get_s3_client()
     try:
         client.head_bucket(Bucket=settings.s3_bucket)
-    except ClientError:
-        client.create_bucket(Bucket=settings.s3_bucket)
+        return
+    except ClientError as exc:
+        code = str(exc.response.get("Error", {}).get("Code", ""))
+        if code not in {"404", "NoSuchBucket", "NotFound"}:
+            raise
+
+    kwargs = {"Bucket": settings.s3_bucket}
+    if settings.s3_region and settings.s3_region != "us-east-1":
+        kwargs["CreateBucketConfiguration"] = {
+            "LocationConstraint": settings.s3_region
+        }
+    client.create_bucket(**kwargs)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/services/storage.py` around lines 27 - 30, The current exception
handling in the storage initialization block catches all ClientError exceptions
broadly and always attempts to create the bucket, which masks permission or
endpoint failures and doesn't distinguish between a missing bucket versus access
issues. Modify the exception handler to check the error code property of the
caught ClientError and only call create_bucket when the error code is
NoSuchBucket. Additionally, when calling create_bucket, include the
CreateBucketConfiguration parameter with LocationConstraint set to the
configured region value only when the region is not us-east-1 (since us-east-1
is the default and doesn't require the constraint parameter).
docker-compose.yml-22-24 (1)

22-24: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Stop hardcoding MinIO root credentials in compose.

Line 22 and Line 45 hardcode admin credentials in source-controlled config. This creates insecure defaults and leaks secrets into shared dev environments. Parameterize both MinIO and backend S3 credentials via env vars.

Suggested patch
   minio:
@@
     environment:
-      MINIO_ROOT_USER: minioadmin
-      MINIO_ROOT_PASSWORD: minioadmin
+      MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
+      MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
@@
   backend:
@@
     environment:
@@
-      S3_ACCESS_KEY: minioadmin
-      S3_SECRET_KEY: minioadmin
+      S3_ACCESS_KEY: ${MINIO_ROOT_USER:-minioadmin}
+      S3_SECRET_KEY: ${MINIO_ROOT_PASSWORD:-minioadmin}

Also applies to: 45-46

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docker-compose.yml` around lines 22 - 24, Replace the hardcoded MinIO root
credentials (MINIO_ROOT_USER and MINIO_ROOT_PASSWORD set to "minioadmin") with
environment variable references using the ${VARIABLE_NAME} syntax. Apply the
same parameterization pattern to the backend S3 credentials at the second
location in the file (around line 45-46). This ensures sensitive credentials are
not stored in source control but instead injected at runtime through environment
variables, eliminating the security vulnerability of hardcoded default
credentials in the shared compose configuration.
backend/app/services/chat.py-85-90 (1)

85-90: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

run_chat does full dataset reload on every user message.

Line 86–Line 90 reload all CSV references and materialize datasets each turn. This scales linearly with total dataset count and will degrade latency/memory as usage grows.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/services/chat.py` around lines 85 - 90, The `run_chat` function
reloads all CSV file references and materializes all datasets on every
invocation by executing the select query on CsvFile, calling load_datasets with
the refs, and calling describe on the datasets. This needs to be cached at the
session or conversation level rather than executed per message. Move the dataset
loading logic (the query selecting CsvFile.filename and CsvFile.s3_key, the
load_datasets call, and the describe call) outside of the per-message processing
so that datasets are loaded once and reused across multiple messages in the same
conversation session, rather than being reloaded for each new message.
frontend/Dockerfile-1-12 (1)

1-12: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Run the frontend container as a non-root user.

This Dockerfile never switches away from root, so the app process runs with unnecessary privileges.

Suggested patch
 FROM node:22-slim
 
 WORKDIR /app
 
-COPY package.json ./
-RUN npm install
+COPY --chown=node:node package*.json ./
+RUN npm ci
 
-COPY . .
+COPY --chown=node:node . .
+USER node
 
 EXPOSE 5173
 
 CMD ["npm", "run", "dev", "--", "--host"]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/Dockerfile` around lines 1 - 12, The Dockerfile currently runs all
processes as root, which poses a security risk. After the WORKDIR /app
instruction, create a new non-root user and group using RUN commands, ensure the
user owns the application directory with appropriate permissions, and add a USER
directive before the final CMD instruction to switch to this non-root user. This
ensures the npm run dev process executes with minimal required privileges.

Source: Linters/SAST tools

frontend/Dockerfile-5-7 (1)

5-7: ⚠️ Potential issue | 🟠 Major

Copy both package.json and package-lock.json before running npm install; use npm ci for deterministic builds.

Line 5-6 only copies package.json and runs npm install, which performs non-deterministic installs. Although package-lock.json exists in the repository, it isn't available during the build—it's only copied later with COPY . . (line 8), after installation completes. Copy package-lock.json alongside package.json on line 5, then use npm ci instead of npm install to ensure reproducible builds.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/Dockerfile` around lines 5 - 7, Modify the COPY command on line 5 to
include both package.json and package-lock.json (change from copying just
package.json to copying both files together). Then replace npm install on line 6
with npm ci to ensure deterministic and reproducible builds that respect the
locked dependency versions.
backend/app/models/message_artifact.py-20-31 (1)

20-31: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Enforce artifact kind at the database layer.

Line 31 stores kind as free-form String(32) with no check/enum constraint. Invalid values can be inserted outside API validation and break cross-layer contracts.

Suggested patch
-from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func
+from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Integer, String, Text, func
@@
 class MessageArtifact(Base):
     __tablename__ = "message_artifact"
+    __table_args__ = (
+        CheckConstraint(
+            "kind IN ('thinking','sql','query_result','figure','table')",
+            name="ck_message_artifact_kind",
+        ),
+    )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/models/message_artifact.py` around lines 20 - 31, The
MessageArtifact class stores the kind field as a plain String(32) column without
any database-level constraint, allowing invalid values to be inserted outside
API validation. Replace the String(32) type with SQLAlchemy's Enum type,
constrained to only the valid values defined in the ARTIFACT_KINDS tuple. This
will enforce valid artifact kinds at the database layer and prevent invalid data
from being persisted.
backend/app/services/agent/title.py-27-31 (1)

27-31: ⚠️ Potential issue | 🟠 Major

Add a timeout around title generation.

Line 27 awaits an external model call without a time limit. If the provider hangs, new-conversation flow can stall before streaming starts. Use asyncio.wait_for() to enforce an 8-second timeout on the agent call.

Suggested patch
+import asyncio
 from pydantic_ai import Agent
@@
     agent: Agent[None] = Agent(model=get_model(), system_prompt=_TITLE_SYSTEM_PROMPT)
     try:
-        result = await agent.run(first_message)
+        result = await asyncio.wait_for(agent.run(first_message), timeout=8)
         title = (result.output or "").strip().strip('"').strip()
     except Exception as exc:  # noqa: BLE001 - titling is best effort
         print(f"[title] generation failed: {exc}")
         return DEFAULT_TITLE
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/services/agent/title.py` around lines 27 - 31, The
agent.run(first_message) call in the title generation function lacks a timeout,
which can cause the conversation flow to hang indefinitely if the external model
provider becomes unresponsive. Wrap the await agent.run(first_message) call with
asyncio.wait_for() and set the timeout to 8 seconds to ensure the operation
fails gracefully if the provider hangs, allowing the function to return the
DEFAULT_TITLE instead of blocking the new-conversation flow.
backend/app/api/artifacts.py-31-40 (1)

31-40: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Avoid full in-memory buffering for artifact downloads.

This endpoint loads the entire object into memory before responding. For large CSV artifacts, that can cause memory pressure and slower responses. Stream the S3 body in chunks instead.

Suggested direction
-from fastapi.responses import Response
+from fastapi.responses import StreamingResponse
@@
-    try:
-        body = storage.download_object(artifact.s3_key)
-    except Exception:
+    try:
+        stream = storage.download_object_stream(artifact.s3_key)
+    except Exception:
         raise HTTPException(status_code=502, detail="Could not fetch artifact content.")
@@
-    return Response(
-        content=body,
+    return StreamingResponse(
+        stream,
         media_type="text/csv",
         headers={"Content-Disposition": f'attachment; filename="{filename}"'},
     )

And in backend/app/services/storage.py, add a chunked iterator (e.g., wrapping Body.iter_chunks()).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/api/artifacts.py` around lines 31 - 40, The artifact download
endpoint currently loads the entire S3 object into memory by buffering the full
response from storage.download_object() before sending it to the client. To
stream large CSV artifacts without full in-memory buffering, modify the
storage.download_object() function (or create a new streaming method) in
backend/app/services/storage.py to return an iterator that wraps the S3
Body.iter_chunks() method for chunked iteration. Then update the artifact
download endpoint to use this streaming iterator as the Response content instead
of the buffered body, allowing the file to be sent to the client in chunks
rather than loading it entirely into memory first.
backend/app/api/conversations.py-142-144 (1)

142-144: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Do not expose raw backend exceptions in SSE error events.

Current error handling sends str(exc) directly to clients, which can leak internal details. Return a generic message to the client and log the real exception server-side.

Suggested patch
+import logging
@@
 router = APIRouter(prefix="/conversations", tags=["conversations"])
+logger = logging.getLogger(__name__)
@@
             except Exception as exc:  # surface failures to the client, then end
-                yield agent_events.to_sse(agent_events.error(str(exc)))
+                logger.exception("Chat stream failed for conversation_id=%s", conversation_id)
+                yield agent_events.to_sse(
+                    agent_events.error("Unable to process message right now.")
+                )
                 yield agent_events.to_sse(agent_events.done())
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/api/conversations.py` around lines 142 - 144, The exception
handling in the except block is directly exposing the raw exception message to
clients by passing str(exc) to agent_events.error(), which can leak sensitive
internal details. Replace the str(exc) argument with a generic error message
like "An error occurred processing your request" that does not expose
implementation details, and separately log the actual exception details using
the server-side logger before yielding the error event to the client. This keeps
internal error information server-side while providing a safe user-facing error
message.
backend/tests/conftest.py-55-60 (1)

55-60: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add a hard safety check before schema reset.

Lines 59-60 will drop all tables for whatever DATABASE_URL resolves to. Since Lines 24-27 allow env override, a misconfigured URL can destroy non-test data.

Suggested fix
 from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
+from sqlalchemy.engine import make_url
@@
 TEST_DATABASE_URL = os.environ["DATABASE_URL"]
+
+def _assert_test_database_url(url: str) -> None:
+    db_name = (make_url(url).database or "").lower()
+    if "test" not in db_name:
+        raise RuntimeError(f"Refusing to run destructive test schema reset on DB '{db_name}'")
+
+_assert_test_database_url(TEST_DATABASE_URL)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/tests/conftest.py` around lines 55 - 60, The _reset() function in the
_schema fixture is dropping and recreating all tables without verifying it's
actually running against a test database. Add a safety check before the
Base.metadata.drop_all call in _reset() to validate that the DATABASE_URL is
configured for testing purposes (such as checking for 'test' in the database
name, localhost patterns, or an explicit test flag). This prevents accidental
destruction of production data if the environment is misconfigured and
DATABASE_URL points to a production database.
backend/scripts/seed.py-194-199 (1)

194-199: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Harden the DB safety gate before destructive operations.

Line 195 relies on substring matching, which can incorrectly allow non-local targets. This script can delete/write substantial data, so the guard should parse the URL host explicitly.

Suggested fix
 from sqlalchemy import delete, func, select
+from sqlalchemy.engine import make_url
@@
 def _guard_dev_database() -> None:
     """Refuse to seed anything that doesn't look like a local dev/test DB."""
     url = get_settings().database_url
-    if not any(host in url for host in ("localhost", "127.0.0.1", "`@db`:")):
+    parsed = make_url(url)
+    host = (parsed.host or "").lower()
+    if host not in {"localhost", "127.0.0.1", "db"}:
         raise SystemExit(
             f"Refusing to seed a non-local database:\n  {url}\n"
             "Point DATABASE_URL at a local dev DB before seeding."
         )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/scripts/seed.py` around lines 194 - 199, The database safety check in
the seed script currently uses unreliable substring matching to verify the
database URL is local, which can incorrectly allow non-local databases if
certain strings appear elsewhere in the URL. Replace the substring matching
logic with explicit URL parsing to extract and validate the host component
properly. Parse the database_url returned by get_settings() using a URL parsing
library to get the hostname/netloc, then verify it matches one of the allowed
local hosts (localhost, 127.0.0.1, or `@db`:) before allowing the seed operation
to proceed.
backend/app/services/agent/tools/visualize.py-52-54 (1)

52-54: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Sanitize artifact titles before storing them.

title is model/user-influenced and later used in Content-Disposition filename formatting (backend/app/api/artifacts.py). Unsanitized CR/LF/quote characters can cause header corruption or response-splitting risk.

🔒 Suggested hardening
+def _sanitize_title(value: str) -> str:
+    clean = value.replace("\r", " ").replace("\n", " ").replace('"', "").strip()
+    return clean or "artifact"
...
-            ctx.deps.artifacts.append(
-                Artifact(kind="figure", title=title, payload=pio.to_json(fig))
-            )
+            safe_title = _sanitize_title(title)
+            ctx.deps.artifacts.append(
+                Artifact(kind="figure", title=safe_title, payload=pio.to_json(fig))
+            )
...
-            ctx.deps.artifacts.append(
-                Artifact(kind="table", title=title, payload=result.to_csv(index=False))
-            )
+            safe_title = _sanitize_title(title)
+            ctx.deps.artifacts.append(
+                Artifact(kind="table", title=safe_title, payload=result.to_csv(index=False))
+            )

Also applies to: 66-68

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/services/agent/tools/visualize.py` around lines 52 - 54, The
title parameter being passed to the Artifact constructor at line 52 (and
similarly at line 66) is user-influenced and gets used in Content-Disposition
header formatting, creating a potential header injection vulnerability. Sanitize
the title variable by removing or escaping dangerous characters such as carriage
returns (CR), line feeds (LF), and quote characters before passing it to the
Artifact constructor. Apply this sanitization to both occurrences where Artifact
is instantiated with the title parameter to prevent header corruption and
response-splitting attacks.
backend/app/services/agent/runner.py-81-86 (1)

81-86: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reset thinking-state on flush to avoid dropping final answer text.

If a response ends with an unmatched <thinking> section, _inside remains True after flush, so later normal deltas are emitted as reasoning instead of text. That can drop assistant content persistence in chat.run_chat.

🐛 Proposed fix
 def flush(self) -> list[Event]:
     out: list[Event] = []
     if self._buf:
         out.append(reasoning(self._buf) if self._inside else text(self._buf))
         self._buf = ""
+    # A model-request node boundary is a safe reset point.
+    self._inside = False
     return out
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/services/agent/runner.py` around lines 81 - 86, The `_inside`
flag tracking whether we are inside a thinking section is not being reset after
flush is called, causing it to persist to True. This means subsequent text
deltas get emitted as reasoning instead of text. In the flush method, reset the
`_inside` flag to False after appending the buffered content to the output list
to ensure the thinking state is cleared and does not affect subsequent content
processing.
🟡 Minor comments (8)
frontend/src/components/AssistantMessage.tsx-73-75 (1)

73-75: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Set explicit animation delays for the typing dots.

On Line 171, var(--d) is never defined by callers, so all dots pulse together.

Proposed fix
-          {streaming && !text && !hasProcess && (
-            <span className="inline-flex gap-1">
-              <Dot /> <Dot /> <Dot />
+          {streaming && !text && !hasProcess && (
+            <span className="inline-flex gap-1">
+              <Dot delayMs={0} /> <Dot delayMs={150} /> <Dot delayMs={300} />
             </span>
           )}
@@
-function Dot() {
+function Dot({ delayMs }: { delayMs: number }) {
   return (
-    <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-white/50 [animation-delay:var(--d)]" />
+    <span
+      className="h-1.5 w-1.5 animate-pulse rounded-full bg-white/50"
+      style={{ animationDelay: `${delayMs}ms` }}
+    />
   );
 }

Also applies to: 169-172

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/components/AssistantMessage.tsx` around lines 73 - 75, The
typing dots animation is not staggered because the CSS variable var(--d)
referenced in the Dot component styling is never defined. Fix this by passing an
explicit animation delay prop to each of the three Dot components in the span
element, with incrementally increasing values (e.g., 0s, 0.2s, 0.4s). Modify the
Dot component to accept this delay prop and apply it as an inline style to the
animated element, ensuring each dot pulses with a staggered effect rather than
in unison.
frontend/src/components/CsvManager.tsx-101-108 (1)

101-108: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Guard against null column_count in dimensions display.

The code checks row_count != null before showing dimensions, but both row_count and column_count can be null according to the CsvFile type. If row_count is present but column_count is null, the UI will render "N×null".

🛡️ Proposed fix
-            {file.row_count != null && (
+            {file.row_count != null && file.column_count != null && (
               <>
                 <span>·</span>
                 <span>
                   {file.row_count}×{file.column_count}
                 </span>
               </>
             )}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/components/CsvManager.tsx` around lines 101 - 108, The
dimensions display in the CsvManager component only guards against null
row_count but doesn't check if column_count is null, which can result in
rendering invalid text like "N×null". Update the conditional guard to check that
both file.row_count and file.column_count are not null before rendering the
dimensions span with the row_count and column_count values.
frontend/src/index.css-27-27 (1)

27-27: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fix the inline comment spacing lint error.

This line is missing a space before */, which triggers the stylelint comment-whitespace-inside rule.

Proposed fix
-  /* displacement filter — so a photo backdrop stays smooth, not pixelated.*/
+  /* displacement filter — so a photo backdrop stays smooth, not pixelated. */
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/index.css` at line 27, The inline comment "displacement filter —
so a photo backdrop stays smooth, not pixelated.*/" is missing a space before
the closing comment delimiter. Add a space between the period and the closing */
to comply with the stylelint comment-whitespace-inside rule.

Source: Linters/SAST tools

frontend/src/App.tsx-234-240 (1)

234-240: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Remove hidden drawer toggles from keyboard focus order.

When show is false, the button is visually hidden but still tabbable. Make it non-focusable while hidden.

Proposed fix
     <button
       onClick={onClick}
       aria-label={`Open ${label} panel`}
+      aria-hidden={!show}
+      tabIndex={show ? 0 : -1}
       className={`liquid-glass absolute top-5 z-20 flex h-10 items-center gap-2 rounded-2xl px-3 text-white/70 transition-all duration-300 hover:text-white ${
         side === "left" ? "left-5 flex-row" : "right-5 flex-row-reverse"
       } ${show ? "scale-100 opacity-100" : "pointer-events-none scale-90 opacity-0"}`}
     >
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/App.tsx` around lines 234 - 240, The button element with the
onClick handler and aria-label for opening the panel is visually hidden when
show is false using CSS classes (pointer-events-none, opacity-0), but it remains
in the keyboard tab order. Add a tabIndex attribute to the button that is set to
-1 when show is false to remove it from keyboard focus, and either omit it or
set it to 0 when show is true to allow normal keyboard navigation.
frontend/src/services/useCsvFiles.ts-23-25 (1)

23-25: ⚠️ Potential issue | 🟡 Minor

Catch refresh() failures in the initial effect.

A rejected refresh() promise is currently left unhandled in this effect path. The listCsvFiles() call can reject when the API responds with a non-OK status (via getJson()), and without a .catch() handler, the rejection will surface as an unhandled promise rejection during network/API failures. The same pattern exists in useConversations.ts and is correctly implemented in useMessages.ts.

Proposed fix
 useEffect(() => {
-  refresh().finally(() => setLoading(false));
+  let cancelled = false;
+  void refresh()
+    .catch(() => {
+      // Optionally add an error state here.
+    })
+    .finally(() => {
+      if (!cancelled) setLoading(false);
+    });
+  return () => {
+    cancelled = true;
+  };
 }, [refresh]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/services/useCsvFiles.ts` around lines 23 - 25, The useEffect
hook in useCsvFiles.ts calls refresh().finally() without a catch handler,
leaving promise rejections from listCsvFiles() unhandled when the API fails. Add
a .catch() handler to the refresh() promise chain before the .finally() to
properly handle and log errors when listCsvFiles() rejects due to API failures,
following the same error handling pattern that is correctly implemented in
useMessages.ts.
README.md-96-96 (1)

96-96: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add a language tag to the fenced layout block.

The code fence starting on Line 96 has no language identifier (MD040). Use text to satisfy markdownlint.

Suggested patch
-```
+```text
 backend/
   app/
     api/            # routers: conversations (+ chat streaming), csv_files, artifacts
@@
 docker-compose.yml
</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @README.md at line 96, The fenced code block starting on line 96 of README.md
is missing a language identifier, which violates the MD040 markdownlint rule.
Add text as the language tag to the opening triple backticks fence that
precedes the directory structure listing (the block containing backend/, app/,
api/, etc.) by changing totext to satisfy the linter requirement.


</details>

<!-- cr-comment:v1:671290b5005124dcb1f4af53 -->

_Source: Linters/SAST tools_

</blockquote></details>
<details>
<summary>backend/app/services/agent/model.py-21-23 (1)</summary><blockquote>

`21-23`: _⚠️ Potential issue_ | _🟡 Minor_ | _⚡ Quick win_

**Validate Anthropic model id suffix before constructing the provider model.**

If `MODEL` is set to `anthropic:` (empty suffix), this path builds an invalid model name and fails later at runtime with a less clear error.

   

<details>
<summary>Suggested patch</summary>

```diff
     if model.startswith("anthropic:"):
         name = model.split(":", 1)[1]
+        if not name:
+            raise ValueError("MODEL must include an Anthropic model id, e.g. 'anthropic:claude-...'.")
         provider = (
             AnthropicProvider(api_key=settings.anthropic_api_key)
             if settings.anthropic_api_key
```
</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/services/agent/model.py` around lines 21 - 23, The code extracts
the model suffix by splitting on the colon when the model parameter starts with
"anthropic:", but it does not validate that the suffix is non-empty. If the
model is set to just "anthropic:" without a suffix, the split operation returns
an empty string for the name variable, which then gets used to construct an
invalid provider model name causing unclear runtime errors later. Add validation
immediately after the split operation where name is assigned to check that name
is not empty, and raise a clear ValueError if it is, indicating that a valid
model suffix must be provided after the "anthropic:" prefix.
```

</details>

<!-- cr-comment:v1:3ccb06477e35f63719840ed4 -->

</blockquote></details>
<details>
<summary>backend/tests/test_csv_files.py-105-116 (1)</summary><blockquote>

`105-116`: _⚠️ Potential issue_ | _🟡 Minor_ | _⚡ Quick win_

**Assert storage rollback in the failed batch-upload test.**

Line 106 says “no partial state,” but this test only checks DB state (Line 115). Add a storage assertion to catch orphaned uploads on partial failure paths.

   

<details>
<summary>Suggested fix</summary>

```diff
 def test_upload_rejects_batch_with_one_bad_file(client: TestClient, mock_storage):
@@
     assert resp.status_code == 400
     assert client.get("/api/csv").json()["total"] == 0
+    assert mock_storage == {}
```
</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/tests/test_csv_files.py` around lines 105 - 116, The
test_upload_rejects_batch_with_one_bad_file function claims no partial state but
only checks that the database was rolled back by asserting the total is 0. Add
an assertion to verify that the mock_storage parameter was also properly cleaned
up and rolled back, ensuring no orphaned files were left behind when the batch
upload fails due to the invalid notes.txt file. Check the mock_storage object to
confirm storage rollback occurred alongside the database rollback.
```

</details>

<!-- cr-comment:v1:9fe1d81ee01916db4a488ba9 -->

</blockquote></details>

</blockquote></details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant