Jodinho commited on
Commit
0c78e88
·
1 Parent(s): c6119cd

feat: revamped project structure

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. Dockerfile +13 -25
  2. README.md +2 -2
  3. alembic.ini +0 -147
  4. alembic/README +0 -1
  5. alembic/env.py +0 -91
  6. alembic/script.py.mako +0 -28
  7. alembic/versions/0724098f2d79_add_embeddings_column_with_server_.py +0 -34
  8. alembic/versions/293bf8cae101_add_conversation_history_table.py +0 -50
  9. alembic/versions/2bae362756b9_version_9.py +0 -58
  10. alembic/versions/2cfae3938d63_change_embeddings_table_structure.py +0 -32
  11. alembic/versions/3bdda54c971f_update_embedding_dimension_to_3072.py +0 -39
  12. alembic/versions/4de3dfd809bd_add_sources_to_conversation_history.py +0 -40
  13. alembic/versions/50eb9bd827e5_version_8.py +0 -32
  14. alembic/versions/51088f15a504_version_5.py +0 -32
  15. alembic/versions/576915939aff_version_4.py +0 -109
  16. alembic/versions/5dd0716c12e2_version_2.py +0 -32
  17. alembic/versions/9c071425ee4a_version_3.py +0 -32
  18. alembic/versions/af5827742674_change_embedding_column_type.py +0 -38
  19. alembic/versions/bbbad8eb92e1_version_7.py +0 -44
  20. alembic/versions/c4f1502f712e_create_embeddings_table_and_vector_.py +0 -78
  21. alembic/versions/c5e48c9bf293_initial.py +0 -79
  22. alembic/versions/c85ba4afc960_add_new_users_table.py +0 -50
  23. alembic/versions/d77bd9f8ce2f_version_10.py +0 -51
  24. alembic/versions/e817f10d0cc8_rename_metadata_column_on_embeddings_.py +0 -44
  25. alembic/versions/f8746e269698_version_6.py +0 -70
  26. kb_docs.py +19 -0
  27. main.py +79 -0
  28. onnx_model_optimized/config.json +0 -46
  29. onnx_model_optimized/ort_config.json +0 -33
  30. onnx_model_optimized/special_tokens_map.json +0 -51
  31. onnx_model_optimized/tokenizer.json +0 -0
  32. onnx_model_optimized/tokenizer_config.json +0 -57
  33. pytest.ini +0 -2
  34. requirements.txt +6 -125
  35. src/app/__init__.py +0 -0
  36. src/app/celery_app.py +0 -54
  37. src/app/config.py +0 -122
  38. src/app/crud.py +0 -783
  39. src/app/db/base.py +0 -3
  40. src/app/db/session.py +0 -58
  41. src/app/limiter.py +0 -4
  42. src/app/logging_config.py +0 -42
  43. src/app/main.py +0 -211
  44. src/app/models.py +0 -329
  45. src/app/routers/__init__.py +0 -0
  46. src/app/routers/auth.py +0 -36
  47. src/app/routers/chats.py +0 -63
  48. src/app/routers/dashboard.py +0 -83
  49. src/app/routers/rag.py +0 -120
  50. src/app/routers/rag_streamed.py +0 -75
Dockerfile CHANGED
@@ -1,19 +1,7 @@
1
- # Use Python 3.10 slim (Standard for HF Spaces)
2
  FROM python:3.11-slim
3
 
4
- WORKDIR /app
5
-
6
- # 1. Install system dependencies + Redis Server
7
- USER root
8
- RUN apt-get update && apt-get install -y --no-install-recommends \
9
- build-essential \
10
- redis-server \
11
- && rm -rf /var/lib/apt/lists/*
12
-
13
- RUN useradd -m -u 1000 user && \
14
- mkdir -p /var/run/redis /var/log/redis && \
15
- chown -R user:user /var/run/redis /var/log/redis /var/lib/redis
16
-
17
  USER user
18
  ENV HOME=/home/user \
19
  PATH=/home/user/.local/bin:$PATH \
@@ -21,20 +9,20 @@ ENV HOME=/home/user \
21
  PYTHONDONTWRITEBYTECODE=1 \
22
  PYTHONUNBUFFERED=1
23
 
24
- # 3. Install Dependencies
25
- # Copy only requirements first to leverage Docker cache
 
26
  COPY --chown=user requirements.txt .
27
- RUN pip install --no-cache-dir --upgrade -r requirements.txt
 
28
 
29
- # 4. Copy Code & Model
30
- # We copy everything in the current directory (src, onnx_model_optimized, alembic)
31
- COPY --chown=user . .
32
 
33
- # 5. Make start script executable
34
- RUN chmod +x start.sh
35
 
36
- # 6. Expose the mandatory Hugging Face port
37
  EXPOSE 7860
38
 
39
- # 7. Run the start script
40
- CMD ["./start.sh"]
 
 
1
  FROM python:3.11-slim
2
 
3
+ # Create a non-root user with UID 1000 as required by HF Spaces
4
+ RUN useradd -m -u 1000 user
 
 
 
 
 
 
 
 
 
 
 
5
  USER user
6
  ENV HOME=/home/user \
7
  PATH=/home/user/.local/bin:$PATH \
 
9
  PYTHONDONTWRITEBYTECODE=1 \
10
  PYTHONUNBUFFERED=1
11
 
12
+ WORKDIR /app
13
+
14
+ # Install Python dependencies
15
  COPY --chown=user requirements.txt .
16
+ RUN pip install --no-cache-dir --upgrade pip && \
17
+ pip install --no-cache-dir -r requirements.txt
18
 
19
+ RUN python -c "from sentence_transformers import SentenceTransformer; SentenceTransformer('all-MiniLM-L6-v2')"
 
 
20
 
21
+ # Copy all application files into the container
22
+ COPY --chown=user . .
23
 
24
+ # Expose the mandatory Hugging Face port
25
  EXPOSE 7860
26
 
27
+ # Run FastAPI directly in the foreground
28
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -8,5 +8,5 @@ app_port: 7860
8
  pinned: false
9
  ---
10
 
11
- # SentimentScope Backend
12
- This is the private backend worker running FastAPI + Celery + ONNX Quantized Model.
 
8
  pinned: false
9
  ---
10
 
11
+ # Joule Dynamics RAG Demo
12
+ Backend API for the Amara Home & Kitchen RAG chatbot.
alembic.ini DELETED
@@ -1,147 +0,0 @@
1
- # A generic, single database configuration.
2
-
3
- [alembic]
4
- # path to migration scripts.
5
- # this is typically a path given in POSIX (e.g. forward slashes)
6
- # format, relative to the token %(here)s which refers to the location of this
7
- # ini file
8
- script_location = %(here)s/alembic
9
-
10
- # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
11
- # Uncomment the line below if you want the files to be prepended with date and time
12
- # see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
13
- # for all available tokens
14
- # file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
15
-
16
- # sys.path path, will be prepended to sys.path if present.
17
- # defaults to the current working directory. for multiple paths, the path separator
18
- # is defined by "path_separator" below.
19
- prepend_sys_path = .
20
-
21
-
22
- # timezone to use when rendering the date within the migration file
23
- # as well as the filename.
24
- # If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library.
25
- # Any required deps can installed by adding `alembic[tz]` to the pip requirements
26
- # string value is passed to ZoneInfo()
27
- # leave blank for localtime
28
- # timezone =
29
-
30
- # max length of characters to apply to the "slug" field
31
- # truncate_slug_length = 40
32
-
33
- # set to 'true' to run the environment during
34
- # the 'revision' command, regardless of autogenerate
35
- # revision_environment = false
36
-
37
- # set to 'true' to allow .pyc and .pyo files without
38
- # a source .py file to be detected as revisions in the
39
- # versions/ directory
40
- # sourceless = false
41
-
42
- # version location specification; This defaults
43
- # to <script_location>/versions. When using multiple version
44
- # directories, initial revisions must be specified with --version-path.
45
- # The path separator used here should be the separator specified by "path_separator"
46
- # below.
47
- # version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
48
-
49
- # path_separator; This indicates what character is used to split lists of file
50
- # paths, including version_locations and prepend_sys_path within configparser
51
- # files such as alembic.ini.
52
- # The default rendered in new alembic.ini files is "os", which uses os.pathsep
53
- # to provide os-dependent path splitting.
54
- #
55
- # Note that in order to support legacy alembic.ini files, this default does NOT
56
- # take place if path_separator is not present in alembic.ini. If this
57
- # option is omitted entirely, fallback logic is as follows:
58
- #
59
- # 1. Parsing of the version_locations option falls back to using the legacy
60
- # "version_path_separator" key, which if absent then falls back to the legacy
61
- # behavior of splitting on spaces and/or commas.
62
- # 2. Parsing of the prepend_sys_path option falls back to the legacy
63
- # behavior of splitting on spaces, commas, or colons.
64
- #
65
- # Valid values for path_separator are:
66
- #
67
- # path_separator = :
68
- # path_separator = ;
69
- # path_separator = space
70
- # path_separator = newline
71
- #
72
- # Use os.pathsep. Default configuration used for new projects.
73
- path_separator = os
74
-
75
- # set to 'true' to search source files recursively
76
- # in each "version_locations" directory
77
- # new in Alembic version 1.10
78
- # recursive_version_locations = false
79
-
80
- # the output encoding used when revision files
81
- # are written from script.py.mako
82
- # output_encoding = utf-8
83
-
84
- # database URL. This is consumed by the user-maintained env.py script only.
85
- # other means of configuring database URLs may be customized within the env.py
86
- # file.
87
- sqlalchemy.url =
88
-
89
-
90
- [post_write_hooks]
91
- # post_write_hooks defines scripts or Python functions that are run
92
- # on newly generated revision scripts. See the documentation for further
93
- # detail and examples
94
-
95
- # format using "black" - use the console_scripts runner, against the "black" entrypoint
96
- # hooks = black
97
- # black.type = console_scripts
98
- # black.entrypoint = black
99
- # black.options = -l 79 REVISION_SCRIPT_FILENAME
100
-
101
- # lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
102
- # hooks = ruff
103
- # ruff.type = module
104
- # ruff.module = ruff
105
- # ruff.options = check --fix REVISION_SCRIPT_FILENAME
106
-
107
- # Alternatively, use the exec runner to execute a binary found on your PATH
108
- # hooks = ruff
109
- # ruff.type = exec
110
- # ruff.executable = ruff
111
- # ruff.options = check --fix REVISION_SCRIPT_FILENAME
112
-
113
- # Logging configuration. This is also consumed by the user-maintained
114
- # env.py script only.
115
- [loggers]
116
- keys = root,sqlalchemy,alembic
117
-
118
- [handlers]
119
- keys = console
120
-
121
- [formatters]
122
- keys = generic
123
-
124
- [logger_root]
125
- level = WARNING
126
- handlers = console
127
- qualname =
128
-
129
- [logger_sqlalchemy]
130
- level = WARNING
131
- handlers =
132
- qualname = sqlalchemy.engine
133
-
134
- [logger_alembic]
135
- level = INFO
136
- handlers =
137
- qualname = alembic
138
-
139
- [handler_console]
140
- class = StreamHandler
141
- args = (sys.stderr,)
142
- level = NOTSET
143
- formatter = generic
144
-
145
- [formatter_generic]
146
- format = %(levelname)-5.5s [%(name)s] %(message)s
147
- datefmt = %H:%M:%S
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
alembic/README DELETED
@@ -1 +0,0 @@
1
- Generic single-database configuration.
 
 
alembic/env.py DELETED
@@ -1,91 +0,0 @@
1
- from logging.config import fileConfig
2
- import asyncio
3
-
4
-
5
- from sqlalchemy import engine_from_config
6
- from sqlalchemy import pool
7
- from sqlalchemy.engine import Connection
8
- from sqlalchemy.ext.asyncio import create_async_engine
9
-
10
- from alembic import context
11
-
12
- from src.app.db.base import Base
13
- from src.app.config import settings
14
- from src.app import models
15
- # this is the Alembic Config object, which provides
16
- # access to the values within the .ini file in use.
17
- config = context.config # type: ignore[attr-defined]
18
-
19
- # Interpret the config file for Python logging.
20
- # This line sets up loggers basically.
21
- if config.config_file_name is not None:
22
- fileConfig(config.config_file_name)
23
-
24
- # add your model's MetaData object here
25
- # for 'autogenerate' support
26
- # from myapp import mymodel
27
- # target_metadata = mymodel.Base.metadata
28
- target_metadata = Base.metadata
29
-
30
- # other values from the config, defined by the needs of env.py,
31
- # can be acquired:
32
- # my_important_option = config.get_main_option("my_important_option")
33
- # ... etc.
34
-
35
-
36
- def run_migrations_offline() -> None:
37
- """Run migrations in 'offline' mode.
38
-
39
- This configures the context with just a URL
40
- and not an Engine, though an Engine is acceptable
41
- here as well. By skipping the Engine creation
42
- we don't even need a DBAPI to be available.
43
-
44
- Calls to context.execute() here emit the given string to the
45
- script output.
46
-
47
- """
48
- url = str(settings.DATABASE_URL)
49
- context.configure(
50
- url=url,
51
- target_metadata=target_metadata,
52
- literal_binds=True,
53
- dialect_opts={"paramstyle": "named"},
54
- ) # type: ignore[attr-defined]
55
-
56
- with context.begin_transaction():
57
- context.run_migrations()
58
-
59
-
60
- def do_run_migrations(connection: Connection) -> None:
61
- context.configure(
62
- connection=connection, target_metadata=target_metadata
63
- )
64
-
65
- with context.begin_transaction():
66
- context.run_migrations()
67
-
68
- async def run_migrations_online() -> None:
69
- """Run migrations in 'online' mode.
70
-
71
- In this scenario we need to create an Engine
72
- and associate a connection with the context.
73
-
74
- """
75
-
76
-
77
- connectable = create_async_engine(
78
- str(settings.DATABASE_URL),
79
- poolclass=pool.NullPool,
80
- connect_args={"command_timeout": 300},
81
- )
82
-
83
- async with connectable.connect() as connection:
84
- await connection.run_sync(do_run_migrations)
85
- await connectable.dispose()
86
-
87
-
88
- if context.is_offline_mode():
89
- run_migrations_offline()
90
- else:
91
- asyncio.run(run_migrations_online())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
alembic/script.py.mako DELETED
@@ -1,28 +0,0 @@
1
- """${message}
2
-
3
- Revision ID: ${up_revision}
4
- Revises: ${down_revision | comma,n}
5
- Create Date: ${create_date}
6
-
7
- """
8
- from typing import Sequence, Union
9
-
10
- from alembic import op
11
- import sqlalchemy as sa
12
- ${imports if imports else ""}
13
-
14
- # revision identifiers, used by Alembic.
15
- revision: str = ${repr(up_revision)}
16
- down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
17
- branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
18
- depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
19
-
20
-
21
- def upgrade() -> None:
22
- """Upgrade schema."""
23
- ${upgrades if upgrades else "pass"}
24
-
25
-
26
- def downgrade() -> None:
27
- """Downgrade schema."""
28
- ${downgrades if downgrades else "pass"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
alembic/versions/0724098f2d79_add_embeddings_column_with_server_.py DELETED
@@ -1,34 +0,0 @@
1
- """add embeddings column with server default
2
-
3
- Revision ID: 0724098f2d79
4
- Revises: 2cfae3938d63
5
- Create Date: 2025-12-03 10:08:06.156818
6
-
7
- """
8
- from typing import Sequence, Union
9
-
10
- from alembic import op
11
- import sqlalchemy as sa
12
-
13
-
14
- # revision identifiers, used by Alembic.
15
- revision: str = '0724098f2d79'
16
- down_revision: Union[str, Sequence[str], None] = '2cfae3938d63'
17
- branch_labels: Union[str, Sequence[str], None] = None
18
- depends_on: Union[str, Sequence[str], None] = None
19
-
20
-
21
- def upgrade() -> None:
22
- """Upgrade schema."""
23
- # ### commands auto generated by Alembic - please adjust! ###
24
- op.add_column('chats', sa.Column('embeddings_status', sa.String(length=30), server_default='pending', nullable=False))
25
- op.create_index(op.f('ix_chats_embeddings_status'), 'chats', ['embeddings_status'], unique=False)
26
- # ### end Alembic commands ###
27
-
28
-
29
- def downgrade() -> None:
30
- """Downgrade schema."""
31
- # ### commands auto generated by Alembic - please adjust! ###
32
- op.drop_index(op.f('ix_chats_embeddings_status'), table_name='chats')
33
- op.drop_column('chats', 'embeddings_status')
34
- # ### end Alembic commands ###
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
alembic/versions/293bf8cae101_add_conversation_history_table.py DELETED
@@ -1,50 +0,0 @@
1
- """add conversation history table
2
-
3
- Revision ID: 293bf8cae101
4
- Revises: 3bdda54c971f
5
- Create Date: 2025-11-18 02:56:39.769901
6
-
7
- """
8
- from typing import Sequence, Union
9
-
10
- from alembic import op
11
- import sqlalchemy as sa
12
-
13
-
14
- # revision identifiers, used by Alembic.
15
- revision: str = '293bf8cae101'
16
- down_revision: Union[str, Sequence[str], None] = '3bdda54c971f'
17
- branch_labels: Union[str, Sequence[str], None] = None
18
- depends_on: Union[str, Sequence[str], None] = None
19
-
20
-
21
- def upgrade() -> None:
22
- """Upgrade schema."""
23
- # ### commands auto generated by Alembic - please adjust! ###
24
- op.create_table('conversation_history',
25
- sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
26
- sa.Column('chat_id', sa.Integer(), nullable=False),
27
- sa.Column('role', sa.String(length=20), nullable=False),
28
- sa.Column('content', sa.Text(), nullable=False),
29
- sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
30
- sa.ForeignKeyConstraint(['chat_id'], ['chats.id'], ondelete='CASCADE'),
31
- sa.PrimaryKeyConstraint('id')
32
- )
33
- op.create_index(op.f('ix_conversation_history_chat_id'), 'conversation_history', ['chat_id'], unique=False)
34
- # op.drop_index(op.f('embeddings_chat_id_idx'), table_name='embeddings')
35
- # op.drop_index(op.f('embeddings_chat_source_idx'), table_name='embeddings')
36
- # op.drop_index(op.f('embeddings_embedding_ivfflat'), table_name='embeddings', postgresql_with={'lists': '100'}, postgresql_using='ivfflat')
37
- # op.create_index(op.f('ix_embeddings_chat_id'), 'embeddings', ['chat_id'], unique=False)
38
- # # ### end Alembic commands ###
39
-
40
-
41
- def downgrade() -> None:
42
- """Downgrade schema."""
43
- # ### commands auto generated by Alembic - please adjust! ###
44
- # op.drop_index(op.f('ix_embeddings_chat_id'), table_name='embeddings')
45
- # op.create_index(op.f('embeddings_embedding_ivfflat'), 'embeddings', ['embedding'], unique=False, postgresql_with={'lists': '100'}, postgresql_using='ivfflat')
46
- # op.create_index(op.f('embeddings_chat_source_idx'), 'embeddings', ['chat_id', 'source_table', 'source_id'], unique=False)
47
- # op.create_index(op.f('embeddings_chat_id_idx'), 'embeddings', ['chat_id'], unique=False)
48
- # op.drop_index(op.f('ix_conversation_history_chat_id'), table_name='conversation_history')
49
- op.drop_table('conversation_history')
50
- # ### end Alembic commands ###
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
alembic/versions/2bae362756b9_version_9.py DELETED
@@ -1,58 +0,0 @@
1
- """Version 9
2
-
3
- Revision ID: 2bae362756b9
4
- Revises: 50eb9bd827e5
5
- Create Date: 2025-11-01 08:15:21.483742
6
-
7
- """
8
- from typing import Sequence, Union
9
-
10
- from alembic import op
11
- import sqlalchemy as sa
12
-
13
-
14
- # revision identifiers, used by Alembic.
15
- revision: str = '2bae362756b9'
16
- down_revision: Union[str, Sequence[str], None] = '50eb9bd827e5'
17
- branch_labels: Union[str, Sequence[str], None] = None
18
- depends_on: Union[str, Sequence[str], None] = None
19
-
20
-
21
- def upgrade() -> None:
22
- """Upgrade schema."""
23
- # ### commands auto generated by Alembic - please adjust! ###
24
- op.create_index(op.f('ix_chats_created_at'), 'chats', ['created_at'], unique=False)
25
- op.create_index(op.f('ix_chats_sentiment_status'), 'chats', ['sentiment_status'], unique=False)
26
- op.create_index(op.f('ix_message_sentiments_analysis_timestamp'), 'message_sentiments', ['analysis_timestamp'], unique=False)
27
- op.create_index(op.f('ix_messages_chat_id'), 'messages', ['chat_id'], unique=False)
28
- op.create_index(op.f('ix_messages_participant_id'), 'messages', ['participant_id'], unique=False)
29
- op.drop_constraint(op.f('messages_participant_id_fkey'), 'messages', type_='foreignkey')
30
- op.create_foreign_key(None, 'messages', 'participants', ['participant_id'], ['id'], ondelete='SET NULL')
31
- op.create_index(op.f('ix_participants_chat_id'), 'participants', ['chat_id'], unique=False)
32
- op.create_index(op.f('ix_participants_created_at'), 'participants', ['created_at'], unique=False)
33
- op.create_index(op.f('ix_segment_sentiments_analysis_timestamp'), 'segment_sentiments', ['analysis_timestamp'], unique=False)
34
- op.create_index(op.f('ix_segments_sender_sender_id'), 'segments_sender', ['sender_id'], unique=False)
35
- op.create_index(op.f('ix_segments_sender_time_segment_id'), 'segments_sender', ['time_segment_id'], unique=False)
36
- op.create_index(op.f('ix_segments_time_chat_id'), 'segments_time', ['chat_id'], unique=False)
37
- op.create_index(op.f('ix_segments_time_start_time'), 'segments_time', ['start_time'], unique=False)
38
- # ### end Alembic commands ###
39
-
40
-
41
- def downgrade() -> None:
42
- """Downgrade schema."""
43
- # ### commands auto generated by Alembic - please adjust! ###
44
- op.drop_index(op.f('ix_segments_time_start_time'), table_name='segments_time')
45
- op.drop_index(op.f('ix_segments_time_chat_id'), table_name='segments_time')
46
- op.drop_index(op.f('ix_segments_sender_time_segment_id'), table_name='segments_sender')
47
- op.drop_index(op.f('ix_segments_sender_sender_id'), table_name='segments_sender')
48
- op.drop_index(op.f('ix_segment_sentiments_analysis_timestamp'), table_name='segment_sentiments')
49
- op.drop_index(op.f('ix_participants_created_at'), table_name='participants')
50
- op.drop_index(op.f('ix_participants_chat_id'), table_name='participants')
51
- op.drop_constraint(None, 'messages', type_='foreignkey')
52
- op.create_foreign_key(op.f('messages_participant_id_fkey'), 'messages', 'participants', ['participant_id'], ['id'])
53
- op.drop_index(op.f('ix_messages_participant_id'), table_name='messages')
54
- op.drop_index(op.f('ix_messages_chat_id'), table_name='messages')
55
- op.drop_index(op.f('ix_message_sentiments_analysis_timestamp'), table_name='message_sentiments')
56
- op.drop_index(op.f('ix_chats_sentiment_status'), table_name='chats')
57
- op.drop_index(op.f('ix_chats_created_at'), table_name='chats')
58
- # ### end Alembic commands ###
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
alembic/versions/2cfae3938d63_change_embeddings_table_structure.py DELETED
@@ -1,32 +0,0 @@
1
- """change embeddings table structure
2
-
3
- Revision ID: 2cfae3938d63
4
- Revises: af5827742674
5
- Create Date: 2025-11-28 18:14:02.462140
6
-
7
- """
8
- from typing import Sequence, Union
9
-
10
- from alembic import op
11
- import sqlalchemy as sa
12
-
13
-
14
- # revision identifiers, used by Alembic.
15
- revision: str = '2cfae3938d63'
16
- down_revision: Union[str, Sequence[str], None] = 'af5827742674'
17
- branch_labels: Union[str, Sequence[str], None] = None
18
- depends_on: Union[str, Sequence[str], None] = None
19
-
20
-
21
- def upgrade() -> None:
22
- """Upgrade schema."""
23
- # ### commands auto generated by Alembic - please adjust! ###
24
- pass
25
- # ### end Alembic commands ###
26
-
27
-
28
- def downgrade() -> None:
29
- """Downgrade schema."""
30
- # ### commands auto generated by Alembic - please adjust! ###
31
- pass
32
- # ### end Alembic commands ###
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
alembic/versions/3bdda54c971f_update_embedding_dimension_to_3072.py DELETED
@@ -1,39 +0,0 @@
1
- """update_embedding_dimension_to_3072
2
-
3
- Revision ID: 3bdda54c971f
4
- Revises: e817f10d0cc8
5
- Create Date: 2025-11-12 14:58:09.216085
6
-
7
- """
8
- from typing import Sequence, Union
9
-
10
- from alembic import op
11
- import sqlalchemy as sa
12
-
13
-
14
- # revision identifiers, used by Alembic.
15
- revision: str = '3bdda54c971f'
16
- down_revision: Union[str, Sequence[str], None] = 'e817f10d0cc8'
17
- branch_labels: Union[str, Sequence[str], None] = None
18
- depends_on: Union[str, Sequence[str], None] = None
19
-
20
- def upgrade() -> None:
21
- # ### commands auto-generated by Alembic - manually adjusted ###
22
- print("Altering embedding column type to vector(3072)...")
23
-
24
- # This command changes the database column to accept the new dimension.
25
- op.execute("ALTER TABLE public.embeddings ALTER COLUMN embedding TYPE vector(1536);")
26
-
27
- print("Done.")
28
- # ### end Alembic commands ###
29
-
30
-
31
- def downgrade() -> None:
32
- # ### commands auto-generated by Alembic - manually adjusted ###
33
- print("Reverting embedding column type to vector(1536)...")
34
-
35
- # This reverts the change if you ever need to.
36
- op.execute("ALTER TABLE public.embeddings ALTER COLUMN embedding TYPE vector(1536);")
37
-
38
- print("Done.")
39
- # ### end Alembic commands ###
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
alembic/versions/4de3dfd809bd_add_sources_to_conversation_history.py DELETED
@@ -1,40 +0,0 @@
1
- """add sources to conversation history
2
-
3
- Revision ID: 4de3dfd809bd
4
- Revises: 293bf8cae101
5
- Create Date: 2025-11-19 02:53:46.860280
6
-
7
- """
8
- from typing import Sequence, Union
9
-
10
- from alembic import op
11
- import sqlalchemy as sa
12
-
13
-
14
- # revision identifiers, used by Alembic.
15
- revision: str = '4de3dfd809bd'
16
- down_revision: Union[str, Sequence[str], None] = '293bf8cae101'
17
- branch_labels: Union[str, Sequence[str], None] = None
18
- depends_on: Union[str, Sequence[str], None] = None
19
-
20
-
21
- def upgrade() -> None:
22
- """Upgrade schema."""
23
- # ### commands auto generated by Alembic - please adjust! ###
24
- op.add_column('conversation_history', sa.Column('sources', sa.JSON(), nullable=True))
25
- # op.drop_index(op.f('embeddings_chat_id_idx'), table_name='embeddings')
26
- # op.drop_index(op.f('embeddings_chat_source_idx'), table_name='embeddings')
27
- # op.drop_index(op.f('embeddings_embedding_ivfflat'), table_name='embeddings', postgresql_with={'lists': '100'}, postgresql_using='ivfflat')
28
- # op.create_index(op.f('ix_embeddings_chat_id'), 'embeddings', ['chat_id'], unique=False)
29
- # ### end Alembic commands ###
30
-
31
-
32
- def downgrade() -> None:
33
- """Downgrade schema."""
34
- # ### commands auto generated by Alembic - please adjust! ###
35
- # op.drop_index(op.f('ix_embeddings_chat_id'), table_name='embeddings')
36
- # op.create_index(op.f('embeddings_embedding_ivfflat'), 'embeddings', ['embedding'], unique=False, postgresql_with={'lists': '100'}, postgresql_using='ivfflat')
37
- # op.create_index(op.f('embeddings_chat_source_idx'), 'embeddings', ['chat_id', 'source_table', 'source_id'], unique=False)
38
- # op.create_index(op.f('embeddings_chat_id_idx'), 'embeddings', ['chat_id'], unique=False)
39
- op.drop_column('conversation_history', 'sources')
40
- # ### end Alembic commands ###
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
alembic/versions/50eb9bd827e5_version_8.py DELETED
@@ -1,32 +0,0 @@
1
- """Version 8
2
-
3
- Revision ID: 50eb9bd827e5
4
- Revises: bbbad8eb92e1
5
- Create Date: 2025-10-18 02:49:04.517995
6
-
7
- """
8
- from typing import Sequence, Union
9
-
10
- from alembic import op
11
- import sqlalchemy as sa
12
-
13
-
14
- # revision identifiers, used by Alembic.
15
- revision: str = '50eb9bd827e5'
16
- down_revision: Union[str, Sequence[str], None] = 'bbbad8eb92e1'
17
- branch_labels: Union[str, Sequence[str], None] = None
18
- depends_on: Union[str, Sequence[str], None] = None
19
-
20
-
21
- def upgrade() -> None:
22
- """Upgrade schema."""
23
- # ### commands auto generated by Alembic - please adjust! ###
24
- op.add_column('chats', sa.Column('cancel_requested', sa.Boolean(), nullable=True))
25
- # ### end Alembic commands ###
26
-
27
-
28
- def downgrade() -> None:
29
- """Downgrade schema."""
30
- # ### commands auto generated by Alembic - please adjust! ###
31
- op.drop_column('chats', 'cancel_requested')
32
- # ### end Alembic commands ###
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
alembic/versions/51088f15a504_version_5.py DELETED
@@ -1,32 +0,0 @@
1
- """version 5
2
-
3
- Revision ID: 51088f15a504
4
- Revises: 576915939aff
5
- Create Date: 2025-10-12 02:43:12.573367
6
-
7
- """
8
- from typing import Sequence, Union
9
-
10
- from alembic import op
11
- import sqlalchemy as sa
12
-
13
-
14
- # revision identifiers, used by Alembic.
15
- revision: str = '51088f15a504'
16
- down_revision: Union[str, Sequence[str], None] = '576915939aff'
17
- branch_labels: Union[str, Sequence[str], None] = None
18
- depends_on: Union[str, Sequence[str], None] = None
19
-
20
-
21
- def upgrade() -> None:
22
- """Upgrade schema."""
23
- # ### commands auto generated by Alembic - please adjust! ###
24
- op.add_column('chats', sa.Column('sentiment_status', sa.String(length=30), nullable=False))
25
- # ### end Alembic commands ###
26
-
27
-
28
- def downgrade() -> None:
29
- """Downgrade schema."""
30
- # ### commands auto generated by Alembic - please adjust! ###
31
- op.drop_column('chats', 'sentiment_status')
32
- # ### end Alembic commands ###
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
alembic/versions/576915939aff_version_4.py DELETED
@@ -1,109 +0,0 @@
1
- """version 4
2
-
3
- Revision ID: 576915939aff
4
- Revises: 9c071425ee4a
5
- Create Date: 2025-10-10 17:57:04.404537
6
-
7
- """
8
- from typing import Sequence, Union
9
-
10
- from alembic import op
11
- import sqlalchemy as sa
12
- from sqlalchemy.dialects import postgresql
13
-
14
- # revision identifiers, used by Alembic.
15
- revision: str = '576915939aff'
16
- down_revision: Union[str, Sequence[str], None] = '9c071425ee4a'
17
- branch_labels: Union[str, Sequence[str], None] = None
18
- depends_on: Union[str, Sequence[str], None] = None
19
-
20
-
21
- def upgrade() -> None:
22
- """Upgrade schema."""
23
- # ### commands auto generated by Alembic - please adjust! ###
24
- op.create_table('segments_time',
25
- sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
26
- sa.Column('chat_id', sa.Integer(), nullable=False),
27
- sa.Column('start_time', sa.DateTime(), nullable=False),
28
- sa.Column('end_time', sa.DateTime(), nullable=False),
29
- sa.Column('duration_minutes', sa.Float(), nullable=False),
30
- sa.Column('message_count', sa.Integer(), nullable=False),
31
- sa.Column('summary', sa.Text(), nullable=True),
32
- sa.ForeignKeyConstraint(['chat_id'], ['chats.id'], ondelete='CASCADE'),
33
- sa.PrimaryKeyConstraint('id')
34
- )
35
- op.create_table('segments_sender',
36
- sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
37
- sa.Column('time_segment_id', sa.Integer(), nullable=False),
38
- sa.Column('sender_id', sa.Integer(), nullable=False),
39
- sa.Column('message_count', sa.Integer(), nullable=False),
40
- sa.Column('combined_text', sa.Text(), nullable=False),
41
- sa.ForeignKeyConstraint(['sender_id'], ['participants.id'], ondelete='CASCADE'),
42
- sa.ForeignKeyConstraint(['time_segment_id'], ['segments_time.id'], ondelete='CASCADE'),
43
- sa.PrimaryKeyConstraint('id')
44
- )
45
- op.create_table('message_sentiments',
46
- sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
47
- sa.Column('message_id', sa.Integer(), nullable=False),
48
- sa.Column('overall_label', sa.String(length=50), nullable=False),
49
- sa.Column('score_positive', sa.Float(), nullable=False),
50
- sa.Column('score_negative', sa.Float(), nullable=False),
51
- sa.Column('score_neutral', sa.Float(), nullable=False),
52
- sa.Column('sentences_summary', sa.JSON(), nullable=True),
53
- sa.Column('opinions', sa.JSON(), nullable=True),
54
- sa.Column('api_version', sa.String(length=50), nullable=True),
55
- sa.Column('analysis_timestamp', sa.DateTime(timezone=True), nullable=False),
56
- sa.Column('error_code', sa.String(length=100), nullable=True),
57
- sa.Column('error_message', sa.Text(), nullable=True),
58
- sa.ForeignKeyConstraint(['message_id'], ['messages.id'], ondelete='CASCADE'),
59
- sa.PrimaryKeyConstraint('id')
60
- )
61
- op.create_index(op.f('ix_message_sentiments_message_id'), 'message_sentiments', ['message_id'], unique=True)
62
- op.create_table('segment_sentiments',
63
- sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
64
- sa.Column('sender_segment_id', sa.Integer(), nullable=False),
65
- sa.Column('overall_label', sa.String(length=50), nullable=False),
66
- sa.Column('score_positive', sa.Float(), nullable=False),
67
- sa.Column('score_negative', sa.Float(), nullable=False),
68
- sa.Column('score_neutral', sa.Float(), nullable=False),
69
- sa.Column('sentences_summary', sa.JSON(), nullable=True),
70
- sa.Column('opinions', sa.JSON(), nullable=True),
71
- sa.Column('api_version', sa.String(length=50), nullable=True),
72
- sa.Column('analysis_timestamp', sa.DateTime(timezone=True), nullable=False),
73
- sa.Column('error_code', sa.String(length=100), nullable=True),
74
- sa.Column('error_message', sa.Text(), nullable=True),
75
- sa.ForeignKeyConstraint(['sender_segment_id'], ['segments_sender.id'], ondelete='CASCADE'),
76
- sa.PrimaryKeyConstraint('id')
77
- )
78
- op.create_index(op.f('ix_segment_sentiments_sender_segment_id'), 'segment_sentiments', ['sender_segment_id'], unique=True)
79
- op.drop_table('sentiments')
80
- # ### end Alembic commands ###
81
-
82
-
83
- def downgrade() -> None:
84
- """Downgrade schema."""
85
- # ### commands auto generated by Alembic - please adjust! ###
86
- op.create_table('sentiments',
87
- sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
88
- sa.Column('message_id', sa.INTEGER(), autoincrement=False, nullable=False),
89
- sa.Column('overall_label', sa.VARCHAR(length=50), autoincrement=False, nullable=False),
90
- sa.Column('score_positive', sa.DOUBLE_PRECISION(precision=53), autoincrement=False, nullable=False),
91
- sa.Column('score_negative', sa.DOUBLE_PRECISION(precision=53), autoincrement=False, nullable=False),
92
- sa.Column('score_neutral', sa.DOUBLE_PRECISION(precision=53), autoincrement=False, nullable=False),
93
- sa.Column('sentences_summary', postgresql.JSON(astext_type=sa.Text()), autoincrement=False, nullable=True),
94
- sa.Column('opinions', postgresql.JSON(astext_type=sa.Text()), autoincrement=False, nullable=True),
95
- sa.Column('api_version', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
96
- sa.Column('analysis_timestamp', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=False),
97
- sa.Column('error_code', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
98
- sa.Column('error_message', sa.TEXT(), autoincrement=False, nullable=True),
99
- sa.ForeignKeyConstraint(['message_id'], ['messages.id'], name=op.f('sentiments_message_id_fkey'), ondelete='CASCADE'),
100
- sa.PrimaryKeyConstraint('id', name=op.f('sentiments_pkey')),
101
- sa.UniqueConstraint('message_id', name=op.f('sentiments_message_id_key'), postgresql_include=[], postgresql_nulls_not_distinct=False)
102
- )
103
- op.drop_index(op.f('ix_segment_sentiments_sender_segment_id'), table_name='segment_sentiments')
104
- op.drop_table('segment_sentiments')
105
- op.drop_index(op.f('ix_message_sentiments_message_id'), table_name='message_sentiments')
106
- op.drop_table('message_sentiments')
107
- op.drop_table('segments_sender')
108
- op.drop_table('segments_time')
109
- # ### end Alembic commands ###
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
alembic/versions/5dd0716c12e2_version_2.py DELETED
@@ -1,32 +0,0 @@
1
- """version 2
2
-
3
- Revision ID: 5dd0716c12e2
4
- Revises: c5e48c9bf293
5
- Create Date: 2025-10-07 02:34:56.649412
6
-
7
- """
8
- from typing import Sequence, Union
9
-
10
- from alembic import op
11
- import sqlalchemy as sa
12
-
13
-
14
- # revision identifiers, used by Alembic.
15
- revision: str = '5dd0716c12e2'
16
- down_revision: Union[str, Sequence[str], None] = 'c5e48c9bf293'
17
- branch_labels: Union[str, Sequence[str], None] = None
18
- depends_on: Union[str, Sequence[str], None] = None
19
-
20
-
21
- def upgrade() -> None:
22
- """Upgrade schema."""
23
- # ### commands auto generated by Alembic - please adjust! ###
24
- pass
25
- # ### end Alembic commands ###
26
-
27
-
28
- def downgrade() -> None:
29
- """Downgrade schema."""
30
- # ### commands auto generated by Alembic - please adjust! ###
31
- pass
32
- # ### end Alembic commands ###
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
alembic/versions/9c071425ee4a_version_3.py DELETED
@@ -1,32 +0,0 @@
1
- """version 3
2
-
3
- Revision ID: 9c071425ee4a
4
- Revises: 5dd0716c12e2
5
- Create Date: 2025-10-07 20:55:00.457665
6
-
7
- """
8
- from typing import Sequence, Union
9
-
10
- from alembic import op
11
- import sqlalchemy as sa
12
-
13
-
14
- # revision identifiers, used by Alembic.
15
- revision: str = '9c071425ee4a'
16
- down_revision: Union[str, Sequence[str], None] = '5dd0716c12e2'
17
- branch_labels: Union[str, Sequence[str], None] = None
18
- depends_on: Union[str, Sequence[str], None] = None
19
-
20
-
21
- def upgrade() -> None:
22
- """Upgrade schema."""
23
- # ### commands auto generated by Alembic - please adjust! ###
24
- pass
25
- # ### end Alembic commands ###
26
-
27
-
28
- def downgrade() -> None:
29
- """Downgrade schema."""
30
- # ### commands auto generated by Alembic - please adjust! ###
31
- pass
32
- # ### end Alembic commands ###
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
alembic/versions/af5827742674_change_embedding_column_type.py DELETED
@@ -1,38 +0,0 @@
1
- """change embedding column type
2
-
3
- Revision ID: af5827742674
4
- Revises: c85ba4afc960
5
- Create Date: 2025-11-28 18:03:09.896314
6
-
7
- """
8
- from typing import Sequence, Union
9
-
10
- from alembic import op
11
- import sqlalchemy as sa
12
-
13
-
14
- # revision identifiers, used by Alembic.
15
- revision: str = 'af5827742674'
16
- down_revision: Union[str, Sequence[str], None] = 'c85ba4afc960'
17
- branch_labels: Union[str, Sequence[str], None] = None
18
- depends_on: Union[str, Sequence[str], None] = None
19
-
20
-
21
- def upgrade() -> None:
22
- """Upgrade schema."""
23
- # ### commands auto generated by Alembic - please adjust! ###
24
- # op.drop_index(op.f('embeddings_chat_id_idx'), table_name='embeddings')
25
- # op.drop_index(op.f('embeddings_chat_source_idx'), table_name='embeddings')
26
- # op.drop_index(op.f('embeddings_embedding_ivfflat'), table_name='embeddings', postgresql_with={'lists': '100'}, postgresql_using='ivfflat')
27
- # op.create_index(op.f('ix_embeddings_chat_id'), 'embeddings', ['chat_id'], unique=False)
28
- # # ### end Alembic commands ###
29
-
30
-
31
- def downgrade() -> None:
32
- """Downgrade schema."""
33
- # ### commands auto generated by Alembic - please adjust! ###
34
- # op.drop_index(op.f('ix_embeddings_chat_id'), table_name='embeddings')
35
- # op.create_index(op.f('embeddings_embedding_ivfflat'), 'embeddings', ['embedding'], unique=False, postgresql_with={'lists': '100'}, postgresql_using='ivfflat')
36
- # op.create_index(op.f('embeddings_chat_source_idx'), 'embeddings', ['chat_id', 'source_table', 'source_id'], unique=False)
37
- # op.create_index(op.f('embeddings_chat_id_idx'), 'embeddings', ['chat_id'], unique=False)
38
- # # ### end Alembic commands ###
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
alembic/versions/bbbad8eb92e1_version_7.py DELETED
@@ -1,44 +0,0 @@
1
- """Version 7
2
-
3
- Revision ID: bbbad8eb92e1
4
- Revises: f8746e269698
5
- Create Date: 2025-10-13 03:43:39.044102
6
-
7
- """
8
- from typing import Sequence, Union
9
-
10
- from alembic import op
11
- import sqlalchemy as sa
12
-
13
-
14
- # revision identifiers, used by Alembic.
15
- revision: str = 'bbbad8eb92e1'
16
- down_revision: Union[str, Sequence[str], None] = 'f8746e269698'
17
- branch_labels: Union[str, Sequence[str], None] = None
18
- depends_on: Union[str, Sequence[str], None] = None
19
-
20
-
21
- def upgrade() -> None:
22
- """Upgrade schema."""
23
- # ### commands auto generated by Alembic - please adjust! ###
24
- op.create_index(op.f('ix_message_sentiments_overall_label'), 'message_sentiments', ['overall_label'], unique=False)
25
- op.create_index(op.f('ix_message_sentiments_overall_label_score'), 'message_sentiments', ['overall_label_score'], unique=False)
26
- op.alter_column('messages', 'raw',
27
- existing_type=sa.TEXT(),
28
- nullable=True)
29
- op.create_index(op.f('ix_segment_sentiments_overall_label'), 'segment_sentiments', ['overall_label'], unique=False)
30
- op.create_index(op.f('ix_segment_sentiments_overall_label_score'), 'segment_sentiments', ['overall_label_score'], unique=False)
31
- # ### end Alembic commands ###
32
-
33
-
34
- def downgrade() -> None:
35
- """Downgrade schema."""
36
- # ### commands auto generated by Alembic - please adjust! ###
37
- op.drop_index(op.f('ix_segment_sentiments_overall_label_score'), table_name='segment_sentiments')
38
- op.drop_index(op.f('ix_segment_sentiments_overall_label'), table_name='segment_sentiments')
39
- op.alter_column('messages', 'raw',
40
- existing_type=sa.TEXT(),
41
- nullable=False)
42
- op.drop_index(op.f('ix_message_sentiments_overall_label_score'), table_name='message_sentiments')
43
- op.drop_index(op.f('ix_message_sentiments_overall_label'), table_name='message_sentiments')
44
- # ### end Alembic commands ###
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
alembic/versions/c4f1502f712e_create_embeddings_table_and_vector_.py DELETED
@@ -1,78 +0,0 @@
1
- """create embeddings table and vector extension
2
-
3
- Revision ID: c4f1502f712e
4
- Revises: d77bd9f8ce2f
5
- Create Date: 2025-11-11 04:08:57.649328
6
-
7
- """
8
- from typing import Sequence, Union
9
-
10
- from alembic import op
11
- import sqlalchemy as sa
12
-
13
-
14
- # revision identifiers, used by Alembic.
15
- revision: str = 'c4f1502f712e'
16
- down_revision: Union[str, Sequence[str], None] = 'd77bd9f8ce2f'
17
- branch_labels: Union[str, Sequence[str], None] = None
18
- depends_on: Union[str, Sequence[str], None] = None
19
-
20
- def upgrade() -> None:
21
- # ### commands auto-generated by Alembic - manually adjusted ###
22
-
23
- # Step 1: Create the vector extension if it doesn't exist
24
- op.execute("CREATE EXTENSION IF NOT EXISTS vector;")
25
-
26
- # Step 2: Create the embeddings table using the exact DDL [cite: 8-17]
27
- # Note: We use 1536 for the embedding dimension as specified [cite: 6, 11]
28
- op.execute("""
29
- CREATE TABLE public.embeddings (
30
- id BIGSERIAL PRIMARY KEY,
31
- chat_id INTEGER NOT NULL,
32
- source_table TEXT NOT NULL,
33
- source_id INTEGER NOT NULL,
34
- embedding vector(1536) NOT NULL,
35
- text_excerpt TEXT,
36
- metadata JSONB DEFAULT '{}'::jsonb,
37
- created_at timestamptz DEFAULT now()
38
- );
39
- """)
40
-
41
- # Step 3: Add the foreign key constraint to your chats table [cite: 18-20]
42
- # This enables ON DELETE CASCADE, as requested by your 4-hour TTL design.
43
- op.execute("""
44
- ALTER TABLE public.embeddings
45
- ADD CONSTRAINT fk_embeddings_chats
46
- FOREIGN KEY (chat_id) REFERENCES public.chats (id) ON DELETE CASCADE;
47
- """)
48
-
49
- # Step 4: Create the standard index for chat_id lookups [cite: 22]
50
- op.execute("""
51
- CREATE INDEX embeddings_chat_id_idx ON public.embeddings (chat_id);
52
- """)
53
-
54
- # Step 5: Create the composite index for common lookups [cite: 24]
55
- op.execute("""
56
- CREATE INDEX embeddings_chat_source_idx ON public.embeddings (chat_id, source_table, source_id);
57
- """)
58
-
59
- # Step 6: Create the ANN index
60
- # Using 'vector_l2_ops' for Euclidean distance, which the guide's
61
- # query operator '<=>' relies on[cite: 132, 141].
62
- op.execute("""
63
- CREATE INDEX embeddings_embedding_ivfflat ON public.embeddings USING ivfflat (embedding vector_l2_ops) WITH (lists=100);
64
- """)
65
-
66
- # ### end Alembic commands ###
67
-
68
-
69
- def downgrade() -> None:
70
- # ### commands auto-generated by Alembic - manually adjusted ###
71
-
72
- # Drop the table, which also drops its indexes and constraints
73
- op.execute("DROP TABLE IF EXISTS public.embeddings;")
74
-
75
- # We'll leave the 'vector' extension in place, as it's harmless
76
- # and dropping it could affect other tables if you use it elsewhere.
77
-
78
- # ### end Alembic commands ###
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
alembic/versions/c5e48c9bf293_initial.py DELETED
@@ -1,79 +0,0 @@
1
- """initial
2
-
3
- Revision ID: c5e48c9bf293
4
- Revises:
5
- Create Date: 2025-10-06 14:10:49.826036
6
-
7
- """
8
- from typing import Sequence, Union
9
-
10
- from alembic import op
11
- import sqlalchemy as sa
12
-
13
-
14
- # revision identifiers, used by Alembic.
15
- revision: str = 'c5e48c9bf293'
16
- down_revision: Union[str, Sequence[str], None] = None
17
- branch_labels: Union[str, Sequence[str], None] = None
18
- depends_on: Union[str, Sequence[str], None] = None
19
-
20
-
21
- def upgrade() -> None:
22
- """Upgrade schema."""
23
- # ### commands auto generated by Alembic - please adjust! ###
24
- op.create_table('chats',
25
- sa.Column('id', sa.Integer(), nullable=False),
26
- sa.Column('title', sa.String(length=255), nullable=True),
27
- sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
28
- sa.PrimaryKeyConstraint('id')
29
- )
30
- op.create_table('participants',
31
- sa.Column('id', sa.Integer(), nullable=False),
32
- sa.Column('chat_id', sa.Integer(), nullable=False),
33
- sa.Column('name', sa.String(length=255), nullable=False),
34
- sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
35
- sa.ForeignKeyConstraint(['chat_id'], ['chats.id'], ondelete='CASCADE'),
36
- sa.PrimaryKeyConstraint('id'),
37
- sa.UniqueConstraint('chat_id', 'name', name='uq_participant_chat_name')
38
- )
39
- op.create_table('messages',
40
- sa.Column('id', sa.Integer(), nullable=False),
41
- sa.Column('chat_id', sa.Integer(), nullable=False),
42
- sa.Column('participant_id', sa.Integer(), nullable=True),
43
- sa.Column('timestamp', sa.DateTime(), nullable=False),
44
- sa.Column('content', sa.Text(), nullable=False),
45
- sa.Column('raw', sa.Text(), nullable=False),
46
- sa.ForeignKeyConstraint(['chat_id'], ['chats.id'], ondelete='CASCADE'),
47
- sa.ForeignKeyConstraint(['participant_id'], ['participants.id'], ),
48
- sa.PrimaryKeyConstraint('id')
49
- )
50
- op.create_index(op.f('ix_messages_timestamp'), 'messages', ['timestamp'], unique=False)
51
- op.create_table('sentiments',
52
- sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
53
- sa.Column('message_id', sa.Integer(), nullable=False),
54
- sa.Column('overall_label', sa.String(length=50), nullable=False),
55
- sa.Column('score_positive', sa.Float(), nullable=False),
56
- sa.Column('score_negative', sa.Float(), nullable=False),
57
- sa.Column('score_neutral', sa.Float(), nullable=False),
58
- sa.Column('sentences_summary', sa.JSON(), nullable=True),
59
- sa.Column('opinions', sa.JSON(), nullable=True),
60
- sa.Column('api_version', sa.String(length=50), nullable=True),
61
- sa.Column('analysis_timestamp', sa.DateTime(timezone=True), nullable=False),
62
- sa.Column('error_code', sa.String(length=100), nullable=True),
63
- sa.Column('error_message', sa.Text(), nullable=True),
64
- sa.ForeignKeyConstraint(['message_id'], ['messages.id'], ondelete='CASCADE'),
65
- sa.PrimaryKeyConstraint('id'),
66
- sa.UniqueConstraint('message_id')
67
- )
68
- # ### end Alembic commands ###
69
-
70
-
71
- def downgrade() -> None:
72
- """Downgrade schema."""
73
- # ### commands auto generated by Alembic - please adjust! ###
74
- op.drop_table('sentiments')
75
- op.drop_index(op.f('ix_messages_timestamp'), table_name='messages')
76
- op.drop_table('messages')
77
- op.drop_table('participants')
78
- op.drop_table('chats')
79
- # ### end Alembic commands ###
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
alembic/versions/c85ba4afc960_add_new_users_table.py DELETED
@@ -1,50 +0,0 @@
1
- """add new users table
2
-
3
- Revision ID: c85ba4afc960
4
- Revises: 4de3dfd809bd
5
- Create Date: 2025-11-23 00:28:21.228614
6
-
7
- """
8
- from typing import Sequence, Union
9
-
10
- from alembic import op
11
- import sqlalchemy as sa
12
-
13
-
14
- # revision identifiers, used by Alembic.
15
- revision: str = 'c85ba4afc960'
16
- down_revision: Union[str, Sequence[str], None] = '4de3dfd809bd'
17
- branch_labels: Union[str, Sequence[str], None] = None
18
- depends_on: Union[str, Sequence[str], None] = None
19
-
20
-
21
- def upgrade() -> None:
22
- """Upgrade schema."""
23
- # ### commands auto generated by Alembic - please adjust! ###
24
- op.create_table('users',
25
- sa.Column('id', sa.UUID(), nullable=False),
26
- sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
27
- sa.PrimaryKeyConstraint('id')
28
- )
29
- op.add_column('chats', sa.Column('owner_id', sa.UUID(), nullable=False))
30
- op.create_index(op.f('ix_chats_owner_id'), 'chats', ['owner_id'], unique=False)
31
- op.create_foreign_key(None, 'chats', 'users', ['owner_id'], ['id'], ondelete='CASCADE')
32
- # op.drop_index(op.f('embeddings_chat_id_idx'), table_name='embeddings')
33
- # op.drop_index(op.f('embeddings_chat_source_idx'), table_name='embeddings')
34
- # op.drop_index(op.f('embeddings_embedding_ivfflat'), table_name='embeddings', postgresql_with={'lists': '100'}, postgresql_using='ivfflat')
35
- # op.create_index(op.f('ix_embeddings_chat_id'), 'embeddings', ['chat_id'], unique=False)
36
- # ### end Alembic commands ###
37
-
38
-
39
- def downgrade() -> None:
40
- """Downgrade schema."""
41
- # ### commands auto generated by Alembic - please adjust! ###
42
- # op.drop_index(op.f('ix_embeddings_chat_id'), table_name='embeddings')
43
- # op.create_index(op.f('embeddings_embedding_ivfflat'), 'embeddings', ['embedding'], unique=False, postgresql_with={'lists': '100'}, postgresql_using='ivfflat')
44
- # op.create_index(op.f('embeddings_chat_source_idx'), 'embeddings', ['chat_id', 'source_table', 'source_id'], unique=False)
45
- # op.create_index(op.f('embeddings_chat_id_idx'), 'embeddings', ['chat_id'], unique=False)
46
- op.drop_constraint(None, 'chats', type_='foreignkey')
47
- op.drop_index(op.f('ix_chats_owner_id'), table_name='chats')
48
- op.drop_column('chats', 'owner_id')
49
- op.drop_table('users')
50
- # ### end Alembic commands ###
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
alembic/versions/d77bd9f8ce2f_version_10.py DELETED
@@ -1,51 +0,0 @@
1
- """Version 10
2
-
3
- Revision ID: d77bd9f8ce2f
4
- Revises: 2bae362756b9
5
- Create Date: 2025-11-02 04:03:43.796576
6
-
7
- """
8
- from typing import Sequence, Union
9
- from alembic import op
10
- import sqlalchemy as sa
11
-
12
- # revision identifiers, used by Alembic.
13
- revision: str = 'd77bd9f8ce2f'
14
- down_revision: Union[str, Sequence[str], None] = '2bae362756b9'
15
- branch_labels: Union[str, Sequence[str], None] = None
16
- depends_on: Union[str, Sequence[str], None] = None
17
-
18
- def upgrade() -> None:
19
- """Upgrade schema."""
20
- # Step 1: Add columns as nullable
21
- op.add_column('messages', sa.Column('word_count', sa.Integer(), nullable=True))
22
- op.add_column('messages', sa.Column('emojis_count', sa.Integer(), nullable=True))
23
- op.add_column('messages', sa.Column('links_count', sa.Integer(), nullable=True))
24
- op.add_column('messages', sa.Column('is_question', sa.Boolean(), nullable=True))
25
- op.add_column('messages', sa.Column('is_media', sa.Boolean(), nullable=True))
26
-
27
- # Step 2: Update existing rows with default values
28
- op.execute("""
29
- UPDATE messages
30
- SET word_count = 0,
31
- emojis_count = 0,
32
- links_count = 0,
33
- is_question = FALSE,
34
- is_media = FALSE
35
- WHERE word_count IS NULL
36
- """)
37
-
38
- # Step 3: Make columns non-nullable
39
- op.alter_column('messages', 'word_count', nullable=False)
40
- op.alter_column('messages', 'emojis_count', nullable=False)
41
- op.alter_column('messages', 'links_count', nullable=False)
42
- op.alter_column('messages', 'is_question', nullable=False)
43
- op.alter_column('messages', 'is_media', nullable=False)
44
-
45
- def downgrade() -> None:
46
- """Downgrade schema."""
47
- op.drop_column('messages', 'is_media')
48
- op.drop_column('messages', 'is_question')
49
- op.drop_column('messages', 'links_count')
50
- op.drop_column('messages', 'emojis_count')
51
- op.drop_column('messages', 'word_count')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
alembic/versions/e817f10d0cc8_rename_metadata_column_on_embeddings_.py DELETED
@@ -1,44 +0,0 @@
1
- """rename metadata column on embeddings table
2
-
3
- Revision ID: e817f10d0cc8
4
- Revises: c4f1502f712e
5
- Create Date: 2025-11-12 11:55:54.052188
6
-
7
- """
8
- from typing import Sequence, Union
9
-
10
- from alembic import op
11
- import sqlalchemy as sa
12
- from sqlalchemy.dialects import postgresql
13
-
14
- # revision identifiers, used by Alembic.
15
- revision: str = 'e817f10d0cc8'
16
- down_revision: Union[str, Sequence[str], None] = 'c4f1502f712e'
17
- branch_labels: Union[str, Sequence[str], None] = None
18
- depends_on: Union[str, Sequence[str], None] = None
19
-
20
-
21
- def upgrade() -> None:
22
- """Upgrade schema."""
23
- # ### commands auto generated by Alembic - please adjust! ###
24
- op.add_column('embeddings', sa.Column('chunk_metadata', sa.JSON(), nullable=True))
25
- op.alter_column('embeddings', 'created_at',
26
- existing_type=postgresql.TIMESTAMP(timezone=True),
27
- nullable=False,
28
- existing_server_default=sa.text('now()'))
29
-
30
- op.drop_column('embeddings', 'metadata')
31
- # ### end Alembic commands ###
32
-
33
-
34
- def downgrade() -> None:
35
- """Downgrade schema."""
36
- # ### commands auto generated by Alembic - please adjust! ###
37
- op.add_column('embeddings', sa.Column('metadata', postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), autoincrement=False, nullable=True))
38
-
39
- op.alter_column('embeddings', 'created_at',
40
- existing_type=postgresql.TIMESTAMP(timezone=True),
41
- nullable=True,
42
- existing_server_default=sa.text('now()'))
43
- op.drop_column('embeddings', 'chunk_metadata')
44
- # ### end Alembic commands ###
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
alembic/versions/f8746e269698_version_6.py DELETED
@@ -1,70 +0,0 @@
1
- """Version 6
2
-
3
- Revision ID: f8746e269698
4
- Revises: 51088f15a504
5
- Create Date: 2025-10-12 20:42:52.982015
6
-
7
- """
8
- from typing import Sequence, Union
9
-
10
- from alembic import op
11
- import sqlalchemy as sa
12
-
13
-
14
- # revision identifiers, used by Alembic.
15
- revision: str = 'f8746e269698'
16
- down_revision: Union[str, Sequence[str], None] = '51088f15a504'
17
- branch_labels: Union[str, Sequence[str], None] = None
18
- depends_on: Union[str, Sequence[str], None] = None
19
-
20
-
21
- def upgrade() -> None:
22
- """Upgrade schema."""
23
- # ### commands auto generated by Alembic - please adjust! ###
24
- op.add_column('message_sentiments', sa.Column('overall_label_score', sa.Float(), nullable=False))
25
- op.alter_column('message_sentiments', 'score_positive',
26
- existing_type=sa.DOUBLE_PRECISION(precision=53),
27
- nullable=True)
28
- op.alter_column('message_sentiments', 'score_negative',
29
- existing_type=sa.DOUBLE_PRECISION(precision=53),
30
- nullable=True)
31
- op.alter_column('message_sentiments', 'score_neutral',
32
- existing_type=sa.DOUBLE_PRECISION(precision=53),
33
- nullable=True)
34
- op.add_column('segment_sentiments', sa.Column('overall_label_score', sa.Float(), nullable=False))
35
- op.alter_column('segment_sentiments', 'score_positive',
36
- existing_type=sa.DOUBLE_PRECISION(precision=53),
37
- nullable=True)
38
- op.alter_column('segment_sentiments', 'score_negative',
39
- existing_type=sa.DOUBLE_PRECISION(precision=53),
40
- nullable=True)
41
- op.alter_column('segment_sentiments', 'score_neutral',
42
- existing_type=sa.DOUBLE_PRECISION(precision=53),
43
- nullable=True)
44
- # ### end Alembic commands ###
45
-
46
-
47
- def downgrade() -> None:
48
- """Downgrade schema."""
49
- # ### commands auto generated by Alembic - please adjust! ###
50
- op.alter_column('segment_sentiments', 'score_neutral',
51
- existing_type=sa.DOUBLE_PRECISION(precision=53),
52
- nullable=False)
53
- op.alter_column('segment_sentiments', 'score_negative',
54
- existing_type=sa.DOUBLE_PRECISION(precision=53),
55
- nullable=False)
56
- op.alter_column('segment_sentiments', 'score_positive',
57
- existing_type=sa.DOUBLE_PRECISION(precision=53),
58
- nullable=False)
59
- op.drop_column('segment_sentiments', 'overall_label_score')
60
- op.alter_column('message_sentiments', 'score_neutral',
61
- existing_type=sa.DOUBLE_PRECISION(precision=53),
62
- nullable=False)
63
- op.alter_column('message_sentiments', 'score_negative',
64
- existing_type=sa.DOUBLE_PRECISION(precision=53),
65
- nullable=False)
66
- op.alter_column('message_sentiments', 'score_positive',
67
- existing_type=sa.DOUBLE_PRECISION(precision=53),
68
- nullable=False)
69
- op.drop_column('message_sentiments', 'overall_label_score')
70
- # ### end Alembic commands ###
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
kb_docs.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ KB_DOCS = [
2
+ {"id": "shipping_times",
3
+ "text": "Standard delivery takes 3–5 business days within major cities and 5–8 business days for other locations. Express delivery (1–2 business days) is available at checkout for an additional fee. Orders placed before 2pm ship the same day."},
4
+ {"id": "shipping_costs", "text": "Standard shipping is free on orders over ₦50,000. Orders below that threshold have a flat ₦2,500 shipping fee. Express shipping is calculated at checkout based on weight and destination."},
5
+ {"id": "returns", "text": "Items can be returned within 14 days of delivery if unused, in original packaging, with proof of purchase. Perishable items and opened personal-care products are not eligible for return."},
6
+ {"id": "refunds", "text": "Once a return is received and inspected, refunds are processed within 5–7 business days to the original payment method. You'll get an email confirmation when the refund is issued."},
7
+ {"id": "tracking", "text": "A tracking link is emailed automatically once an order ships. Tracking can also be checked by logging into your account under 'My Orders.'"},
8
+ {"id": "payment", "text": "We accept debit/credit cards, bank transfer, and major mobile payment wallets. Payment is processed securely at checkout; we never store full card details."},
9
+ {"id": "warranty",
10
+ "text": "All small appliances (blenders, kettles, toasters, etc.) come with a 12-month manufacturer warranty covering defects. Warranty claims require the original receipt and are handled through the manufacturer's service center."},
11
+ {"id": "cookware_care", "text": "Non-stick cookware should be hand-washed with a soft sponge — no metal utensils or abrasive scrubbers, as these damage the coating. Avoid high heat above medium for non-stick pans to preserve the coating's lifespan."},
12
+ {"id": "wholesale", "text": "For orders over 20 units of the same item, contact our sales team directly for wholesale pricing. Bulk orders typically ship within 7–10 business days depending on stock."},
13
+ {"id": "damaged_item", "text": "If an item arrives damaged, contact support within 48 hours with photos of the damage and packaging. A replacement or full refund will be issued — no need to return the damaged item in most cases."},
14
+ {"id": "cancellation", "text": "Orders can be cancelled free of charge within 1 hour of placing them. After that, the order may already be processing and cancellation isn't guaranteed — contact support immediately if this happens."},
15
+ {"id": "gift_wrapping", "text": "Gift wrapping is available at checkout for a small fee and includes a printed gift note if you add a message. Gift cards are available in fixed denominations and don't expire."},
16
+ {"id": "international", "text": "We currently ship only within the country. International shipping isn't available yet, but you can sign up for restock/expansion notifications on the homepage."},
17
+ {"id": "pickup", "text": "Store pickup isn't currently offered — all orders are delivered directly to the address provided at checkout."},
18
+ {"id": "escalation", "text": "If a question falls outside what I can answer, or you'd prefer to speak with a person directly, I'll flag it for the team and someone will follow up by email within one business day."},
19
+ ]
main.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import numpy as np
3
+ from fastapi import FastAPI
4
+ from fastapi.middleware.cors import CORSMiddleware
5
+ from pydantic import BaseModel
6
+ from sentence_transformers import SentenceTransformer
7
+ from groq import Groq
8
+ from dotenv import load_dotenv
9
+ from kb_docs import KB_DOCS
10
+
11
+ load_dotenv()
12
+ app = FastAPI()
13
+
14
+ # Allow your Vercel frontend to reach this backend
15
+ app.add_middleware(
16
+ CORSMiddleware,
17
+ allow_origins=["*"],
18
+ allow_methods=["POST", "GET"],
19
+ allow_headers=["*"],
20
+ )
21
+
22
+ print("Initializing Embedding Model...")
23
+ embed_model = SentenceTransformer("all-MiniLM-L6-v2")
24
+ texts = [d["text"] for d in KB_DOCS]
25
+ kb_embeddings = embed_model.encode(texts, normalize_embeddings=True)
26
+ print("Embeddings loaded into memory.")
27
+
28
+ groq_client = Groq(api_key=os.environ.get("GROQ_API_KEY", ""))
29
+
30
+ SYSTEM_PROMPT = """You are the customer support assistant for Amara Home & Kitchen, an online kitchenware and home-goods retailer.
31
+ Answer ONLY using the information provided below. Keep answers short — 2-4 sentences.
32
+ If the answer isn't in the information provided, say you'll connect them with a human team member rather than guessing.
33
+ Never invent policies, prices, or timelines that aren't in the information.
34
+
35
+ INFORMATION:
36
+ {context}
37
+ """
38
+
39
+
40
+ class ChatRequest(BaseModel):
41
+ question: str
42
+
43
+
44
+ def retrieve(question: str, top_k: int = 3):
45
+ q_emb = embed_model.encode([question], normalize_embeddings=True)[0]
46
+ sims = kb_embeddings @ q_emb
47
+ top_idx = np.argsort(sims)[::-1][:top_k]
48
+ return [KB_DOCS[i]["text"] for i in top_idx]
49
+
50
+
51
+ def ask_groq(question: str, context: str, model: str = "llama-3.3-70b-versatile"):
52
+ resp = groq_client.chat.completions.create(
53
+ model=model,
54
+ messages=[
55
+ {"role": "system", "content": SYSTEM_PROMPT.format(
56
+ context=context)},
57
+ {"role": "user", "content": question},
58
+ ],
59
+ temperature=0.2,
60
+ max_tokens=300,
61
+ )
62
+ return resp.choices[0].message.content
63
+
64
+
65
+ @app.get("/health")
66
+ def health():
67
+ return {"status": "ok"}
68
+
69
+
70
+ @app.post("/api/rag/chat")
71
+ def chat(req: ChatRequest):
72
+ context_chunks = retrieve(req.question)
73
+ context = "\n".join(context_chunks)
74
+ try:
75
+ answer = ask_groq(req.question, context,
76
+ model="llama-3.3-70b-versatile")
77
+ except Exception:
78
+ answer = ask_groq(req.question, context, model="llama-3.1-8b-instant")
79
+ return {"answer": answer}
onnx_model_optimized/config.json DELETED
@@ -1,46 +0,0 @@
1
- {
2
- "adapters": {
3
- "adapters": {},
4
- "config_map": {},
5
- "fusion_config_map": {},
6
- "fusions": {}
7
- },
8
- "architectures": [
9
- "XLMRobertaForSequenceClassification"
10
- ],
11
- "attention_probs_dropout_prob": 0.1,
12
- "bos_token_id": 0,
13
- "classifier_dropout": null,
14
- "eos_token_id": 2,
15
- "gradient_checkpointing": false,
16
- "hidden_act": "gelu",
17
- "hidden_dropout_prob": 0.1,
18
- "hidden_size": 768,
19
- "id2label": {
20
- "0": "positive",
21
- "1": "neutral",
22
- "2": "negative"
23
- },
24
- "initializer_range": 0.02,
25
- "intermediate_size": 3072,
26
- "label2id": {
27
- "negative": 2,
28
- "neutral": 1,
29
- "positive": 0
30
- },
31
- "layer_norm_eps": 1e-05,
32
- "max_length": 512,
33
- "max_position_embeddings": 514,
34
- "model_type": "xlm-roberta",
35
- "num_attention_heads": 6,
36
- "num_hidden_layers": 10,
37
- "output_past": true,
38
- "pad_token_id": 1,
39
- "position_embedding_type": "absolute",
40
- "problem_type": "single_label_classification",
41
- "torch_dtype": "float32",
42
- "transformers_version": "4.55.4",
43
- "type_vocab_size": 1,
44
- "use_cache": true,
45
- "vocab_size": 70006
46
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
onnx_model_optimized/ort_config.json DELETED
@@ -1,33 +0,0 @@
1
- {
2
- "one_external_file": true,
3
- "opset": null,
4
- "optimization": {},
5
- "quantization": {
6
- "activations_dtype": "QUInt8",
7
- "activations_symmetric": false,
8
- "format": "QOperator",
9
- "is_static": false,
10
- "mode": "IntegerOps",
11
- "nodes_to_exclude": [],
12
- "nodes_to_quantize": [],
13
- "operators_to_quantize": [
14
- "Conv",
15
- "MatMul",
16
- "Attention",
17
- "LSTM",
18
- "Gather",
19
- "Transpose",
20
- "EmbedLayerNormalization"
21
- ],
22
- "per_channel": true,
23
- "qdq_add_pair_to_weight": false,
24
- "qdq_dedicated_pair": false,
25
- "qdq_op_type_per_channel_support_to_axis": {
26
- "MatMul": 1
27
- },
28
- "reduce_range": false,
29
- "weights_dtype": "QUInt8",
30
- "weights_symmetric": true
31
- },
32
- "use_external_data_format": false
33
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
onnx_model_optimized/special_tokens_map.json DELETED
@@ -1,51 +0,0 @@
1
- {
2
- "bos_token": {
3
- "content": "<s>",
4
- "lstrip": false,
5
- "normalized": false,
6
- "rstrip": false,
7
- "single_word": false
8
- },
9
- "cls_token": {
10
- "content": "<s>",
11
- "lstrip": false,
12
- "normalized": false,
13
- "rstrip": false,
14
- "single_word": false
15
- },
16
- "eos_token": {
17
- "content": "</s>",
18
- "lstrip": false,
19
- "normalized": false,
20
- "rstrip": false,
21
- "single_word": false
22
- },
23
- "mask_token": {
24
- "content": "<mask>",
25
- "lstrip": false,
26
- "normalized": false,
27
- "rstrip": false,
28
- "single_word": false
29
- },
30
- "pad_token": {
31
- "content": "<pad>",
32
- "lstrip": false,
33
- "normalized": false,
34
- "rstrip": false,
35
- "single_word": false
36
- },
37
- "sep_token": {
38
- "content": "</s>",
39
- "lstrip": false,
40
- "normalized": false,
41
- "rstrip": false,
42
- "single_word": false
43
- },
44
- "unk_token": {
45
- "content": "<unk>",
46
- "lstrip": false,
47
- "normalized": false,
48
- "rstrip": false,
49
- "single_word": false
50
- }
51
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
onnx_model_optimized/tokenizer.json DELETED
The diff for this file is too large to render. See raw diff
 
onnx_model_optimized/tokenizer_config.json DELETED
@@ -1,57 +0,0 @@
1
- {
2
- "added_tokens_decoder": {
3
- "0": {
4
- "content": "<s>",
5
- "lstrip": false,
6
- "normalized": false,
7
- "rstrip": false,
8
- "single_word": false,
9
- "special": true
10
- },
11
- "1": {
12
- "content": "<pad>",
13
- "lstrip": false,
14
- "normalized": false,
15
- "rstrip": false,
16
- "single_word": false,
17
- "special": true
18
- },
19
- "2": {
20
- "content": "</s>",
21
- "lstrip": false,
22
- "normalized": false,
23
- "rstrip": false,
24
- "single_word": false,
25
- "special": true
26
- },
27
- "4": {
28
- "content": "<unk>",
29
- "lstrip": false,
30
- "normalized": false,
31
- "rstrip": false,
32
- "single_word": false,
33
- "special": true
34
- },
35
- "70005": {
36
- "content": "<mask>",
37
- "lstrip": false,
38
- "normalized": false,
39
- "rstrip": false,
40
- "single_word": false,
41
- "special": true
42
- }
43
- },
44
- "bos_token": "<s>",
45
- "clean_up_tokenization_spaces": false,
46
- "cls_token": "<s>",
47
- "do_lower_case": false,
48
- "eos_token": "</s>",
49
- "extra_special_tokens": {},
50
- "mask_token": "<mask>",
51
- "model_max_length": 1000000000000000019884624838656,
52
- "pad_token": "<pad>",
53
- "sep_token": "</s>",
54
- "sp_model_kwargs": {},
55
- "tokenizer_class": "XLMRobertaTokenizerFast",
56
- "unk_token": "<unk>"
57
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
pytest.ini DELETED
@@ -1,2 +0,0 @@
1
- [pytest]
2
- pythonpath = .
 
 
 
requirements.txt CHANGED
@@ -1,125 +1,6 @@
1
- --extra-index-url https://download.pytorch.org/whl/cpu
2
-
3
- # --- Core Async & Web ---
4
- aiofiles
5
- aiohappyeyeballs
6
- aiohttp
7
- aiosignal
8
- aiosqlite
9
- anyio
10
- asyncpg
11
- fastapi
12
- h11
13
- httpcore
14
- httpx
15
- httpx-sse
16
- starlette
17
- uvicorn
18
- python-multipart
19
- slowapi
20
- limits
21
- email-validator
22
- dnspython
23
-
24
- # --- Database & Migrations ---
25
- alembic
26
- sqlalchemy
27
- psycopg-binary
28
- psycopg
29
- redis
30
- greenlet
31
- pgvector
32
- qdrant-client
33
-
34
- # --- Auth & Security ---
35
- PyJWT
36
- passlib[bcrypt]
37
- python-jose
38
- python-dotenv
39
-
40
- # --- AI, ML & Data Science ---
41
- torch
42
- transformers
43
- optimum[onnxruntime]
44
- onnxruntime
45
- onnx
46
- scikit-learn
47
- scipy
48
- numpy
49
- pandas
50
- networkx
51
- tiktoken
52
- tokenizers
53
- safetensors
54
- regex
55
- pydantic
56
- pydantic-settings
57
- pydantic_core
58
-
59
- # --- Azure & OpenAI ---
60
- openai
61
- azure-ai-inference
62
- azure-ai-textanalytics
63
- azure-common
64
- azure-core
65
-
66
- # --- LangChain Ecosystem ---
67
- langchain
68
- langchain-classic
69
- langchain-community
70
- langchain-core
71
- langchain-openai
72
- langchain-text-splitters
73
- langgraph
74
- langgraph-checkpoint
75
- langgraph-prebuilt
76
- langgraph-sdk
77
- langsmith
78
-
79
- # --- Celery & Queue ---
80
- celery
81
- kombu
82
- amqp
83
- billiard
84
- vine
85
-
86
- # --- Utilities & processing ---
87
- requests
88
- requests-toolbelt
89
- tqdm
90
- colorama
91
- coloredlogs
92
- click
93
- click-didyoumean
94
- click-plugins
95
- click-repl
96
- cachetools
97
- charset-normalizer
98
- idna
99
- jinja2
100
- joblib
101
- marshmallow
102
- multidict
103
- packaging
104
- pillow
105
- protobuf
106
- python-dateutil
107
- pytz
108
- pyyaml
109
- six
110
- tenacity
111
- typing-extensions
112
- urllib3
113
- wrapt
114
- xxhash
115
- yarl
116
- zstandard
117
- fsspec
118
- huggingface-hub
119
- filelock
120
- frozenlist
121
- attrs
122
- annotated-types
123
- certifi
124
- soupsieve
125
- beautifulsoup4
 
1
+ fastapi==0.115.0
2
+ uvicorn[standard]==0.32.0
3
+ sentence-transformers==3.3.1
4
+ numpy==1.26.4
5
+ groq==0.13.0
6
+ python-dotenv==1.0.1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/app/__init__.py DELETED
File without changes
src/app/celery_app.py DELETED
@@ -1,54 +0,0 @@
1
- # src/app/celery_app.py
2
-
3
- import os
4
- import ssl
5
- from celery import Celery
6
- from src.app.config import settings
7
-
8
-
9
- # Priority list for the broker
10
- BROKER_FAILOVER_LIST = [str(settings.CELERY_BROKER_URL), "redis://localhost:6379/0"]
11
-
12
- celery_app = Celery(
13
- "chat_processor",
14
- broker=BROKER_FAILOVER_LIST, # Celery will try these in order
15
- include=["src.app.services.sentiment_worker", "src.app.services.embedding_worker"]
16
- )
17
-
18
- ssl_options = {
19
- 'ssl_cert_reqs': ssl.CERT_NONE
20
- }
21
-
22
- celery_app.conf.update(
23
- # Broker & Backend Settings
24
- result_backend=settings.CELERY_RESULT_BACKEND,
25
- broker_use_ssl=ssl_options,
26
- redis_backend_use_ssl=ssl_options,
27
-
28
- # Critical Connection Resilience
29
- broker_transport_options={
30
- 'visibility_timeout': 3600,
31
- 'socket_timeout': 30,
32
- 'socket_keepalive': True,
33
- 'health_check_interval': 15, # Ping every 15s to keep Upstash alive
34
- 'retry_on_timeout': True,
35
- },
36
-
37
- # Task Settings
38
- task_serializer="json",
39
- accept_content=["json"],
40
- result_serializer="json",
41
- timezone="UTC",
42
- enable_utc=True,
43
- task_acks_late=True, # Critical: allows continuation after fallback
44
-
45
- # Worker Settings
46
- worker_prefetch_multiplier=1,
47
- worker_cancel_long_running_tasks_on_connection_loss=True,
48
-
49
- # Routing
50
- task_routes={
51
- 'src.app.services.embedding_worker.generate_embeddings_task': {'queue': 'embeddings'},
52
- 'src.app.services.sentiment_worker.analyze_sentiment_task': {'queue': 'sentiment'},
53
- }
54
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/app/config.py DELETED
@@ -1,122 +0,0 @@
1
- from pydantic import AnyUrl, AnyHttpUrl, field_validator, PostgresDsn, model_validator
2
- from pydantic_settings import BaseSettings, SettingsConfigDict
3
- from typing import Optional, List
4
- import secrets
5
- import logging
6
- import logging.config
7
-
8
-
9
-
10
- class Settings(BaseSettings):
11
- model_config = SettingsConfigDict(env_file=".env")
12
-
13
- PROJECT_NAME: str = "SentimentScope API"
14
- APP_NAME: str = PROJECT_NAME
15
- ENVIRONMENT: str = "development"
16
-
17
- DB_USER: str
18
- DB_PASSWORD: str
19
- DB_HOST: str
20
- DB_PORT: int
21
- DB_NAME: str
22
- DATABASE_URL: Optional[PostgresDsn] = None
23
-
24
- # Azure OpenAI configs...
25
- AZURE_OPENAI_ENDPOINT: AnyHttpUrl
26
- AZURE_OPENAI_API_KEY: str
27
- AZURE_OPENAI_DEPLOYMENT: str
28
- AZURE_OPENAI_API_VERSION: str
29
- AZURE_OPENAI_ENDPOINT_CONTEXT_4: AnyHttpUrl
30
- AZURE_OPENAI_DEPLOYMENT_CONTEXT_4: str
31
- AZURE_OPENAI_API_KEY_CONTEXT_4: str
32
- AZURE_OPENAI_API_VERSION_CONTEXT_4: str
33
- AZURE_OPENAI_ENDPOINT_ROUTER: AnyHttpUrl
34
- AZURE_OPENAI_DEPLOYMENT_ROUTER: str
35
- AZURE_OPENAI_API_KEY_ROUTER: str
36
- AZURE_OPENAI_API_VERSION_ROUTER: str
37
- # AZURE_OPENAI_ENDPOINT_MAIN: AnyHttpUrl
38
- # AZURE_OPENAI_DEPLOYMENT_MAIN: str
39
- # AZURE_OPENAI_API_KEY_MAIN: str
40
- # AZURE_OPENAI_API_VERSION_MAIN: str
41
- AZURE_OPENAI_ENDPOINT_CONTEXT_4O: AnyHttpUrl
42
- AZURE_OPENAI_DEPLOYMENT_CONTEXT_4O: str
43
- AZURE_OPENAI_API_KEY_CONTEXT_4O: str
44
- AZURE_OPENAI_API_VERSION_CONTEXT_4O: str
45
- AZURE_OPENAI_ENDPOINT_MAIN_4O: AnyHttpUrl
46
- AZURE_OPENAI_DEPLOYMENT_MAIN_4O: str
47
- AZURE_OPENAI_API_KEY_MAIN_4O: str
48
- AZURE_OPENAI_API_VERSION_MAIN_4O: str
49
- AZURE_OPENAI_ENDPOINT_MAIN_4O_MINI: AnyHttpUrl
50
- AZURE_OPENAI_DEPLOYMENT_MAIN_4O_MINI: str
51
- AZURE_OPENAI_API_KEY_MAIN_4O_MINI: str
52
- AZURE_OPENAI_API_VERSION_MAIN_4O_MINI: str
53
- AZURE_LANGUAGE_ENDPOINT: Optional[str] = None
54
- AZURE_LANGUAGE_KEY: Optional[str] = None
55
-
56
- SECRET_KEY: str = secrets.token_urlsafe(32)
57
- IS_CELERY_WORKER: str = "false"
58
- CELERY_BROKER_URL: Optional[str] = None
59
- CELERY_RESULT_BACKEND: Optional[str] = None
60
- QDRANT_URL: str
61
- QDRANT_API_KEY: str
62
- CORS_ORIGINS: List[str] = []
63
- JWT_ALGORITHM: str
64
- JWT_ACCESS_TOKEN_EXPIRE_DAYS: int
65
-
66
- CORS_ALLOW_CREDENTIALS: bool = True
67
- TRUSTED_HOSTS: List[str] = ["*"]
68
-
69
-
70
- DB_CONNECT_RETRIES: int = 3
71
- DB_CONNECT_BACKOFF_SECONDS: int = 2
72
-
73
- DEBUG: bool = True
74
- LOG_LEVEL: str = "INFO"
75
-
76
- MAX_UPLOAD_SIZE_BYTES: int = 10 * 1024 * 1024 # 10 MB
77
-
78
- @model_validator(mode="before")
79
- @classmethod
80
- def assemble_database_url(cls, values):
81
- # 1. Check if URL is provided directly
82
- if values.get("DATABASE_URL"):
83
- url = str(values.get("DATABASE_URL"))
84
- # If it starts with postgresql:// but not postgresql+asyncpg://, fix it
85
- if url.startswith("postgresql://") and "asyncpg" not in url:
86
- values["DATABASE_URL"] = url.replace("postgresql://", "postgresql+asyncpg://", 1)
87
-
88
- # 2. If no URL provided, assemble it from parts (your existing logic)
89
- elif not values.get("DATABASE_URL"):
90
- user = values.get("DB_USER")
91
- password = values.get("DB_PASSWORD")
92
- host = values.get("DB_HOST")
93
- port = values.get("DB_PORT")
94
- db = values.get("DB_NAME")
95
-
96
- values["DATABASE_URL"] = (
97
- f"postgresql+asyncpg://{user}:{password}@{host}:{port}/{db}"
98
- )
99
-
100
- return values
101
-
102
- settings = Settings()
103
-
104
-
105
-
106
- def setup_logging():
107
- """Configure global logging level and format based on settings."""
108
- log_level = getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO)
109
-
110
- logging.basicConfig(
111
- level=log_level,
112
- format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
113
- datefmt="%Y-%m-%d %H:%M:%S",
114
- )
115
-
116
- # Suppress noisy loggers from dependencies
117
- # logging.getLogger("uvicorn").setLevel(logging.WARNING)
118
- # logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
119
- logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
120
- # logging.getLogger("asyncio").setLevel(logging.WARNING)
121
- # logging.getLogger("httpx").setLevel(logging.WARNING)
122
- logging.getLogger("multipart").setLevel(logging.WARNING)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/app/crud.py DELETED
@@ -1,783 +0,0 @@
1
- from sqlalchemy.ext.asyncio import AsyncSession
2
- from sqlalchemy.exc import SQLAlchemyError
3
- from sqlalchemy.future import select
4
- from sqlalchemy import insert, func, delete, select
5
- from sqlalchemy.orm import selectinload, joinedload
6
- from typing import List, Optional, Dict, Tuple, AsyncGenerator
7
- from src.app import models, schemas
8
- from src.app.utils.raw_txt_parser import CleanedMessage
9
- from src.app.utils.message_analytics import compute_message_analytics
10
- from datetime import date, datetime, time
11
- import uuid
12
-
13
- TIME_PERIOD_BUCKETS = {
14
- "Morning": [6, 7, 8, 9, 10, 11],
15
- "Afternoon": [12, 13, 14, 15, 16],
16
- "Evening": [17, 18, 19, 20],
17
- "Night": [21, 22, 23, 0, 1, 2, 3, 4, 5]
18
- }
19
-
20
-
21
- async def create_chat(db: AsyncSession, owner_id: uuid.UUID, title: Optional[str] = None, should_commit: bool = True) -> models.Chat:
22
- new_chat = models.Chat(title=title, owner_id=owner_id)
23
- db.add(new_chat)
24
- await db.flush()
25
-
26
- if should_commit:
27
- await db.commit()
28
- await db.refresh(new_chat)
29
- return new_chat
30
-
31
-
32
- async def get_chat(db: AsyncSession, chat_id: int) -> Optional[models.Chat]:
33
- result = await db.execute(select(models.Chat).where(models.Chat.id == chat_id))
34
- return result.scalars().first()
35
-
36
-
37
-
38
- async def get_all_chats(db: AsyncSession) -> List[models.Chat]:
39
- result = await db.execute(select(models.Chat).order_by(models.Chat.created_at.desc()))
40
- return result.scalars().all()
41
-
42
- async def get_chat_by_name(db: AsyncSession, chat_name: str) -> Optional[models.Chat]:
43
- chat_res = await db.execute(
44
- select(models.Chat).where(models.Chat.title == chat_name.strip())
45
- )
46
- return chat_res.scalars().first()
47
-
48
-
49
-
50
-
51
- async def delete_chat(db: AsyncSession, chat_id: int, should_commit: bool = True) -> bool:
52
- chat = await get_chat(db, chat_id=chat_id)
53
- if chat:
54
- await db.execute(delete(models.Chat).where(models.Chat.id == chat_id))
55
- if should_commit:
56
- await db.commit()
57
- return True
58
- return False
59
-
60
-
61
-
62
- async def request_chat_cancel(db: AsyncSession, chat_id: int) -> bool:
63
- result = await db.execute(select(models.Chat).filter(models.Chat.id == chat_id))
64
- chat = result.scalars().first()
65
- if chat:
66
- chat.cancel_requested = True
67
- await db.commit()
68
- return True
69
- return False
70
-
71
-
72
- async def is_chat_cancelled(db: AsyncSession, chat_id: int) -> bool:
73
- result = await db.execute(select(models.Chat.cancel_requested).filter(models.Chat.id == chat_id))
74
- cancelled = result.scalar()
75
- return bool(cancelled)
76
-
77
- async def update_chat_status(db: AsyncSession, chat_id: int, status: str):
78
- chat = await get_chat(db, chat_id)
79
- if chat:
80
- chat.sentiment_status = status
81
- db.add(chat)
82
- await db.commit()
83
-
84
- async def update_chat_embedding_status(db: AsyncSession, chat_id: int, status: str, should_commit: bool = True):
85
- chat = await get_chat(db, chat_id)
86
- if chat:
87
- chat.embeddings_status = status
88
- db.add(chat)
89
- if should_commit:
90
- await db.commit()
91
-
92
-
93
- async def get_chat_embedding_status(db: AsyncSession, chat_id: int) -> Optional[str]:
94
- """
95
- Quickly get just the embeddings_status of a chat.
96
- """
97
- result = await db.execute(
98
- select(models.Chat.embeddings_status)
99
- .where(models.Chat.id == chat_id)
100
- )
101
- return result.scalar_one_or_none()
102
-
103
- async def get_chat_status(db: AsyncSession, chat_id: int) -> Optional[str]:
104
- """
105
- Quickly get just the sentiment_status of a chat.
106
- """
107
- result = await db.execute(
108
- select(models.Chat.sentiment_status)
109
- .where(models.Chat.id == chat_id)
110
- )
111
- return result.scalar_one_or_none()
112
-
113
- async def get_conversation_history(
114
- db: AsyncSession,
115
- chat_id: int,
116
- limit: int = 6 # Fetch last 6 turns (3 pairs) to save tokens
117
- ) -> List[models.ConversationHistory]:
118
- stmt = (
119
- select(models.ConversationHistory)
120
- .where(models.ConversationHistory.chat_id == chat_id)
121
- .order_by(models.ConversationHistory.created_at.desc()) # Get newest first
122
- .limit(limit)
123
- )
124
- result = await db.execute(stmt)
125
- # Reverse to return chronological order (Oldest -> Newest)
126
- return result.scalars().all()[::-1]
127
-
128
- async def add_conversation_turn(
129
- db: AsyncSession,
130
- chat_id: int,
131
- user_q: str,
132
- ai_a: str,
133
- sources: Optional[List[dict]] = None
134
- ):
135
- """Saves both user query and AI response in a transaction."""
136
- user_msg = models.ConversationHistory(chat_id=chat_id, role="user", content=user_q, sources=None)
137
- ai_msg = models.ConversationHistory(chat_id=chat_id, role="assistant", content=ai_a, sources=sources)
138
- db.add_all([user_msg, ai_msg])
139
- await db.commit()
140
-
141
- async def get_messages_batch(
142
- db: AsyncSession,
143
- chat_id: int,
144
- limit: int,
145
- offset: int,
146
- min_date: Optional[datetime] = None
147
- ) -> List[models.Message]:
148
- """
149
- Fetches a batch of messages for a chat with optional filtering by minimum date.
150
- """
151
- stmt = (
152
- select(models.Message)
153
- .where(models.Message.chat_id == chat_id)
154
- )
155
-
156
- # Optional min timestamp
157
- if min_date:
158
- stmt = stmt.where(models.Message.timestamp >= min_date)
159
-
160
- # Pagination + ordering
161
- stmt = (
162
- stmt.order_by(models.Message.timestamp.desc())
163
- .limit(limit)
164
- .offset(offset)
165
- )
166
-
167
- result = await db.execute(stmt)
168
- return result.scalars().all()
169
-
170
-
171
-
172
-
173
-
174
-
175
-
176
-
177
-
178
-
179
-
180
- async def clear_conversation_history(db: AsyncSession, chat_id: int):
181
-
182
- await db.execute(
183
- delete(models.ConversationHistory)
184
- .where(models.ConversationHistory.chat_id == chat_id)
185
- )
186
- await db.commit()
187
-
188
- async def get_full_conversation_history(
189
- db: AsyncSession,
190
- chat_id: int
191
- ) -> List[models.ConversationHistory]:
192
- """
193
- Fetches the complete history for a chat session to restore the UI state.
194
- Ordered purely chronologically (Oldest -> Newest).
195
- """
196
- stmt = (
197
- select(models.ConversationHistory)
198
- .where(models.ConversationHistory.chat_id == chat_id)
199
- .order_by(models.ConversationHistory.created_at.asc())
200
- )
201
- result = await db.execute(stmt)
202
- return result.scalars().all()
203
-
204
-
205
- async def create_participant(db: AsyncSession, chat_id: int, name: str, should_commit: bool = True) -> models.Participant:
206
- new_participant = models.Participant(chat_id=chat_id, name=name)
207
- db.add(new_participant)
208
- await db.flush()
209
- if should_commit:
210
- await db.commit()
211
- await db.refresh(new_participant)
212
- return new_participant
213
-
214
-
215
- async def bulk_insert_participants(db: AsyncSession, chat_id: int, names: List[str], should_commit: bool = True) -> List[models.Participant]:
216
- # Fetch existing participants to avoid duplicates
217
- existing_q = await db.execute(select(models.Participant).where(models.Participant.chat_id == chat_id))
218
- already_existing = {p.name: p for p in existing_q.scalars().all()}
219
- to_create = [models.Participant(chat_id=chat_id, name=name) for name in names if name not in already_existing]
220
-
221
- if to_create:
222
- db.add_all(to_create)
223
- await db.flush()
224
-
225
- if should_commit:
226
- await db.commit()
227
- for p in to_create:
228
- await db.refresh(p)
229
-
230
- q = await db.execute(select(models.Participant).where(models.Participant.chat_id == chat_id))
231
- return q.scalars().all()
232
-
233
- async def get_participants_by_chat(db: AsyncSession, chat_id: int) -> List[models.Participant]:
234
- result = await db.execute(select(models.Participant).where(models.Participant.chat_id == chat_id))
235
- return result.scalars().all()
236
-
237
-
238
-
239
- async def create_message(db: AsyncSession, message: schemas.MessageCreate, chat_id: int, participant_id: Optional[int], should_commit: bool = True) -> models.Message:
240
- # Compute analytics for the message
241
- analytics = compute_message_analytics(message.content)
242
-
243
- new_msg = models.Message(
244
- chat_id=chat_id,
245
- participant_id=participant_id,
246
- timestamp=message.timestamp,
247
- content=message.content,
248
- raw=getattr(message, "raw", None) or message.content, # Use content if raw is not provided
249
- word_count=analytics['word_count'],
250
- emojis_count=analytics['emojis_count'],
251
- links_count=analytics['links_count'],
252
- is_question=analytics['is_question'],
253
- is_media=analytics['is_media']
254
- )
255
- db.add(new_msg)
256
- await db.flush()
257
- if should_commit:
258
- await db.commit()
259
- await db.refresh(new_msg)
260
- return new_msg
261
-
262
-
263
- async def bulk_insert_messages(
264
- db: AsyncSession,
265
- messages: List[dict],
266
- should_commit: bool = True
267
- ) -> List[int]:
268
- if not messages:
269
- return []
270
-
271
- try:
272
- CHUNK_SIZE = 2500
273
- inserted_ids = []
274
-
275
- for i in range(0, len(messages), CHUNK_SIZE):
276
- chunk = messages[i:i + CHUNK_SIZE]
277
- stmt = insert(models.Message).values(chunk).returning(models.Message.id)
278
- result = await db.execute(stmt)
279
- inserted_ids.extend(result.scalars().all())
280
-
281
- if should_commit:
282
- await db.commit()
283
-
284
- return inserted_ids
285
-
286
- except SQLAlchemyError:
287
- await db.rollback()
288
- raise
289
-
290
-
291
-
292
- async def get_messages_by_chat(db: AsyncSession, chat_id: int) -> List[models.Message]:
293
- result = await db.execute(select(models.Message).where(models.Message.chat_id == chat_id))
294
- return result.scalars().all()
295
-
296
- async def get_all_sender_segments_for_chat(db: AsyncSession, chat_id: int) -> List[models.SenderSegment]:
297
- stmt = (
298
- select(models.SenderSegment)
299
- .join(models.TimeSegment)
300
- .where(models.TimeSegment.chat_id == chat_id)
301
- )
302
- result = await db.execute(stmt)
303
- return result.scalars().all()
304
-
305
- async def get_segments_batch(
306
- db: AsyncSession,
307
- chat_id: int,
308
- limit: int,
309
- offset: int,
310
- min_date: Optional[datetime] = None
311
- ) -> List[models.SenderSegment]:
312
-
313
- stmt = (
314
- select(models.SenderSegment)
315
- .join(models.TimeSegment)
316
- .where(models.TimeSegment.chat_id == chat_id)
317
- )
318
-
319
- if min_date:
320
- stmt = stmt.where(models.TimeSegment.start_time >= min_date)
321
-
322
- stmt = (
323
- stmt.order_by(models.TimeSegment.start_time.desc())
324
- .limit(limit)
325
- .offset(offset)
326
- )
327
-
328
- result = await db.execute(stmt)
329
- return result.scalars().all()
330
-
331
-
332
- # Issues here to handle later
333
- async def delete_message(db: AsyncSession, message_id: int, should_commit: bool = True) -> bool:
334
-
335
- try:
336
- result = await db.execute(select(models.Message).where(models.Message.id == message_id))
337
- message = result.scalars().first()
338
- if not message:
339
- return False
340
-
341
- participant_id = message.participant_id
342
- chat_id = message.chat_id
343
-
344
- await db.delete(message)
345
-
346
- if participant_id:
347
- other_messages_query = await db.execute(
348
- select(models.Message).where(
349
- models.Message.chat_id == chat_id,
350
- models.Message.participant_id == participant_id
351
- )
352
- )
353
-
354
- rem_messages = other_messages_query.scalars().first()
355
- if not rem_messages:
356
- participant = await db.get(models.Participant, participant_id)
357
- if participant:
358
- await db.delete(participant)
359
- if should_commit:
360
- await db.commit()
361
-
362
- return True
363
- except Exception:
364
- if should_commit:
365
- await db.rollback()
366
- raise
367
-
368
- async def create_sender_segment(
369
- db: AsyncSession,
370
- time_segment_id: int,
371
- sender_id: int,
372
- sender_details: dict,
373
- should_commit: bool = True
374
- ):
375
- seg = models.SenderSegment(
376
- time_segment_id=time_segment_id,
377
- sender_id=sender_id,
378
- message_count=sender_details.get("message_count"),
379
- combined_text=sender_details.get("combined_text", "")
380
- )
381
- db.add(seg)
382
- await db.flush()
383
- if should_commit:
384
- await db.commit()
385
- await db.refresh(seg)
386
- return seg
387
-
388
-
389
-
390
- async def create_time_segment(
391
- db: AsyncSession,
392
- chat_id: int,
393
- time_segment_details: dict,
394
- should_commit: bool = True
395
- ) -> models.TimeSegment:
396
- seg = models.TimeSegment(
397
- chat_id=chat_id,
398
- start_time=time_segment_details.get("start_time"),
399
- end_time=time_segment_details.get("end_time"),
400
- duration_minutes=time_segment_details.get("duration_minutes"),
401
- message_count=time_segment_details.get("message_count"),
402
- summary=time_segment_details.get("summary", None)
403
- )
404
- db.add(seg)
405
- await db.flush()
406
- if should_commit:
407
- await db.commit()
408
- await db.refresh(seg)
409
- return seg
410
-
411
-
412
- async def bulk_insert_segments(
413
- db: AsyncSession,
414
- chat_id: int,
415
- participants_map: dict,
416
- time_segments_list: List[dict]
417
- ) -> int:
418
- if not time_segments_list:
419
- return 0
420
-
421
- # Build all TimeSegment objects
422
- time_segments = [
423
- models.TimeSegment(
424
- chat_id=chat_id,
425
- start_time=seg.get("start_time"),
426
- end_time=seg.get("end_time"),
427
- duration_minutes=seg.get("duration_minutes"),
428
- message_count=seg.get("message_count"),
429
- summary=seg.get("summary")
430
- )
431
- for seg in time_segments_list
432
- ]
433
-
434
- # Bulk add time segments
435
- db.add_all(time_segments)
436
- await db.flush() # Assign IDs
437
-
438
- # Build all SenderSegment objects linked to their TimeSegments
439
- sender_segments = []
440
- for seg_obj, seg_dict in zip(time_segments, time_segments_list):
441
- sender_groups = seg_dict.get("sender_groups", {})
442
- for sender, details in sender_groups.items():
443
- sender_id = participants_map.get(sender)
444
- if not sender_id:
445
- continue
446
- sender_segments.append(models.SenderSegment(
447
- time_segment_id=seg_obj.id,
448
- sender_id=sender_id,
449
- message_count=details.get("message_count"),
450
- combined_text=details.get("combined_text", "")
451
- ))
452
-
453
- if sender_segments:
454
- db.add_all(sender_segments)
455
- await db.flush()
456
-
457
- return len(time_segments)
458
-
459
- async def get_sender_segments_by_sender_id(db: AsyncSession, sender_id: int) -> List[models.SenderSegment]:
460
- result = await db.execute(
461
- select(models.SenderSegment)
462
- .where(models.SenderSegment.sender_id == sender_id)
463
- )
464
- return result.scalars().all()
465
-
466
- async def get_all_time_segments(db: AsyncSession, chat_id: int) -> List[models.TimeSegment]:
467
- result = await db.execute(
468
- select(models.TimeSegment)
469
- .where(models.TimeSegment.chat_id == chat_id)
470
- )
471
- return result.scalars().all()
472
-
473
- async def create_message_sentiment(
474
- db: AsyncSession,
475
- msg_id: int,
476
- payload: dict,
477
- should_commit: bool = True
478
- ) -> models.MessageSentiment:
479
- s = models.MessageSentiment(message_id = msg_id, **payload)
480
- db.add(s)
481
- await db.flush()
482
-
483
- if should_commit:
484
- await db.commit()
485
- await db.refresh(s)
486
- return s
487
-
488
- async def create_segment_sentiment(
489
- db: AsyncSession,
490
- sender_segment_id: int,
491
- payload: dict,
492
- should_commit: bool = True
493
- ) -> models.SegmentSentiment:
494
- s = models.SegmentSentiment(sender_segment_id=sender_segment_id, **payload)
495
- db.add(s)
496
- await db.flush()
497
-
498
- if should_commit:
499
- await db.commit()
500
- await db.refresh(s)
501
- return s
502
-
503
-
504
- async def get_sentiment_progress(db: AsyncSession, chat_id: int):
505
- """
506
- Efficiently gets counts using SQL func.count.
507
- """
508
-
509
- # Use func.count for direct SQL COUNT(*), which is instant
510
- total_messages_q = await db.execute(
511
- select(func.count(models.Message.id))
512
- .where(models.Message.chat_id == chat_id)
513
- )
514
- total_messages = total_messages_q.scalar_one()
515
-
516
- scored_msgs_q = await db.execute(
517
- select(func.count(models.MessageSentiment.id))
518
- .join(models.Message)
519
- .where(models.Message.chat_id == chat_id)
520
- )
521
- total_messages_scored = scored_msgs_q.scalar_one()
522
-
523
- total_segments_q = await db.execute(
524
- select(func.count(models.SenderSegment.id))
525
- .join(models.TimeSegment)
526
- .where(models.TimeSegment.chat_id == chat_id)
527
- )
528
- total_segments = total_segments_q.scalar_one()
529
-
530
- scored_segments_q = await db.execute(
531
- select(func.count(models.SegmentSentiment.id))
532
- .join(models.SenderSegment)
533
- .join(models.TimeSegment)
534
- .where(models.TimeSegment.chat_id == chat_id)
535
- )
536
- total_segments_scored = scored_segments_q.scalar_one()
537
-
538
- return {
539
- "messages_total": total_messages,
540
- "messages_scored": total_messages_scored,
541
- "segments_total": total_segments,
542
- "segments_scored": total_segments_scored
543
- }
544
-
545
- async def get_chat_message_sentiments(db: AsyncSession, chat_id: int) -> Optional[List[models.MessageSentiment]]:
546
- stmt = select(models.MessageSentiment).join(models.Message).where(
547
- models.Message.chat_id == chat_id
548
- )
549
- result = await db.execute(stmt)
550
-
551
- return result.scalars().all()
552
-
553
- async def get_segments_for_summarization(
554
- db: AsyncSession, chat_id: int
555
- ) -> List[models.TimeSegment]:
556
- """
557
- Fetches all TimeSegments for a specific chat that do not
558
- yet have a summary.
559
-
560
- This function is optimized to EAGERLY load the related
561
- 'sender_segments' for each TimeSegment in a single batch,
562
- preventing the N+1 query problem in the summary service.
563
- """
564
-
565
- # 1. Define the query
566
- stmt = (
567
- select(models.TimeSegment)
568
- .where(models.TimeSegment.chat_id == chat_id)
569
- .where(models.TimeSegment.summary == None) # <-- Finds work to do
570
- .options(
571
- selectinload(models.TimeSegment.sender_segments) # <-- The EFFICIENT part
572
- )
573
- .order_by(models.TimeSegment.start_time) # Process segments in order
574
- )
575
-
576
- # 2. Execute the query
577
- result = await db.execute(stmt)
578
-
579
- # 3. Return the TimeSegment objects
580
- # .scalars() gets the TimeSegment objects
581
- # .all() executes and returns the list
582
- segments = result.scalars().all()
583
-
584
- return segments
585
-
586
-
587
- async def get_chat_segment_sentiments(db: AsyncSession, chat_id: int) -> Optional[List[models.SegmentSentiment]]:
588
- stmt = select(models.SegmentSentiment).join(models.SenderSegment).join(
589
- models.TimeSegment
590
- ).where(
591
- models.TimeSegment.chat_id == chat_id
592
- )
593
-
594
- result = await db.execute(stmt)
595
- return result.scalars().all()
596
-
597
- async def get_sentiment_by_message(db: AsyncSession, message_id: int) -> Optional[schemas.SentimentRead]:
598
- result = await db.execute(select(
599
- models.MessageSentiment
600
- )
601
- .where(models.MessageSentiment.message_id == message_id)
602
- )
603
-
604
- if result:
605
- return result.scalars().first()
606
- return None
607
-
608
- async def get_sentiment_by_segment(db: AsyncSession, segment_id: int) -> Optional[schemas.SentimentRead]:
609
- result = await db.execute(
610
- select(
611
- models.SegmentSentiment
612
- )
613
- .where(models.SegmentSentiment.sender_segment_id == segment_id)
614
- )
615
- if result: return result.scalars().first()
616
- return None
617
-
618
- # Ingest Cleaned Chat
619
- async def ingest_cleaned_chat(
620
- db: AsyncSession,
621
- owner_id: uuid.UUID,
622
- chat_name: str,
623
- cleaned_messages: List["CleanedMessage"],
624
- segments_list: List[dict]
625
- ) -> models.Chat:
626
- try:
627
- # Create chat
628
- chat = await create_chat(db, owner_id=owner_id, title=chat_name, should_commit=False)
629
-
630
- # Insert participants
631
- all_chat_participants = {m.sender for m in cleaned_messages if m.sender}
632
- participants = await bulk_insert_participants(
633
- db=db,
634
- chat_id=chat.id,
635
- names=all_chat_participants,
636
- should_commit=False
637
- )
638
-
639
- participants_map = {p.name: p.id for p in participants}
640
-
641
- # Insert time and sender segments (batched)
642
- await bulk_insert_segments(
643
- db=db,
644
- chat_id=chat.id,
645
- participants_map=participants_map,
646
- time_segments_list=segments_list
647
- )
648
- # Insert messages in bulk (batched)
649
- # We must compute analytics for each message first
650
- message_dicts = []
651
- for msg in cleaned_messages:
652
- # Skip messages without a sender (e.g., system messages)
653
- if not msg.sender:
654
- continue
655
-
656
- # Compute analytics, just like in create_message
657
- analytics = compute_message_analytics(msg.text)
658
-
659
- message_dicts.append(
660
- {
661
- "chat_id": chat.id,
662
- "participant_id": participants_map.get(msg.sender),
663
- "timestamp": msg.timestamp,
664
- "content": msg.text,
665
- # Match the logic from create_message: use content if raw is missing
666
- "raw": getattr(msg, "raw", None) or msg.text,
667
-
668
- # Add the missing analytics fields
669
- "word_count": analytics['word_count'],
670
- "emojis_count": analytics['emojis_count'],
671
- "links_count": analytics['links_count'],
672
- "is_question": analytics['is_question'],
673
- "is_media": analytics['is_media']
674
- }
675
- )
676
-
677
- await bulk_insert_messages(db, message_dicts, should_commit=False)
678
-
679
- # Mark chat status
680
- chat.sentiment_status = "pending"
681
-
682
- # Single final commit
683
- await db.commit()
684
- await db.refresh(chat)
685
- return chat
686
-
687
- except Exception:
688
- await db.rollback()
689
- raise
690
- async def get_unscored_messages(
691
- db: AsyncSession,
692
- chat_id: int,
693
- limit: Optional[int] = None
694
- ):
695
- q = await db.execute(
696
- select(models.Message)
697
- .where(models.Message.chat_id==chat_id)
698
- .outerjoin(models.MessageSentiment)
699
- .where(models.MessageSentiment.id == None)
700
- )
701
-
702
- res = q.scalars().all()
703
-
704
- return res if limit is None else res[:limit]
705
-
706
- async def get_unscored_sender_segments(
707
- db: AsyncSession,
708
- chat_id: int
709
- ):
710
- q = await db.execute(
711
- select(models.SenderSegment).join(models.TimeSegment)
712
- .where(models.TimeSegment.chat_id == chat_id)
713
- .outerjoin(models.SegmentSentiment)
714
- .where(models.SegmentSentiment.id == None)
715
- )
716
-
717
- res = q.scalars().all()
718
-
719
- return res
720
-
721
-
722
- def _get_filtered_query(
723
- chat_id: int,
724
- date_from: Optional[date] = None,
725
- date_to: Optional[date] = None,
726
- participant_names: Optional[List[str]] = None,
727
- time_period: Optional[str] = None
728
- ):
729
- stmt = select(models.Message).where(models.Message.chat_id == chat_id)
730
-
731
- if date_from:
732
- stmt = stmt.where(func.date(models.Message.timestamp) >= date_from)
733
- if date_to:
734
- stmt = stmt.where(func.date(models.Message.timestamp) <= date_to)
735
- if participant_names:
736
- stmt = stmt.join(models.Participant).where(models.Participant.name.in_(participant_names))
737
- if time_period and time_period in TIME_PERIOD_BUCKETS:
738
- hours = TIME_PERIOD_BUCKETS[time_period]
739
- stmt = stmt.where(func.extract('hour', models.Message.timestamp).in_(hours))
740
-
741
- return stmt
742
-
743
-
744
- # --- REFACTORED STREAMING FUNCTION ---
745
- async def stream_unscored_messages(
746
- db: AsyncSession,
747
- chat_id: int
748
- ) -> AsyncGenerator[models.Message, None]:
749
- """
750
- Streams unscored messages one by one using a server-side cursor.
751
- This avoids loading all messages into memory.
752
- """
753
- stmt = (
754
- select(models.Message)
755
- .where(models.Message.chat_id == chat_id)
756
- .outerjoin(models.MessageSentiment)
757
- .where(models.MessageSentiment.id == None)
758
- .order_by(models.Message.timestamp) # Good practice to have an order
759
- )
760
-
761
- stream = await db.stream_scalars(stmt)
762
- async for msg in stream:
763
- yield msg
764
-
765
- # --- REFACTORED STREAMING FUNCTION ---
766
- async def stream_unscored_sender_segments(
767
- db: AsyncSession,
768
- chat_id: int
769
- ) -> AsyncGenerator[models.SenderSegment, None]:
770
- """
771
- Streams unscored sender segments one by one.
772
- """
773
- stmt = (
774
- select(models.SenderSegment)
775
- .join(models.TimeSegment)
776
- .where(models.TimeSegment.chat_id == chat_id)
777
- .outerjoin(models.SegmentSentiment)
778
- .where(models.SegmentSentiment.id == None)
779
- )
780
-
781
- stream = await db.stream_scalars(stmt)
782
- async for seg in stream:
783
- yield seg
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/app/db/base.py DELETED
@@ -1,3 +0,0 @@
1
- from sqlalchemy.orm import declarative_base
2
-
3
- Base = declarative_base()
 
 
 
 
src/app/db/session.py DELETED
@@ -1,58 +0,0 @@
1
- from typing import AsyncGenerator
2
- from sqlalchemy.ext.asyncio import (
3
- AsyncSession,
4
- create_async_engine,
5
- async_sessionmaker
6
- )
7
-
8
-
9
- from sqlalchemy.pool import NullPool, QueuePool
10
- import os
11
-
12
- from src.app.config import settings
13
- import logging
14
-
15
-
16
- logging.getLogger('sqlalchemy.engine').setLevel(logging.WARNING)
17
- logging.getLogger('sqlalchemy.pool').setLevel(logging.WARNING)
18
-
19
- DATABASE_URL = str(settings.DATABASE_URL)
20
-
21
- IS_CELERY_WORKER = os.getenv("IS_CELERY_WORKER", "false").lower() == "true"
22
-
23
- engine_args = {
24
- "echo": False,
25
- "echo_pool": False,
26
- "logging_name": 'sqlalchemy.engine',
27
- "pool_pre_ping": True,
28
- }
29
-
30
- if IS_CELERY_WORKER:
31
- engine_args["poolclass"] = NullPool
32
- else:
33
- engine_args["pool_size"] = 10
34
- engine_args["max_overflow"] = 0
35
- engine_args["pool_timeout"] = 30
36
- engine_args["pool_recycle"] = 3600
37
-
38
- engine = create_async_engine(
39
- DATABASE_URL,
40
-
41
- **engine_args
42
- )
43
-
44
-
45
- AsyncSessionLocal = async_sessionmaker(
46
- bind=engine,
47
- class_=AsyncSession,
48
- autoflush=False,
49
- autocommit=False,
50
- expire_on_commit=False,
51
- )
52
-
53
- async def get_db() -> AsyncGenerator[AsyncSession, None]:
54
- """
55
- Dependency injection to get a new async session.
56
- """
57
- async with AsyncSessionLocal() as session:
58
- yield session
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/app/limiter.py DELETED
@@ -1,4 +0,0 @@
1
- from slowapi import Limiter
2
- from slowapi.util import get_remote_address
3
-
4
- limiter = Limiter(key_func=get_remote_address)
 
 
 
 
 
src/app/logging_config.py DELETED
@@ -1,42 +0,0 @@
1
- import logging.config
2
-
3
- LOGGING_CONFIG = {
4
- "version": 1,
5
- "disable_existing_loggers": False,
6
- "formatters": {
7
- "standard": {
8
- "format": "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
9
- },
10
- },
11
- "handlers": {
12
- "default": {
13
- "level": "INFO",
14
- "formatter": "standard",
15
- "class": "logging.StreamHandler",
16
- "stream": "ext://sys.stdout",
17
- },
18
- "sqlalchemy": {
19
- "level": "WARNING",
20
- "formatter": "standard",
21
- "class": "logging.StreamHandler",
22
- "stream": "ext://sys.stdout",
23
- }
24
- },
25
- "loggers": {
26
- "": { # Root logger
27
- "handlers": ["default"],
28
- "level": "INFO",
29
- "propagate": True
30
- },
31
- "sqlalchemy.engine": {
32
- "handlers": ["sqlalchemy"],
33
- "level": "WARNING",
34
- "propagate": False
35
- },
36
- "sqlalchemy.pool": {
37
- "handlers": ["sqlalchemy"],
38
- "level": "WARNING",
39
- "propagate": False
40
- }
41
- }
42
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/app/main.py DELETED
@@ -1,211 +0,0 @@
1
- from fastapi import FastAPI, Request, HTTPException
2
- from fastapi.responses import JSONResponse
3
- from fastapi.exceptions import RequestValidationError
4
- from fastapi.middleware.cors import CORSMiddleware
5
- from fastapi.middleware.trustedhost import TrustedHostMiddleware
6
- from starlette.middleware.base import BaseHTTPMiddleware
7
- from starlette.middleware import Middleware
8
- from slowapi.errors import RateLimitExceeded
9
- from slowapi import _rate_limit_exceeded_handler
10
- from contextlib import asynccontextmanager
11
- from fastapi.responses import JSONResponse
12
- from fastapi import Request
13
- from sqlalchemy.exc import SQLAlchemyError
14
- import asyncio
15
- import logging
16
- import socket
17
- import uuid
18
- import time
19
- from typing import Callable
20
-
21
-
22
- from src.app.config import settings, setup_logging
23
- from src.app.db.session import engine
24
- from src.app.routers import auth, uploads, chats, sentiment_sse, dashboard, sentiment_dashboard, rag
25
- from src.app.limiter import limiter
26
- setup_logging()
27
- logger = logging.getLogger(__name__)
28
- APP_VERSION = "v1"
29
-
30
-
31
- class RequestIDMiddleware(BaseHTTPMiddleware):
32
- async def dispatch(self, request: Request, call_next: Callable):
33
- request_id = request.headers.get("X-Request-ID") or str(uuid.uuid4())
34
- request.state.request_id = request_id
35
- start = time.time()
36
- try:
37
- response = await call_next(request)
38
- except Exception:
39
-
40
- logger.exception("Unhandled error during request %s %s", request.method, request.url)
41
- raise
42
- process_time = (time.time() - start) * 1000
43
- response.headers["X-Request-ID"] = request_id
44
- response.headers["X-Process-Time-ms"] = f"{process_time:.2f}"
45
- logger.info("%s %s %s -> %s (%.2fms) [rid=%s]",
46
- request.client.host if request.client else "-",
47
- request.method, request.url.path,
48
- getattr(response, "status_code", "-"), process_time, request_id)
49
- return response
50
-
51
-
52
- async def test_db_connection():
53
-
54
- async with engine.begin() as conn:
55
- await conn.run_sync(lambda sync_conn: None)
56
-
57
- @asynccontextmanager
58
- async def lifespan(app: FastAPI):
59
-
60
- logger.info("Starting application: %s", settings.APP_NAME if hasattr(settings, "APP_NAME") else "whatsapp-sentiment")
61
-
62
- if settings.DEBUG and settings.ENVIRONMENT == "production":
63
- logger.critical("DEBUG mode is enabled while ENVIRONMENT==production")
64
- raise RuntimeError("Invalid configuration: DEBUG cannot be True in production")
65
-
66
- # Validate CORS config early
67
- if settings.CORS_ORIGINS == ["*"] and settings.CORS_ALLOW_CREDENTIALS:
68
-
69
- logger.critical("CORS misconfiguration: allow_origins='*' cannot be used with allow_credentials=True")
70
- raise RuntimeError("Invalid CORS configuration: '*' may not be used with credentials")
71
-
72
-
73
- max_attempts = getattr(settings, "DB_CONNECT_RETRIES", 3)
74
- backoff = getattr(settings, "DB_CONNECT_BACKOFF_SECONDS", 2)
75
- connected = False
76
- last_exc = None
77
- for attempt in range(1, max_attempts + 1):
78
- try:
79
- await test_db_connection()
80
- connected = True
81
- logger.info("✅ Database connection successful")
82
- break
83
- except Exception as exc:
84
- last_exc = exc
85
- logger.warning("Attempt %d/%d - DB connection failed: %s", attempt, max_attempts, exc)
86
- await asyncio.sleep(backoff * attempt)
87
-
88
- if not connected:
89
- logger.critical("Could not connect to the database after %d attempts. Shutting down.", max_attempts)
90
-
91
- try:
92
- await engine.dispose()
93
- except Exception:
94
- logger.exception("Error while disposing engine after failed startup")
95
-
96
- raise RuntimeError("Database connection failed")
97
- yield
98
-
99
-
100
- logger.info("Shutting down application gracefully")
101
- try:
102
- await engine.dispose()
103
- except Exception:
104
- logger.exception("Failed to dispose DB engine during shutdown")
105
-
106
-
107
- middleware = [
108
- Middleware(RequestIDMiddleware),
109
-
110
- Middleware(TrustedHostMiddleware, allowed_hosts=settings.TRUSTED_HOSTS or ["*"]),
111
- ]
112
-
113
- app = FastAPI(title="WhatsApp Sentiment Analysis API", lifespan=lifespan, middleware=middleware)
114
-
115
- app.state.limiter = limiter
116
- app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
117
-
118
- app.add_middleware(
119
- CORSMiddleware,
120
- allow_origins=settings.CORS_ORIGINS,
121
- allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
122
- allow_headers=["*"],
123
- allow_credentials=getattr(settings, "CORS_ALLOW_CREDENTIALS", True),
124
- expose_headers=["X-Request-ID", "X-Process-Time-ms"],
125
- )
126
-
127
-
128
- @app.exception_handler(HTTPException)
129
- async def http_exception_handler(request: Request, exc: HTTPException):
130
-
131
- logger.warning("HTTPException (%s) - %s %s - %s", exc.status_code, request.method, request.url, exc.detail)
132
- return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
133
-
134
- @app.exception_handler(RequestValidationError)
135
- async def validation_exception_handler(request: Request, exc: RequestValidationError):
136
- logger.warning("Validation error for %s %s: %s", request.method, request.url, exc.errors())
137
- return JSONResponse(status_code=422, content={"detail": exc.errors()})
138
-
139
- @app.exception_handler(OSError)
140
- async def os_exception_handler(request: Request, exc: OSError):
141
-
142
- if isinstance(exc, socket.gaierror):
143
- logger.exception("DNS/host resolution error while handling request %s %s: %s", request.method, request.url, exc)
144
- return JSONResponse(status_code=502, content={"detail": "Upstream host resolution failed"})
145
- logger.exception("OS error while handling request %s %s", request.method, request.url)
146
- return JSONResponse(status_code=500, content={"detail": "Internal server error"})
147
-
148
- @app.exception_handler(Exception)
149
- async def unhandled_exception_handler(request: Request, exc: Exception):
150
-
151
- logger.exception("Unhandled exception for request %s %s", request.method, request.url)
152
- return JSONResponse(status_code=500, content={"detail": "Internal server error"})
153
-
154
- @app.exception_handler(SQLAlchemyError)
155
- async def db_exception_handler(request: Request, exc: SQLAlchemyError):
156
- """
157
- Globally catches Database errors (connection pool exhaustion, timeouts).
158
- Returns a 503 Service Unavailable instead of crashing the server.
159
- """
160
- # Log the full error for your debugging
161
- # log.error(f"Database error: {str(exc)}")
162
-
163
- return JSONResponse(
164
- status_code=503,
165
- content={
166
- "detail": "Service is busy. Database connection limit reached. Please try again in a moment."
167
- },
168
- )
169
-
170
- @app.get("/", tags=["health"])
171
- async def root():
172
- return {"ok": True, "message": "Welcome to the WhatsApp Sentiment Analysis API"}
173
-
174
- @app.get("/health/live", tags=["health"])
175
- async def liveness_probe():
176
- return {"status": "alive"}
177
-
178
- @app.get("/health/ready", tags=["health"])
179
- async def readiness_probe():
180
-
181
- try:
182
- async with engine.connect() as conn:
183
- await conn.execute("SELECT 1")
184
- except Exception:
185
- logger.exception("Readiness check failed: DB not available")
186
- return JSONResponse(status_code=503, content={"status": "unavailable"})
187
- return {"status": "ready"}
188
-
189
- # -----------------------------------------------------------------------------
190
- # Include routers
191
- # -----------------------------------------------------------------------------
192
- app.include_router(auth.router, prefix=f"/api/{APP_VERSION}/auth", tags=["auth"])
193
- app.include_router(uploads.router, prefix=f"/api/{APP_VERSION}/uploads", tags=["uploads"])
194
- app.include_router(chats.router, prefix=f"/api/{APP_VERSION}/chats", tags=["chats"])
195
- app.include_router(sentiment_sse.router, prefix=f"/api/{APP_VERSION}/sentiment", tags=["sentiment"])
196
- app.include_router(dashboard.router, prefix=f"/api/{APP_VERSION}/dashboard", tags=["dashboard"])
197
- app.include_router(sentiment_dashboard.router, prefix=f"/api/{APP_VERSION}/sentiment-dashboard", tags=["Sentiment Dashboard"])
198
- app.include_router(rag.router, prefix=f"/api/{APP_VERSION}/rag", tags=["RAG Chat"])
199
-
200
-
201
- def _log_startup_banner():
202
- masked_db = "(hidden)"
203
- try:
204
- masked_db = str(settings.DATABASE_URL)[:100] + "..."
205
- except Exception:
206
- pass
207
-
208
- logger.info("Application startup complete")
209
- logger.info("Environment: %s | Debug: %s | CORS origins: %s", settings.ENVIRONMENT, settings.DEBUG, settings.CORS_ORIGINS)
210
- logger.info("Database: %s", masked_db)
211
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/app/models.py DELETED
@@ -1,329 +0,0 @@
1
- from datetime import datetime, timezone
2
- from typing import List, Optional, Dict
3
- from sqlalchemy import (
4
- JSON, Integer, String, DateTime, Text, ForeignKey, Float,
5
- UniqueConstraint, Boolean, BigInteger, func, Index
6
- )
7
- from sqlalchemy.orm import relationship, Mapped, mapped_column
8
- import uuid
9
- from sqlalchemy.dialects.postgresql import UUID
10
- from src.app.db.base import Base
11
- from src.app.schemas import SentimentStatusEnum, EmbeddingStatusEnum
12
- from pgvector.sqlalchemy import Vector
13
-
14
-
15
- class User(Base):
16
- __tablename__ = 'users'
17
- id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
18
- created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
19
- chats: Mapped[List["Chat"]] = relationship(
20
- "Chat",
21
- back_populates="owner",
22
- lazy="selectin",
23
- passive_deletes=True
24
- )
25
-
26
- class Chat(Base):
27
- __tablename__ = "chats"
28
-
29
- id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
30
- title: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
31
- created_at: Mapped[datetime] = mapped_column(
32
- DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), index=True
33
- )
34
- sentiment_status: Mapped[SentimentStatusEnum] = mapped_column(
35
- String(30), nullable=False, default=SentimentStatusEnum.pending.value, index=True
36
- )
37
- # In your models.py
38
-
39
- embeddings_status: Mapped[EmbeddingStatusEnum] = mapped_column(
40
- String(30),
41
- nullable=False,
42
- default=EmbeddingStatusEnum.pending.value, # Keeps Python logic happy
43
- server_default=EmbeddingStatusEnum.pending.value, # <--- ADDS SQL DEFAULT
44
- index=True
45
- )
46
- cancel_requested: Mapped[Optional[bool]] = mapped_column(Boolean, default=False)
47
- owner_id: Mapped[uuid.UUID] = mapped_column(
48
- ForeignKey("users.id", ondelete="CASCADE"),
49
- nullable=False,
50
- index=True
51
- )
52
- messages: Mapped[List["Message"]] = relationship(
53
- "Message",
54
- back_populates="chat",
55
- lazy="selectin",
56
- passive_deletes=True
57
- )
58
- time_segments: Mapped[List["TimeSegment"]] = relationship(
59
- "TimeSegment",
60
- back_populates="chat",
61
- lazy="selectin",
62
- passive_deletes=True
63
- )
64
-
65
- history: Mapped[List["ConversationHistory"]] = relationship(
66
- "ConversationHistory",
67
- back_populates="chat",
68
- lazy="selectin",
69
- passive_deletes=True
70
- )
71
- owner: Mapped["User"] = relationship(
72
- "User",
73
- back_populates="chats",
74
- lazy="selectin",
75
- passive_deletes=True
76
- )
77
-
78
-
79
- class Participant(Base):
80
- __tablename__ = "participants"
81
-
82
- id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
83
- chat_id: Mapped[int] = mapped_column(
84
- ForeignKey("chats.id", ondelete="CASCADE"), nullable=False, index=True
85
- )
86
- name: Mapped[str] = mapped_column(String(255), nullable=False)
87
- created_at: Mapped[datetime] = mapped_column(
88
- DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), index=True
89
- )
90
-
91
- __table_args__ = (UniqueConstraint("chat_id", "name", name="uq_participant_chat_name"),)
92
-
93
- messages: Mapped[List["Message"]] = relationship(
94
- "Message",
95
- back_populates="participant",
96
- lazy="selectin",
97
- passive_deletes=True
98
- )
99
- sender_segments: Mapped[List["SenderSegment"]] = relationship(
100
- "SenderSegment",
101
- back_populates="participant",
102
- lazy="selectin",
103
- passive_deletes=True
104
- )
105
-
106
-
107
- class Message(Base):
108
- __tablename__ = "messages"
109
-
110
- id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
111
- chat_id: Mapped[int] = mapped_column(
112
- ForeignKey("chats.id", ondelete="CASCADE"), nullable=False, index=True
113
- )
114
- participant_id: Mapped[Optional[int]] = mapped_column(
115
- ForeignKey("participants.id", ondelete="SET NULL"), nullable=True, index=True
116
- )
117
- timestamp: Mapped[datetime] = mapped_column(DateTime, nullable=False, index=True)
118
- content: Mapped[str] = mapped_column(Text, nullable=False)
119
- raw: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
120
-
121
- # Analytics fields
122
- word_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
123
- emojis_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
124
- links_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
125
- is_question: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
126
- is_media: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
127
-
128
- chat: Mapped["Chat"] = relationship(
129
- "Chat",
130
- back_populates="messages",
131
- lazy="selectin",
132
- passive_deletes=True
133
- )
134
- participant: Mapped[Optional["Participant"]] = relationship(
135
- "Participant",
136
- back_populates="messages",
137
- lazy="selectin"
138
- )
139
- sentiment: Mapped[Optional["MessageSentiment"]] = relationship(
140
- "MessageSentiment",
141
- back_populates="message",
142
- uselist=False,
143
- lazy="joined",
144
- passive_deletes=True
145
- )
146
-
147
-
148
- class SenderSegment(Base):
149
- __tablename__ = "segments_sender"
150
-
151
- id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
152
- time_segment_id: Mapped[int] = mapped_column(
153
- ForeignKey("segments_time.id", ondelete="CASCADE"), nullable=False, index=True
154
- )
155
- sender_id: Mapped[int] = mapped_column(
156
- ForeignKey("participants.id", ondelete="CASCADE"), nullable=False, index=True
157
- )
158
- message_count: Mapped[int] = mapped_column(Integer, default=0)
159
- combined_text: Mapped[str] = mapped_column(Text, nullable=False)
160
-
161
- time_segment: Mapped["TimeSegment"] = relationship(
162
- "TimeSegment",
163
- back_populates="sender_segments",
164
- lazy="selectin",
165
- passive_deletes=True
166
- )
167
- participant: Mapped["Participant"] = relationship(
168
- "Participant",
169
- back_populates="sender_segments",
170
- lazy="selectin",
171
- passive_deletes=True
172
- )
173
- sentiment: Mapped[Optional["SegmentSentiment"]] = relationship(
174
- "SegmentSentiment",
175
- back_populates="sender_segment",
176
- uselist=False,
177
- lazy="joined",
178
- passive_deletes=True
179
- )
180
- class TimeSegment(Base):
181
- __tablename__ = "segments_time"
182
-
183
- id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
184
- chat_id: Mapped[int] = mapped_column(
185
- ForeignKey("chats.id", ondelete="CASCADE"), nullable=False, index=True
186
- )
187
- start_time: Mapped[datetime] = mapped_column(DateTime, nullable=False, index=True)
188
- end_time: Mapped[datetime] = mapped_column(DateTime, nullable=False)
189
- duration_minutes: Mapped[float] = mapped_column(Float, nullable=False)
190
- message_count: Mapped[int] = mapped_column(Integer, default=0)
191
- summary: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
192
-
193
- chat: Mapped["Chat"] = relationship(
194
- "Chat",
195
- back_populates="time_segments",
196
- lazy="selectin",
197
- passive_deletes=True
198
- )
199
- sender_segments: Mapped[List["SenderSegment"]] = relationship(
200
- "SenderSegment",
201
- back_populates="time_segment",
202
- lazy="selectin",
203
- passive_deletes=True
204
- )
205
-
206
-
207
- class MessageSentiment(Base):
208
- __tablename__ = "message_sentiments"
209
-
210
- id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
211
- message_id: Mapped[int] = mapped_column(
212
- ForeignKey("messages.id", ondelete="CASCADE"),
213
- unique=True,
214
- nullable=False,
215
- index=True
216
- )
217
- overall_label: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
218
- overall_label_score: Mapped[float] = mapped_column(Float, nullable=False, index=True)
219
- score_positive: Mapped[Optional[float]] = mapped_column(Float)
220
- score_negative: Mapped[Optional[float]] = mapped_column(Float)
221
- score_neutral: Mapped[Optional[float]] = mapped_column(Float)
222
- sentences_summary: Mapped[Optional[Dict]] = mapped_column(JSON, nullable=True)
223
- opinions: Mapped[Optional[Dict]] = mapped_column(JSON, nullable=True)
224
- api_version: Mapped[Optional[str]] = mapped_column(String(50))
225
- analysis_timestamp: Mapped[datetime] = mapped_column(
226
- DateTime(timezone=True),
227
- default=lambda: datetime.now(timezone.utc),
228
- index=True
229
- )
230
- error_code: Mapped[Optional[str]] = mapped_column(String(100))
231
- error_message: Mapped[Optional[str]] = mapped_column(Text)
232
-
233
- message: Mapped["Message"] = relationship(
234
- "Message",
235
- back_populates="sentiment",
236
- lazy="joined",
237
- passive_deletes=True
238
- )
239
-
240
-
241
- class SegmentSentiment(Base):
242
- __tablename__ = "segment_sentiments"
243
-
244
- id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
245
- sender_segment_id: Mapped[int] = mapped_column(
246
- ForeignKey("segments_sender.id", ondelete="CASCADE"),
247
- unique=True,
248
- nullable=False,
249
- index=True
250
- )
251
- overall_label: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
252
- overall_label_score: Mapped[float] = mapped_column(Float, nullable=False, index=True)
253
- score_positive: Mapped[Optional[float]] = mapped_column(Float)
254
- score_negative: Mapped[Optional[float]] = mapped_column(Float)
255
- score_neutral: Mapped[Optional[float]] = mapped_column(Float)
256
- sentences_summary: Mapped[Optional[Dict]] = mapped_column(JSON, nullable=True)
257
- opinions: Mapped[Optional[Dict]] = mapped_column(JSON, nullable=True)
258
- api_version: Mapped[Optional[str]] = mapped_column(String(50))
259
- analysis_timestamp: Mapped[datetime] = mapped_column(
260
- DateTime(timezone=True),
261
- default=lambda: datetime.now(timezone.utc),
262
- index=True
263
- )
264
- error_code: Mapped[Optional[str]] = mapped_column(String(100))
265
- error_message: Mapped[Optional[str]] = mapped_column(Text)
266
-
267
- sender_segment: Mapped["SenderSegment"] = relationship(
268
- "SenderSegment",
269
- back_populates="sentiment",
270
- lazy="joined",
271
- passive_deletes=True
272
- )
273
-
274
- # Stub table for reference purpose
275
- class Embedding(Base):
276
- __tablename__ = "embeddings"
277
-
278
- id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
279
- chat_id: Mapped[int] = mapped_column(
280
- ForeignKey("chats.id", ondelete="CASCADE"), nullable=False
281
- )
282
- source_table: Mapped[str] = mapped_column(Text, nullable=False)
283
- source_id: Mapped[int] = mapped_column(Integer, nullable=False)
284
-
285
- embedding: Mapped[List[float]] = mapped_column(Vector(1536), nullable=False)
286
-
287
- text_excerpt: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
288
- chunk_metadata: Mapped[Optional[dict]] = mapped_column(JSON, default={})
289
- created_at: Mapped[datetime] = mapped_column(
290
- DateTime(timezone=True), server_default=func.now()
291
- )
292
-
293
- __table_args__ = (
294
- # Match the 'embeddings_chat_id_idx' from your manual migration
295
- Index('embeddings_chat_id_idx', 'chat_id'),
296
-
297
- # Match the composite index 'embeddings_chat_source_idx'
298
- Index('embeddings_chat_source_idx', 'chat_id', 'source_table', 'source_id'),
299
-
300
- # Match the IVFFlat vector index
301
- Index(
302
- 'embeddings_embedding_ivfflat',
303
- 'embedding',
304
- postgresql_using='ivfflat',
305
- postgresql_with={'lists': 100},
306
- postgresql_ops={'embedding': 'vector_l2_ops'}
307
- ),
308
- )
309
-
310
- class ConversationHistory(Base):
311
- __tablename__ = "conversation_history"
312
-
313
- id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
314
- chat_id: Mapped[int] = mapped_column(
315
- ForeignKey("chats.id", ondelete="CASCADE"), nullable=False, index=True
316
- )
317
- role: Mapped[str] = mapped_column(String(20), nullable=False) # "user" or "assistant"
318
- content: Mapped[str] = mapped_column(Text, nullable=False)
319
- sources: Mapped[Optional[List[Dict]]] = mapped_column(JSON, nullable=True)
320
- created_at: Mapped[datetime] = mapped_column(
321
- DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
322
- )
323
-
324
- chat: Mapped["Chat"] = relationship(
325
- "Chat",
326
- back_populates="history",
327
- lazy="selectin",
328
- passive_deletes=True
329
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/app/routers/__init__.py DELETED
File without changes
src/app/routers/auth.py DELETED
@@ -1,36 +0,0 @@
1
- from fastapi import APIRouter, Depends
2
- from sqlalchemy.ext.asyncio import AsyncSession
3
- from datetime import datetime, timedelta, timezone
4
- import jwt
5
-
6
- from src.app.config import settings
7
- from src.app.db.session import get_db
8
- from src.app import models
9
- from src.app.security import ALGORITHM, ACCESS_TOKEN_EXPIRE_MINUTES
10
-
11
- router = APIRouter()
12
-
13
- @router.post("/guest", response_model=dict)
14
- async def create_guest_session(db: AsyncSession = Depends(get_db)):
15
- """
16
- Creates a new anonymous user and returns a long-lived JWT.
17
- The frontend should call this ONCE on first load and store the token.
18
- """
19
- # 1. Create a new Anonymous User
20
- new_user = models.User()
21
- db.add(new_user)
22
- await db.commit()
23
- await db.refresh(new_user)
24
-
25
- # 2. GeneratT
26
- expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
27
-
28
- # "sub" holds the User UUID
29
- to_encode = {"sub": str(new_user.id), "exp": expire}
30
- encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
31
-
32
- return {
33
- "access_token": encoded_jwt,
34
- "token_type": "bearer",
35
- "user_id": str(new_user.id)
36
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/app/routers/chats.py DELETED
@@ -1,63 +0,0 @@
1
- from fastapi import APIRouter, Depends, HTTPException, status, Request
2
- from sqlalchemy.ext.asyncio import AsyncSession
3
- from sqlalchemy import select
4
- import asyncio
5
- from src.app.config import settings
6
- from src.app.services.cleanup_service import delete_single_chat_background
7
- from src.app.db.session import get_db
8
- from src.app import crud, schemas, models
9
- from src.app.security import get_current_user
10
- from src.app.limiter import limiter
11
- import logging
12
- log = logging.getLogger(__name__)
13
-
14
- router = APIRouter()
15
-
16
- @router.delete("/{chat_id}", response_model=dict, status_code=status.HTTP_200_OK)
17
- @limiter.limit("5/minute")
18
- async def delete_chat(
19
- request: Request,
20
- chat_id: int,
21
- db: AsyncSession = Depends(get_db),
22
- current_user: models.User = Depends(get_current_user)
23
- ):
24
- """
25
- Delete a chat and all its related data.
26
-
27
- """
28
- try:
29
-
30
- stmt = select(models.Chat).where(models.Chat.id == chat_id).with_for_update()
31
- result = await db.execute(stmt)
32
- chat = result.scalars().first()
33
-
34
- if not chat:
35
- # We must commit/rollback to release the implicit transaction lock if we error out
36
- await db.rollback()
37
- raise HTTPException(status_code=404, detail="Chat not found")
38
-
39
- # 2. Verify Ownership
40
- if chat.owner_id != current_user.id:
41
- await db.rollback()
42
- raise HTTPException(status_code=404, detail="Chat not found")
43
-
44
- # 3. Check Status
45
- if chat.sentiment_status == "processing":
46
- await db.rollback()
47
- raise HTTPException(
48
- status_code=409,
49
- detail="Sentiment analysis is still running. Please cancel the job first."
50
- )
51
-
52
- await db.rollback() # Release the lock before starting background task
53
-
54
- asyncio.create_task(delete_single_chat_background(chat_id))
55
-
56
- return {"ok": True, "message": f"Chat {chat_id} deleted successfully"}
57
-
58
- except HTTPException:
59
- raise
60
- except Exception as e:
61
- await db.rollback()
62
- log.error(f"Error scheduling delete for chat {chat_id}: {e}")
63
- raise HTTPException(status_code=500, detail="Internal server error")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/app/routers/dashboard.py DELETED
@@ -1,83 +0,0 @@
1
- from datetime import datetime
2
- from typing import List, Optional
3
- from fastapi import APIRouter, Depends, Query, HTTPException, Request
4
- from sqlalchemy.ext.asyncio import AsyncSession
5
-
6
- from src.app.schemas import DashboardData, DashboardFilters
7
- from src.app.db.session import get_db
8
- from src.app import crud, models
9
- from src.app.services.dashboard_service import get_dashboard_data
10
- from src.app.security import get_current_user
11
- from src.app.limiter import limiter
12
-
13
- router = APIRouter()
14
-
15
- def get_dashboard_filters(
16
- start_date: Optional[datetime] = Query(
17
- None,
18
- description="Start date for filtering (ISO 8601 format)"
19
- ),
20
- end_date: Optional[datetime] = Query(
21
- None,
22
- description="End date for filtering (ISO 8601 format)"
23
- ),
24
- participants: Optional[List[str]] = Query(
25
- None,
26
- description="List of participant names to include (can be used multiple times)"
27
- ),
28
- time_period: Optional[str] = Query(
29
- None,
30
- description="Time period to filter by",
31
- # This enum adds validation and OpenAPI docs for free
32
- enum=["Morning", "Afternoon", "Evening", "Night"]
33
- )
34
- ) -> DashboardFilters:
35
- """
36
- Dependency that parses GET query parameters into a
37
- DashboardFilters Pydantic model.
38
- """
39
- return DashboardFilters(
40
- start_date=start_date,
41
- end_date=end_date,
42
- participants=participants,
43
- time_period=time_period
44
- )
45
-
46
-
47
- # --- Phase 1: Endpoint Definition ---
48
-
49
- @router.get(
50
- "/chats/{chat_id}",
51
- response_model=DashboardData,
52
- summary="Get Chat Dashboard Analytics"
53
- )
54
- @limiter.limit("20/minute")
55
- async def read_chat_dashboard(
56
- request: Request,
57
- chat_id: int,
58
- db: AsyncSession = Depends(get_db),
59
- filters: DashboardFilters = Depends(get_dashboard_filters),
60
- current_user: models.User = Depends(get_current_user)
61
- ):
62
- """
63
- Retrieve a comprehensive analytics dashboard for a specific chat.
64
-
65
- This endpoint provides all data necessary to populate the dashboard,
66
- with optional filters for:
67
-
68
- - **Date Range**: `start_date` and `end_date`
69
- - **Participants**: `participants` (e.g., ?participants=User+A&participants=User+B)
70
- - **Time of Day**: `time_period`
71
- """
72
-
73
- chat = await crud.get_chat(db, chat_id=chat_id)
74
- if not chat or chat.owner_id != current_user.id:
75
- raise HTTPException(status_code=404, detail="Chat not found")
76
-
77
- dashboard_data = await get_dashboard_data(chat_id=chat_id, db=db, filters=filters)
78
-
79
- if not dashboard_data:
80
- # The service will return None if the chat_id doesn't exist
81
- raise HTTPException(status_code=404, detail="Chat not found or no data available.")
82
-
83
- return dashboard_data
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/app/routers/rag.py DELETED
@@ -1,120 +0,0 @@
1
- import logging
2
- from fastapi import APIRouter, Depends, HTTPException, Request
3
- from fastapi.responses import StreamingResponse, JSONResponse
4
- from sqlalchemy.ext.asyncio import AsyncSession
5
- from langchain_core.messages import HumanMessage, AIMessage
6
- from sqlalchemy import select
7
- from typing import List, Optional
8
- from src.app.db.session import get_db
9
- from src.app import crud, models
10
- from src.app.schemas import RagQueryRequest, ConversationHistoryItem
11
- from src.app.security import get_current_user
12
- from src.app.services import router_service
13
- from src.app.limiter import limiter
14
-
15
- router = APIRouter()
16
- log = logging.getLogger(__name__)
17
-
18
- # --- NEW ENDPOINT START ---
19
- @router.get("/chat/{chat_id}/status")
20
- async def get_chat_status(
21
- chat_id: int,
22
- db: AsyncSession = Depends(get_db),
23
- current_user: models.User = Depends(get_current_user)
24
- ):
25
- # Single query to check ownership AND get status at the same time
26
- # This prevents double DB hits (one for auth, one for status)
27
- query = select(models.Chat.embeddings_status).where(
28
- models.Chat.id == chat_id,
29
- models.Chat.owner_id == current_user.id
30
- )
31
- result = await db.execute(query)
32
- status = result.scalar_one_or_none()
33
-
34
- if status is None:
35
- raise HTTPException(status_code=404, detail="Chat not found")
36
-
37
- return JSONResponse(content={"status": status})
38
-
39
-
40
- @router.post(
41
- "/chat/{chat_id}/query/streamed"
42
- )
43
- @limiter.limit("5/minute")
44
- async def query_chat_streamed(
45
- request: Request,
46
- chat_id: int,
47
- payload: RagQueryRequest,
48
- db: AsyncSession = Depends(get_db),
49
- current_user: models.User = Depends(get_current_user)
50
- ):
51
- """
52
- Main endpoint for asking questions to a chat (Streaming).
53
- """
54
-
55
- chat = await crud.get_chat(db, chat_id=chat_id)
56
- if not chat or chat.owner_id != current_user.id:
57
- raise HTTPException(status_code=404, detail="Chat not found")
58
-
59
-
60
-
61
- history_objs = await crud.get_conversation_history(db, chat_id, limit=6)
62
- chat_history_messages = []
63
- for h in history_objs:
64
- if h.role == "user":
65
- chat_history_messages.append(HumanMessage(content=h.content))
66
- else:
67
- chat_history_messages.append(AIMessage(content=h.content))
68
-
69
- analytics_data = payload.analytics_json or {}
70
- return StreamingResponse(
71
- router_service.route_and_process(
72
- query=payload.question,
73
- analytics_json=analytics_data,
74
- chat_id=chat_id,
75
- db=db,
76
- chat_history=chat_history_messages
77
- ),
78
- media_type="text/event-stream"
79
- )
80
-
81
-
82
-
83
-
84
-
85
- @router.delete("/chat/{chat_id}/history", status_code=204)
86
- @limiter.limit("10/minute")
87
- async def clear_chat_history(
88
- request: Request,
89
- chat_id: int,
90
- db: AsyncSession = Depends(get_db),
91
- current_user: models.User = Depends(get_current_user)
92
- ):
93
- """
94
- Clears the conversation memory for this chat.
95
- """
96
-
97
- chat = await crud.get_chat(db, chat_id=chat_id)
98
- if not chat or chat.owner_id != current_user.id:
99
- raise HTTPException(status_code=404, detail="Chat not found")
100
-
101
-
102
- await crud.clear_conversation_history(db, chat_id)
103
-
104
- return
105
-
106
- @router.get(
107
- "/chat/{chat_id}/history",
108
- response_model=List[ConversationHistoryItem]
109
- )
110
- async def get_chat_history(
111
- chat_id: int,
112
- db: AsyncSession = Depends(get_db),
113
- current_user: models.User = Depends(get_current_user)
114
- ):
115
- chat = await crud.get_chat(db, chat_id=chat_id)
116
- if not chat or chat.owner_id != current_user.id:
117
- raise HTTPException(status_code=404, detail="Chat not found")
118
-
119
- history = await crud.get_full_conversation_history(db, chat_id)
120
- return history
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/app/routers/rag_streamed.py DELETED
@@ -1,75 +0,0 @@
1
- # src/app/routers/rag.py
2
-
3
- import logging
4
- from fastapi import APIRouter, Depends, HTTPException
5
-
6
- from sqlalchemy.ext.asyncio import AsyncSession
7
- from typing import List
8
- from src.app.db.session import get_db
9
- from src.app import crud
10
- from src.app.schemas import RagQueryRequest, RagQueryResponse
11
- from src.app.services import rag_service_prev
12
- from src.app.schemas import ConversationHistoryItem
13
-
14
- router = APIRouter()
15
- log = logging.getLogger(__name__)
16
-
17
- @router.post(
18
- "/chat/{chat_id}/query",
19
- response_model=RagQueryResponse
20
- )
21
- async def query_chat(
22
- chat_id: int,
23
- payload: RagQueryRequest,
24
- db: AsyncSession = Depends(get_db)
25
- ):
26
- """
27
- Main endpoint for asking questions to a chat.
28
-
29
- """
30
-
31
- chat = await crud.get_chat(db, chat_id=chat_id)
32
- if not chat:
33
- raise HTTPException(status_code=404, detail="Chat not found")
34
-
35
-
36
- try:
37
- response = await rag_service_prev.process_rag_query(chat_id, payload)
38
- return response
39
- except Exception as e:
40
- log.error(f"RAG endpoint failed for chat {chat_id}: {e}", exc_info=True)
41
- raise HTTPException(status_code=500, detail="Error processing your question.")
42
-
43
-
44
- @router.delete("/chat/{chat_id}/history", status_code=204)
45
- async def clear_chat_history(
46
- chat_id: int,
47
- db: AsyncSession = Depends(get_db)
48
- ):
49
- """
50
- Clears the conversation memory for this chat.
51
- """
52
-
53
- chat = await crud.get_chat(db, chat_id=chat_id)
54
- if not chat:
55
- raise HTTPException(status_code=404, detail="Chat not found")
56
-
57
-
58
- await crud.clear_conversation_history(db, chat_id)
59
-
60
- return
61
-
62
- @router.get(
63
- "/chat/{chat_id}/history",
64
- response_model=List[ConversationHistoryItem]
65
- )
66
- async def get_chat_history(
67
- chat_id: int,
68
- db: AsyncSession = Depends(get_db)
69
- ):
70
- chat = await crud.get_chat(db, chat_id=chat_id)
71
- if not chat:
72
- raise HTTPException(status_code=404, detail="Chat not found")
73
-
74
- history = await crud.get_full_conversation_history(db, chat_id)
75
- return history