Hamdy005 commited on
Commit
c1fa8be
Β·
1 Parent(s): 9295b1e

fix: Fixing requirements.txt inconsistencies

Browse files
Dockerfile CHANGED
@@ -6,6 +6,7 @@ WORKDIR /app
6
  RUN apt-get update && apt-get install -y --no-install-recommends \
7
  ffmpeg \
8
  libsndfile1 \
 
9
  && rm -rf /var/lib/apt/lists/*
10
 
11
  COPY requirements.txt .
 
6
  RUN apt-get update && apt-get install -y --no-install-recommends \
7
  ffmpeg \
8
  libsndfile1 \
9
+ git \
10
  && rm -rf /var/lib/apt/lists/*
11
 
12
  COPY requirements.txt .
alembic.ini ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ # Or organize into date-based subdirectories (requires recursive_version_locations = true)
16
+ # file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
17
+
18
+ # sys.path path, will be prepended to sys.path if present.
19
+ # defaults to the current working directory. for multiple paths, the path separator
20
+ # is defined by "path_separator" below.
21
+ prepend_sys_path = .
22
+
23
+
24
+ # timezone to use when rendering the date within the migration file
25
+ # as well as the filename.
26
+ # If specified, requires the tzdata library which can be installed by adding
27
+ # `alembic[tz]` to the pip requirements.
28
+ # string value is passed to ZoneInfo()
29
+ # leave blank for localtime
30
+ # timezone =
31
+
32
+ # max length of characters to apply to the "slug" field
33
+ # truncate_slug_length = 40
34
+
35
+ # set to 'true' to run the environment during
36
+ # the 'revision' command, regardless of autogenerate
37
+ # revision_environment = false
38
+
39
+ # set to 'true' to allow .pyc and .pyo files without
40
+ # a source .py file to be detected as revisions in the
41
+ # versions/ directory
42
+ # sourceless = false
43
+
44
+ # version location specification; This defaults
45
+ # to <script_location>/versions. When using multiple version
46
+ # directories, initial revisions must be specified with --version-path.
47
+ # The path separator used here should be the separator specified by "path_separator"
48
+ # below.
49
+ # version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
50
+
51
+ # path_separator; This indicates what character is used to split lists of file
52
+ # paths, including version_locations and prepend_sys_path within configparser
53
+ # files such as alembic.ini.
54
+ # The default rendered in new alembic.ini files is "os", which uses os.pathsep
55
+ # to provide os-dependent path splitting.
56
+ #
57
+ # Note that in order to support legacy alembic.ini files, this default does NOT
58
+ # take place if path_separator is not present in alembic.ini. If this
59
+ # option is omitted entirely, fallback logic is as follows:
60
+ #
61
+ # 1. Parsing of the version_locations option falls back to using the legacy
62
+ # "version_path_separator" key, which if absent then falls back to the legacy
63
+ # behavior of splitting on spaces and/or commas.
64
+ # 2. Parsing of the prepend_sys_path option falls back to the legacy
65
+ # behavior of splitting on spaces, commas, or colons.
66
+ #
67
+ # Valid values for path_separator are:
68
+ #
69
+ # path_separator = :
70
+ # path_separator = ;
71
+ # path_separator = space
72
+ # path_separator = newline
73
+ #
74
+ # Use os.pathsep. Default configuration used for new projects.
75
+ path_separator = os
76
+
77
+ # set to 'true' to search source files recursively
78
+ # in each "version_locations" directory
79
+ # new in Alembic version 1.10
80
+ # recursive_version_locations = false
81
+
82
+ # the output encoding used when revision files
83
+ # are written from script.py.mako
84
+ # output_encoding = utf-8
85
+
86
+ # database URL. This is consumed by the user-maintained env.py script only.
87
+ # other means of configuring database URLs may be customized within the env.py
88
+ # file.
89
+ sqlalchemy.url = driver://user:pass@localhost/dbname
90
+
91
+
92
+ [post_write_hooks]
93
+ # post_write_hooks defines scripts or Python functions that are run
94
+ # on newly generated revision scripts. See the documentation for further
95
+ # detail and examples
96
+
97
+ # format using "black" - use the console_scripts runner, against the "black" entrypoint
98
+ # hooks = black
99
+ # black.type = console_scripts
100
+ # black.entrypoint = black
101
+ # black.options = -l 79 REVISION_SCRIPT_FILENAME
102
+
103
+ # lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
104
+ # hooks = ruff
105
+ # ruff.type = module
106
+ # ruff.module = ruff
107
+ # ruff.options = check --fix REVISION_SCRIPT_FILENAME
108
+
109
+ # Alternatively, use the exec runner to execute a binary found on your PATH
110
+ # hooks = ruff
111
+ # ruff.type = exec
112
+ # ruff.executable = ruff
113
+ # ruff.options = check --fix REVISION_SCRIPT_FILENAME
114
+
115
+ # Logging configuration. This is also consumed by the user-maintained
116
+ # env.py script only.
117
+ [loggers]
118
+ keys = root,sqlalchemy,alembic
119
+
120
+ [handlers]
121
+ keys = console
122
+
123
+ [formatters]
124
+ keys = generic
125
+
126
+ [logger_root]
127
+ level = WARNING
128
+ handlers = console
129
+ qualname =
130
+
131
+ [logger_sqlalchemy]
132
+ level = WARNING
133
+ handlers =
134
+ qualname = sqlalchemy.engine
135
+
136
+ [logger_alembic]
137
+ level = INFO
138
+ handlers =
139
+ qualname = alembic
140
+
141
+ [handler_console]
142
+ class = StreamHandler
143
+ args = (sys.stderr,)
144
+ level = NOTSET
145
+ formatter = generic
146
+
147
+ [formatter_generic]
148
+ format = %(levelname)-5.5s [%(name)s] %(message)s
149
+ datefmt = %H:%M:%S
alembic/README ADDED
@@ -0,0 +1 @@
 
 
1
+ Generic single-database configuration.
alembic/env.py ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import re
3
+ import sys
4
+ import urllib.parse
5
+ from logging.config import fileConfig
6
+
7
+ from sqlalchemy import engine_from_config, pool
8
+ from alembic import context
9
+
10
+ # Ensure both src and project root / app root are on sys.path
11
+ _here = os.path.dirname(os.path.abspath(__file__))
12
+ _parent = os.path.abspath(os.path.join(_here, ".."))
13
+ _grandparent = os.path.abspath(os.path.join(_parent, ".."))
14
+ for _p in (_grandparent, _parent):
15
+ if _p and _p not in sys.path:
16
+ sys.path.insert(0, _p)
17
+
18
+ from src.config import settings
19
+ from src.db_models import Base
20
+
21
+
22
+ # this is the Alembic Config object, which provides
23
+ # access to the values within the .ini file in use.
24
+ config = context.config
25
+
26
+ # Interpret the config file for Python logging.
27
+ if config.config_file_name is not None:
28
+ fileConfig(config.config_file_name)
29
+
30
+ # Set target metadata for autogenerate support
31
+ target_metadata = Base.metadata
32
+
33
+
34
+ def sanitize_db_url(url: str) -> str:
35
+ """Sanitize database URL by stripping pgbouncer flags and URL-encoding password special chars."""
36
+ if not url or "://" not in url:
37
+ return url
38
+ if "pgbouncer=true" in url:
39
+ url = url.replace("?pgbouncer=true&", "?").replace("&pgbouncer=true", "").replace("?pgbouncer=true", "")
40
+
41
+ # URL-encode un-encoded special characters in password
42
+ pattern = r"^(?P<scheme>[^:]+://)(?P<user>[^:]+):(?P<password>.+)@(?P<host>[^@:]+:\d+/.+)$"
43
+ m = re.match(pattern, url)
44
+ if m:
45
+ scheme = m.group("scheme")
46
+ user = m.group("user")
47
+ password = m.group("password")
48
+ host = m.group("host")
49
+ if "%" not in password:
50
+ password = urllib.parse.quote(password, safe="")
51
+ return f"{scheme}{user}:{password}@{host}"
52
+ return url
53
+
54
+
55
+ # Dynamically inject DATABASE_URL from config.env / settings
56
+ db_url = sanitize_db_url(settings.database_url or os.getenv("DATABASE_URL", ""))
57
+
58
+ if db_url:
59
+ # Escape % as %% for configparser interpolation
60
+ config.set_main_option("sqlalchemy.url", db_url.replace("%", "%%"))
61
+
62
+
63
+ def run_migrations_offline() -> None:
64
+ """Run migrations in 'offline' mode."""
65
+ context.configure(
66
+ url=db_url,
67
+ target_metadata=target_metadata,
68
+ literal_binds=True,
69
+ dialect_opts={"paramstyle": "named"},
70
+ compare_type=my_compare_type,
71
+ )
72
+
73
+ with context.begin_transaction():
74
+ context.run_migrations()
75
+
76
+
77
+ def my_compare_type(context, inspected_column, metadata_column, inspected_type, metadata_type):
78
+ # Ignore equivalent string/text type differences between Supabase TEXT and SQLAlchemy String
79
+ ins_type = type(inspected_type).__name__.upper()
80
+ meta_type = type(metadata_type).__name__.upper()
81
+ if ins_type in ("TEXT", "VARCHAR", "STRING") and meta_type in ("TEXT", "VARCHAR", "STRING"):
82
+ return False
83
+ return None
84
+
85
+
86
+ def include_object(object, name, type_, reflected, compare_to):
87
+ """
88
+ Only report table-level additions and removals.
89
+ Ignore column, index, FK, and constraint differences on existing tables
90
+ to avoid noise from schema drift between Supabase native schema and ORM models.
91
+ """
92
+ # Always include tables (so new/removed tables are detected)
93
+ if type_ == "table":
94
+ return True
95
+ # Skip everything else on tables that already exist in the DB
96
+ return False
97
+
98
+
99
+ def run_migrations_online() -> None:
100
+ """Run migrations in 'online' mode."""
101
+ if not db_url:
102
+ return
103
+
104
+ configuration = config.get_section(config.config_ini_section, {})
105
+ if db_url:
106
+ configuration["sqlalchemy.url"] = db_url
107
+
108
+ connectable = engine_from_config(
109
+ configuration,
110
+ prefix="sqlalchemy.",
111
+ poolclass=pool.NullPool,
112
+ )
113
+
114
+ with connectable.connect() as connection:
115
+ context.configure(
116
+ connection=connection,
117
+ target_metadata=target_metadata,
118
+ compare_type=False,
119
+ compare_nullable=False,
120
+ include_object=include_object,
121
+ )
122
+
123
+ with context.begin_transaction():
124
+ context.run_migrations()
125
+
126
+
127
+ if context.is_offline_mode():
128
+ run_migrations_offline()
129
+ else:
130
+ run_migrations_online()
alembic/script.py.mako ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ try:
13
+ import pgvector
14
+ except ImportError:
15
+ pass
16
+ ${imports if imports else ""}
17
+
18
+
19
+ # revision identifiers, used by Alembic.
20
+ revision: str = ${repr(up_revision)}
21
+ down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
22
+ branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
23
+ depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
24
+
25
+
26
+ def upgrade() -> None:
27
+ """Upgrade schema."""
28
+ ${upgrades if upgrades else "pass"}
29
+
30
+
31
+ def downgrade() -> None:
32
+ """Downgrade schema."""
33
+ ${downgrades if downgrades else "pass"}
alembic/versions/046aabbd3fee_removing_refresh_tokens_table.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Removing Refresh Tokens Table
2
+
3
+ Revision ID: 046aabbd3fee
4
+ Revises: 0f6538b84133
5
+ Create Date: 2026-08-15 06:27:29.825530
6
+
7
+ """
8
+ from typing import Sequence, Union
9
+
10
+ from alembic import op
11
+ import sqlalchemy as sa
12
+ try:
13
+ import pgvector
14
+ except ImportError:
15
+ pass
16
+ from sqlalchemy.dialects import postgresql
17
+
18
+
19
+ # revision identifiers, used by Alembic.
20
+ revision: str = '046aabbd3fee'
21
+ down_revision: Union[str, Sequence[str], None] = '0f6538b84133'
22
+ branch_labels: Union[str, Sequence[str], None] = None
23
+ depends_on: Union[str, Sequence[str], None] = None
24
+
25
+
26
+ def upgrade() -> None:
27
+ """Upgrade schema."""
28
+ # ### commands auto generated by Alembic - please adjust! ###
29
+ op.drop_table('refresh_tokens')
30
+ # ### end Alembic commands ###
31
+
32
+
33
+ def downgrade() -> None:
34
+ """Downgrade schema."""
35
+ # ### commands auto generated by Alembic - please adjust! ###
36
+ op.create_table('refresh_tokens',
37
+ sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), autoincrement=False, nullable=False),
38
+ sa.Column('user_id', sa.UUID(), autoincrement=False, nullable=False),
39
+ sa.Column('token_hash', sa.TEXT(), autoincrement=False, nullable=False),
40
+ sa.Column('expires_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=False),
41
+ sa.Column('revoked', sa.BOOLEAN(), server_default=sa.text('false'), autoincrement=False, nullable=False),
42
+ sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=False),
43
+ sa.ForeignKeyConstraint(['user_id'], ['profiles.id'], name=op.f('refresh_tokens_user_id_fkey'), ondelete='CASCADE'),
44
+ sa.PrimaryKeyConstraint('id', name=op.f('refresh_tokens_pkey')),
45
+ sa.UniqueConstraint('token_hash', name=op.f('refresh_tokens_token_hash_key'), postgresql_include=[], postgresql_nulls_not_distinct=False)
46
+ )
47
+ # ### end Alembic commands ###
alembic/versions/0f6538b84133_initial_supabase_schema.py ADDED
@@ -0,0 +1,346 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Initial Supabase schema
2
+
3
+ Revision ID: 0f6538b84133
4
+ Revises:
5
+ Create Date: 2026-08-09 22:55:09.716607
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
+ import pgvector
14
+
15
+
16
+ # revision identifiers, used by Alembic.
17
+ revision: str = '0f6538b84133'
18
+ down_revision: Union[str, Sequence[str], None] = None
19
+ branch_labels: Union[str, Sequence[str], None] = None
20
+ depends_on: Union[str, Sequence[str], None] = None
21
+
22
+
23
+ def upgrade() -> None:
24
+ """Upgrade schema."""
25
+ # ### commands auto generated by Alembic - please adjust! ###
26
+ op.alter_column('chat_messages', 'role',
27
+ existing_type=sa.TEXT(),
28
+ type_=sa.String(),
29
+ nullable=True)
30
+ op.alter_column('chat_messages', 'content',
31
+ existing_type=sa.TEXT(),
32
+ nullable=True)
33
+ op.alter_column('chat_messages', 'retrieved_chunk_ids',
34
+ existing_type=postgresql.ARRAY(sa.UUID()),
35
+ type_=postgresql.JSONB(astext_type=sa.Text()),
36
+ existing_nullable=True)
37
+ op.drop_index(op.f('idx_chat_messages_session_id'), table_name='chat_messages')
38
+ op.alter_column('chat_sessions', 'material_id',
39
+ existing_type=sa.UUID(),
40
+ nullable=False)
41
+ op.alter_column('chat_sessions', 'title',
42
+ existing_type=sa.TEXT(),
43
+ type_=sa.String(),
44
+ existing_nullable=True)
45
+ op.drop_index(op.f('idx_chat_sessions_material_id'), table_name='chat_sessions')
46
+ op.drop_index(op.f('idx_chat_sessions_user_id'), table_name='chat_sessions')
47
+ op.drop_constraint(op.f('chat_sessions_material_id_fkey'), 'chat_sessions', type_='foreignkey')
48
+ op.create_foreign_key(None, 'chat_sessions', 'materials', ['material_id'], ['id'], ondelete='CASCADE')
49
+ op.drop_index(op.f('idx_material_chunks_material_id'), table_name='material_chunks')
50
+ op.drop_constraint(op.f('material_chunks_material_id_chunk_index_key'), 'material_chunks', type_='unique')
51
+ op.alter_column('material_embeddings', 'embedding',
52
+ existing_type=pgvector.sqlalchemy.vector.VECTOR(dim=384),
53
+ nullable=True)
54
+ op.drop_index(op.f('idx_embeddings_chunk_id'), table_name='material_embeddings')
55
+ op.drop_index(op.f('idx_embeddings_material_id'), table_name='material_embeddings')
56
+ op.drop_index(op.f('idx_embeddings_vector'), table_name='material_embeddings', postgresql_with={'lists': '100'}, postgresql_using='ivfflat')
57
+ op.alter_column('materials', 'source_type',
58
+ existing_type=sa.TEXT(),
59
+ type_=sa.String(),
60
+ existing_nullable=False)
61
+ op.alter_column('materials', 'title',
62
+ existing_type=sa.TEXT(),
63
+ type_=sa.String(),
64
+ existing_nullable=False)
65
+ op.alter_column('materials', 'file_path',
66
+ existing_type=sa.TEXT(),
67
+ type_=sa.String(),
68
+ existing_nullable=True)
69
+ op.alter_column('materials', 'url',
70
+ existing_type=sa.TEXT(),
71
+ type_=sa.String(),
72
+ existing_nullable=True)
73
+ op.alter_column('materials', 'status',
74
+ existing_type=sa.TEXT(),
75
+ type_=sa.String(),
76
+ existing_nullable=False,
77
+ existing_server_default=sa.text("'pending'::text"))
78
+ op.alter_column('materials', 'error_message',
79
+ existing_type=sa.TEXT(),
80
+ type_=sa.String(),
81
+ existing_nullable=True)
82
+ op.alter_column('materials', 'vector_store_path',
83
+ existing_type=sa.TEXT(),
84
+ type_=sa.String(),
85
+ existing_nullable=True)
86
+ op.alter_column('materials', 'title_normalized',
87
+ existing_type=sa.TEXT(),
88
+ type_=sa.String(),
89
+ existing_nullable=True,
90
+ existing_server_default=sa.Computed('normalize_arabic(lower(title))', persisted=True))
91
+ op.drop_index(op.f('idx_materials_status'), table_name='materials')
92
+ op.drop_index(op.f('idx_materials_title_normalized_trgm'), table_name='materials', postgresql_using='gin')
93
+ op.drop_index(op.f('idx_materials_title_trgm'), table_name='materials', postgresql_using='gin')
94
+ op.drop_index(op.f('idx_materials_user_id'), table_name='materials')
95
+ op.drop_constraint(op.f('materials_user_id_title_key'), 'materials', type_='unique')
96
+ op.alter_column('profiles', 'email',
97
+ existing_type=sa.TEXT(),
98
+ type_=sa.String(),
99
+ existing_nullable=True)
100
+ op.alter_column('profiles', 'display_name',
101
+ existing_type=sa.TEXT(),
102
+ type_=sa.String(),
103
+ existing_nullable=True)
104
+ op.alter_column('profiles', 'avatar_url',
105
+ existing_type=sa.TEXT(),
106
+ type_=sa.String(),
107
+ existing_nullable=True)
108
+ op.alter_column('profiles', 'last_request_date',
109
+ existing_type=sa.DATE(),
110
+ type_=sa.String(),
111
+ existing_nullable=True)
112
+ op.alter_column('profiles', 'theme',
113
+ existing_type=sa.TEXT(),
114
+ type_=sa.String(),
115
+ existing_nullable=False,
116
+ existing_server_default=sa.text("'system'::text"))
117
+ op.drop_constraint(op.f('profiles_id_fkey'), 'profiles', type_='foreignkey')
118
+ op.alter_column('quiz_attempts', 'score',
119
+ existing_type=sa.INTEGER(),
120
+ nullable=True)
121
+ op.alter_column('quiz_attempts', 'total',
122
+ existing_type=sa.INTEGER(),
123
+ nullable=True)
124
+ op.alter_column('quiz_attempts', 'results',
125
+ existing_type=postgresql.JSONB(astext_type=sa.Text()),
126
+ nullable=True)
127
+ op.drop_index(op.f('idx_quiz_attempts_quiz_id'), table_name='quiz_attempts')
128
+ op.alter_column('quizzes', 'source_type',
129
+ existing_type=sa.TEXT(),
130
+ type_=sa.String(),
131
+ nullable=True)
132
+ op.alter_column('quizzes', 'difficulty',
133
+ existing_type=sa.TEXT(),
134
+ type_=sa.String(),
135
+ nullable=True)
136
+ op.alter_column('quizzes', 'mcq_count',
137
+ existing_type=sa.INTEGER(),
138
+ nullable=True)
139
+ op.alter_column('quizzes', 'tf_count',
140
+ existing_type=sa.INTEGER(),
141
+ nullable=True)
142
+ op.alter_column('quizzes', 'quiz_data',
143
+ existing_type=postgresql.JSONB(astext_type=sa.Text()),
144
+ nullable=True)
145
+ op.alter_column('quizzes', 'status',
146
+ existing_type=sa.TEXT(),
147
+ type_=sa.String(),
148
+ existing_nullable=False,
149
+ existing_server_default=sa.text("'completed'::text"))
150
+ op.alter_column('quizzes', 'model_name',
151
+ existing_type=sa.TEXT(),
152
+ type_=sa.String(),
153
+ existing_nullable=True)
154
+ op.alter_column('quizzes', 'error_message',
155
+ existing_type=sa.TEXT(),
156
+ type_=sa.String(),
157
+ existing_nullable=True)
158
+ op.drop_index(op.f('idx_quizzes_material_id'), table_name='quizzes')
159
+ op.drop_index(op.f('idx_quizzes_user_id'), table_name='quizzes')
160
+ op.drop_constraint(op.f('quizzes_material_id_fkey'), 'quizzes', type_='foreignkey')
161
+ op.create_foreign_key(None, 'quizzes', 'materials', ['material_id'], ['id'], ondelete='CASCADE')
162
+ op.alter_column('refresh_tokens', 'token_hash',
163
+ existing_type=sa.TEXT(),
164
+ type_=sa.String(),
165
+ existing_nullable=False)
166
+ op.drop_index(op.f('idx_refresh_tokens_token_hash'), table_name='refresh_tokens')
167
+ op.drop_index(op.f('idx_refresh_tokens_user_id'), table_name='refresh_tokens')
168
+ op.alter_column('summaries', 'status',
169
+ existing_type=sa.TEXT(),
170
+ type_=sa.String(),
171
+ existing_nullable=False,
172
+ existing_server_default=sa.text("'completed'::text"))
173
+ op.alter_column('summaries', 'model_name',
174
+ existing_type=sa.TEXT(),
175
+ type_=sa.String(),
176
+ existing_nullable=True)
177
+ op.alter_column('summaries', 'error_message',
178
+ existing_type=sa.TEXT(),
179
+ type_=sa.String(),
180
+ existing_nullable=True)
181
+ op.drop_index(op.f('idx_summaries_material_id'), table_name='summaries')
182
+ op.drop_constraint(op.f('summaries_material_id_key'), 'summaries', type_='unique')
183
+ # ### end Alembic commands ###
184
+
185
+
186
+ def downgrade() -> None:
187
+ """Downgrade schema."""
188
+ # ### commands auto generated by Alembic - please adjust! ###
189
+ op.create_unique_constraint(op.f('summaries_material_id_key'), 'summaries', ['material_id'], postgresql_nulls_not_distinct=False)
190
+ op.create_index(op.f('idx_summaries_material_id'), 'summaries', ['material_id'], unique=False)
191
+ op.alter_column('summaries', 'error_message',
192
+ existing_type=sa.String(),
193
+ type_=sa.TEXT(),
194
+ existing_nullable=True)
195
+ op.alter_column('summaries', 'model_name',
196
+ existing_type=sa.String(),
197
+ type_=sa.TEXT(),
198
+ existing_nullable=True)
199
+ op.alter_column('summaries', 'status',
200
+ existing_type=sa.String(),
201
+ type_=sa.TEXT(),
202
+ existing_nullable=False,
203
+ existing_server_default=sa.text("'completed'::text"))
204
+ op.create_index(op.f('idx_refresh_tokens_user_id'), 'refresh_tokens', ['user_id'], unique=False)
205
+ op.create_index(op.f('idx_refresh_tokens_token_hash'), 'refresh_tokens', ['token_hash'], unique=False)
206
+ op.alter_column('refresh_tokens', 'token_hash',
207
+ existing_type=sa.String(),
208
+ type_=sa.TEXT(),
209
+ existing_nullable=False)
210
+ op.drop_constraint(None, 'quizzes', type_='foreignkey')
211
+ op.create_foreign_key(op.f('quizzes_material_id_fkey'), 'quizzes', 'materials', ['material_id'], ['id'], ondelete='SET NULL')
212
+ op.create_index(op.f('idx_quizzes_user_id'), 'quizzes', ['user_id'], unique=False)
213
+ op.create_index(op.f('idx_quizzes_material_id'), 'quizzes', ['material_id'], unique=False)
214
+ op.alter_column('quizzes', 'error_message',
215
+ existing_type=sa.String(),
216
+ type_=sa.TEXT(),
217
+ existing_nullable=True)
218
+ op.alter_column('quizzes', 'model_name',
219
+ existing_type=sa.String(),
220
+ type_=sa.TEXT(),
221
+ existing_nullable=True)
222
+ op.alter_column('quizzes', 'status',
223
+ existing_type=sa.String(),
224
+ type_=sa.TEXT(),
225
+ existing_nullable=False,
226
+ existing_server_default=sa.text("'completed'::text"))
227
+ op.alter_column('quizzes', 'quiz_data',
228
+ existing_type=postgresql.JSONB(astext_type=sa.Text()),
229
+ nullable=False)
230
+ op.alter_column('quizzes', 'tf_count',
231
+ existing_type=sa.INTEGER(),
232
+ nullable=False)
233
+ op.alter_column('quizzes', 'mcq_count',
234
+ existing_type=sa.INTEGER(),
235
+ nullable=False)
236
+ op.alter_column('quizzes', 'difficulty',
237
+ existing_type=sa.String(),
238
+ type_=sa.TEXT(),
239
+ nullable=False)
240
+ op.alter_column('quizzes', 'source_type',
241
+ existing_type=sa.String(),
242
+ type_=sa.TEXT(),
243
+ nullable=False)
244
+ op.create_index(op.f('idx_quiz_attempts_quiz_id'), 'quiz_attempts', ['quiz_id'], unique=False)
245
+ op.alter_column('quiz_attempts', 'results',
246
+ existing_type=postgresql.JSONB(astext_type=sa.Text()),
247
+ nullable=False)
248
+ op.alter_column('quiz_attempts', 'total',
249
+ existing_type=sa.INTEGER(),
250
+ nullable=False)
251
+ op.alter_column('quiz_attempts', 'score',
252
+ existing_type=sa.INTEGER(),
253
+ nullable=False)
254
+ op.create_foreign_key(op.f('profiles_id_fkey'), 'profiles', 'users', ['id'], ['id'], referent_schema='auth', ondelete='CASCADE')
255
+ op.alter_column('profiles', 'theme',
256
+ existing_type=sa.String(),
257
+ type_=sa.TEXT(),
258
+ existing_nullable=False,
259
+ existing_server_default=sa.text("'system'::text"))
260
+ op.alter_column('profiles', 'last_request_date',
261
+ existing_type=sa.String(),
262
+ type_=sa.DATE(),
263
+ existing_nullable=True)
264
+ op.alter_column('profiles', 'avatar_url',
265
+ existing_type=sa.String(),
266
+ type_=sa.TEXT(),
267
+ existing_nullable=True)
268
+ op.alter_column('profiles', 'display_name',
269
+ existing_type=sa.String(),
270
+ type_=sa.TEXT(),
271
+ existing_nullable=True)
272
+ op.alter_column('profiles', 'email',
273
+ existing_type=sa.String(),
274
+ type_=sa.TEXT(),
275
+ existing_nullable=True)
276
+ op.create_unique_constraint(op.f('materials_user_id_title_key'), 'materials', ['user_id', 'title'], postgresql_nulls_not_distinct=False)
277
+ op.create_index(op.f('idx_materials_user_id'), 'materials', ['user_id'], unique=False)
278
+ op.create_index(op.f('idx_materials_title_trgm'), 'materials', ['title'], unique=False, postgresql_using='gin')
279
+ op.create_index(op.f('idx_materials_title_normalized_trgm'), 'materials', ['title_normalized'], unique=False, postgresql_using='gin')
280
+ op.create_index(op.f('idx_materials_status'), 'materials', ['status'], unique=False)
281
+ op.alter_column('materials', 'title_normalized',
282
+ existing_type=sa.String(),
283
+ type_=sa.TEXT(),
284
+ existing_nullable=True,
285
+ existing_server_default=sa.Computed('normalize_arabic(lower(title))', persisted=True))
286
+ op.alter_column('materials', 'vector_store_path',
287
+ existing_type=sa.String(),
288
+ type_=sa.TEXT(),
289
+ existing_nullable=True)
290
+ op.alter_column('materials', 'error_message',
291
+ existing_type=sa.String(),
292
+ type_=sa.TEXT(),
293
+ existing_nullable=True)
294
+ op.alter_column('materials', 'status',
295
+ existing_type=sa.String(),
296
+ type_=sa.TEXT(),
297
+ existing_nullable=False,
298
+ existing_server_default=sa.text("'pending'::text"))
299
+ op.alter_column('materials', 'url',
300
+ existing_type=sa.String(),
301
+ type_=sa.TEXT(),
302
+ existing_nullable=True)
303
+ op.alter_column('materials', 'file_path',
304
+ existing_type=sa.String(),
305
+ type_=sa.TEXT(),
306
+ existing_nullable=True)
307
+ op.alter_column('materials', 'title',
308
+ existing_type=sa.String(),
309
+ type_=sa.TEXT(),
310
+ existing_nullable=False)
311
+ op.alter_column('materials', 'source_type',
312
+ existing_type=sa.String(),
313
+ type_=sa.TEXT(),
314
+ existing_nullable=False)
315
+ op.create_index(op.f('idx_embeddings_vector'), 'material_embeddings', ['embedding'], unique=False, postgresql_with={'lists': '100'}, postgresql_using='ivfflat')
316
+ op.create_index(op.f('idx_embeddings_material_id'), 'material_embeddings', ['material_id'], unique=False)
317
+ op.create_index(op.f('idx_embeddings_chunk_id'), 'material_embeddings', ['chunk_id'], unique=False)
318
+ op.alter_column('material_embeddings', 'embedding',
319
+ existing_type=pgvector.sqlalchemy.vector.VECTOR(dim=384),
320
+ nullable=False)
321
+ op.create_unique_constraint(op.f('material_chunks_material_id_chunk_index_key'), 'material_chunks', ['material_id', 'chunk_index'], postgresql_nulls_not_distinct=False)
322
+ op.create_index(op.f('idx_material_chunks_material_id'), 'material_chunks', ['material_id'], unique=False)
323
+ op.drop_constraint(None, 'chat_sessions', type_='foreignkey')
324
+ op.create_foreign_key(op.f('chat_sessions_material_id_fkey'), 'chat_sessions', 'materials', ['material_id'], ['id'], ondelete='SET NULL')
325
+ op.create_index(op.f('idx_chat_sessions_user_id'), 'chat_sessions', ['user_id'], unique=False)
326
+ op.create_index(op.f('idx_chat_sessions_material_id'), 'chat_sessions', ['material_id'], unique=False)
327
+ op.alter_column('chat_sessions', 'title',
328
+ existing_type=sa.String(),
329
+ type_=sa.TEXT(),
330
+ existing_nullable=True)
331
+ op.alter_column('chat_sessions', 'material_id',
332
+ existing_type=sa.UUID(),
333
+ nullable=True)
334
+ op.create_index(op.f('idx_chat_messages_session_id'), 'chat_messages', ['session_id'], unique=False)
335
+ op.alter_column('chat_messages', 'retrieved_chunk_ids',
336
+ existing_type=postgresql.JSONB(astext_type=sa.Text()),
337
+ type_=postgresql.ARRAY(sa.UUID()),
338
+ existing_nullable=True)
339
+ op.alter_column('chat_messages', 'content',
340
+ existing_type=sa.TEXT(),
341
+ nullable=False)
342
+ op.alter_column('chat_messages', 'role',
343
+ existing_type=sa.String(),
344
+ type_=sa.TEXT(),
345
+ nullable=False)
346
+ # ### end Alembic commands ###
main.py CHANGED
@@ -28,10 +28,33 @@ from src.config import settings
28
  async def lifespan(app: FastAPI):
29
  # Run pending Alembic database migrations automatically on server startup
30
  try:
31
- import subprocess
32
- logger.info("Running database migrations via Alembic...")
33
- subprocess.run(["alembic", "upgrade", "head"], check=True)
34
- logger.info("Database migrations completed successfully.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  except Exception as e:
36
  logger.warning(f"Database migration step failed or skipped: {e}")
37
 
 
28
  async def lifespan(app: FastAPI):
29
  # Run pending Alembic database migrations automatically on server startup
30
  try:
31
+ db_url = settings.database_url or os.getenv("DATABASE_URL", "")
32
+ if not db_url:
33
+ logger.info("DATABASE_URL not configured; skipping automatic Alembic migrations.")
34
+ else:
35
+ from alembic.config import Config
36
+ from alembic import command
37
+
38
+ candidate_ini = None
39
+ for p in [
40
+ os.path.join(current_dir, "alembic.ini"),
41
+ os.path.join(parent_dir, "alembic.ini"),
42
+ os.path.abspath("alembic.ini"),
43
+ ]:
44
+ if os.path.isfile(p):
45
+ candidate_ini = p
46
+ break
47
+
48
+ if candidate_ini:
49
+ logger.info(f"Running database migrations via Alembic ({candidate_ini})...")
50
+ alembic_cfg = Config(candidate_ini)
51
+ script_dir = os.path.join(os.path.dirname(candidate_ini), "alembic")
52
+ if os.path.isdir(script_dir):
53
+ alembic_cfg.set_main_option("script_location", script_dir)
54
+ command.upgrade(alembic_cfg, "head")
55
+ logger.info("Database migrations completed successfully.")
56
+ else:
57
+ logger.warning("alembic.ini not found; skipping database migrations.")
58
  except Exception as e:
59
  logger.warning(f"Database migration step failed or skipped: {e}")
60
 
rag/rag.py CHANGED
@@ -56,6 +56,10 @@ from .schemas import EmbeddingJob
56
 
57
  @lru_cache
58
  def get_embedder():
 
 
 
 
59
  return HuggingFaceEmbeddings(
60
  model_name="sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2",
61
  model_kwargs={"device": "cpu"},
 
56
 
57
  @lru_cache
58
  def get_embedder():
59
+ try:
60
+ import sentence_transformers
61
+ except Exception as exc:
62
+ logger.error(f"Failed to import sentence_transformers directly: {exc}", exc_info=True)
63
  return HuggingFaceEmbeddings(
64
  model_name="sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2",
65
  model_kwargs={"device": "cpu"},
requirements.txt CHANGED
@@ -1,6 +1,7 @@
1
  --extra-index-url https://download.pytorch.org/whl/cpu
2
  torch
3
  torchvision
 
4
 
5
  arxiv==2.2.0
6
  wikipedia==1.4.0
@@ -16,9 +17,16 @@ mistralai
16
  langchain-openai
17
  langchain-huggingface
18
  langchain-text-splitters
 
19
  python-dotenv
20
- sentence-transformers
21
- numpy
 
 
 
 
 
 
22
 
23
  pdfplumber==0.11.4
24
  unstructured==0.18.15
@@ -35,9 +43,11 @@ passlib[bcrypt]>=1.7.4
35
  supabase
36
  redis>=5.0.0
37
  transformers
 
38
  sacremoses
39
  sentencepiece
40
  omegaconf
 
41
 
42
  # ── ASR Models ──────────────────────────────────────────
43
  nemo_toolkit[asr]
 
1
  --extra-index-url https://download.pytorch.org/whl/cpu
2
  torch
3
  torchvision
4
+ torchaudio
5
 
6
  arxiv==2.2.0
7
  wikipedia==1.4.0
 
17
  langchain-openai
18
  langchain-huggingface
19
  langchain-text-splitters
20
+ langsmith
21
  python-dotenv
22
+
23
+ # ── Retrieval & Search ────────────────────────────────────
24
+ sentence-transformers>=3.0.0
25
+ rank-bm25>=0.2.2
26
+
27
+ # ── Binary Compatibility (NumPy & PyArrow) ────────────────
28
+ numpy>=1.26.4,<2.0.0
29
+ pyarrow>=14.0.0,<18.0.0
30
 
31
  pdfplumber==0.11.4
32
  unstructured==0.18.15
 
43
  supabase
44
  redis>=5.0.0
45
  transformers
46
+ einops
47
  sacremoses
48
  sentencepiece
49
  omegaconf
50
+ onnxruntime>=1.17.0
51
 
52
  # ── ASR Models ──────────────────────────────────────────
53
  nemo_toolkit[asr]